mirror of
https://github.com/langgenius/dify.git
synced 2026-02-14 07:15:35 +08:00
Compare commits
6 Commits
feat/defau
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
| c1bea059b7 | |||
| be97957237 | |||
| 64950df9cf | |||
| dfa46cd113 | |||
| 64c8d6004d | |||
| 72b96ba972 |
@ -553,8 +553,6 @@ WORKFLOW_LOG_CLEANUP_ENABLED=false
|
||||
WORKFLOW_LOG_RETENTION_DAYS=30
|
||||
# Batch size for workflow log cleanup operations (default: 100)
|
||||
WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
|
||||
# Comma-separated list of workflow IDs to clean logs for
|
||||
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
|
||||
|
||||
# App configuration
|
||||
APP_MAX_EXECUTION_TIME=1200
|
||||
|
||||
@ -265,11 +265,6 @@ class PluginConfig(BaseSettings):
|
||||
default=60 * 60,
|
||||
)
|
||||
|
||||
PLUGIN_MAX_FILE_SIZE: PositiveInt = Field(
|
||||
description="Maximum allowed size (bytes) for plugin-generated files",
|
||||
default=50 * 1024 * 1024,
|
||||
)
|
||||
|
||||
|
||||
class MarketplaceConfig(BaseSettings):
|
||||
"""
|
||||
@ -1319,9 +1314,6 @@ class WorkflowLogConfig(BaseSettings):
|
||||
WORKFLOW_LOG_CLEANUP_BATCH_SIZE: int = Field(
|
||||
default=100, description="Batch size for workflow run log cleanup operations"
|
||||
)
|
||||
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: str = Field(
|
||||
default="", description="Comma-separated list of workflow IDs to clean logs for"
|
||||
)
|
||||
|
||||
|
||||
class SwaggerUIConfig(BaseSettings):
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, PositiveInt
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@ -51,43 +49,3 @@ class OceanBaseVectorConfig(BaseSettings):
|
||||
),
|
||||
default="ik",
|
||||
)
|
||||
|
||||
OCEANBASE_VECTOR_BATCH_SIZE: PositiveInt = Field(
|
||||
description="Number of documents to insert per batch",
|
||||
default=100,
|
||||
)
|
||||
|
||||
OCEANBASE_VECTOR_METRIC_TYPE: Literal["l2", "cosine", "inner_product"] = Field(
|
||||
description="Distance metric type for vector index: l2, cosine, or inner_product",
|
||||
default="l2",
|
||||
)
|
||||
|
||||
OCEANBASE_HNSW_M: PositiveInt = Field(
|
||||
description="HNSW M parameter (max number of connections per node)",
|
||||
default=16,
|
||||
)
|
||||
|
||||
OCEANBASE_HNSW_EF_CONSTRUCTION: PositiveInt = Field(
|
||||
description="HNSW efConstruction parameter (index build-time search width)",
|
||||
default=256,
|
||||
)
|
||||
|
||||
OCEANBASE_HNSW_EF_SEARCH: int = Field(
|
||||
description="HNSW efSearch parameter (query-time search width, -1 uses server default)",
|
||||
default=-1,
|
||||
)
|
||||
|
||||
OCEANBASE_VECTOR_POOL_SIZE: PositiveInt = Field(
|
||||
description="SQLAlchemy connection pool size",
|
||||
default=5,
|
||||
)
|
||||
|
||||
OCEANBASE_VECTOR_MAX_OVERFLOW: int = Field(
|
||||
description="SQLAlchemy connection pool max overflow connections",
|
||||
default=10,
|
||||
)
|
||||
|
||||
OCEANBASE_HNSW_REFRESH_THRESHOLD: int = Field(
|
||||
description="Minimum number of inserted documents to trigger an automatic HNSW index refresh (0 to disable)",
|
||||
default=1000,
|
||||
)
|
||||
|
||||
@ -3,8 +3,6 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
# from core.plugin.entities.plugin import GenericProviderID, ToolProviderID
|
||||
from core.plugin.entities.plugin_daemon import CredentialType, PluginBasicBooleanResponse, PluginToolProviderEntity
|
||||
from core.plugin.impl.base import BasePluginClient
|
||||
@ -124,7 +122,7 @@ class PluginToolManager(BasePluginClient):
|
||||
},
|
||||
)
|
||||
|
||||
return merge_blob_chunks(response, max_file_size=dify_config.PLUGIN_MAX_FILE_SIZE)
|
||||
return merge_blob_chunks(response)
|
||||
|
||||
def validate_provider_credentials(
|
||||
self, tenant_id: str, user_id: str, provider: str, credentials: dict[str, Any]
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
from pyobvector import VECTOR, ObVecClient, cosine_distance, inner_product, l2_distance # type: ignore
|
||||
from pyobvector import VECTOR, ObVecClient, l2_distance # type: ignore
|
||||
from sqlalchemy import JSON, Column, String
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from configs import dify_config
|
||||
from core.rag.datasource.vdb.vector_base import BaseVector
|
||||
@ -20,14 +19,10 @@ from models.dataset import Dataset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_OCEANBASE_HNSW_BUILD_PARAM = {"M": 16, "efConstruction": 256}
|
||||
DEFAULT_OCEANBASE_HNSW_SEARCH_PARAM = {"efSearch": 64}
|
||||
OCEANBASE_SUPPORTED_VECTOR_INDEX_TYPE = "HNSW"
|
||||
_VALID_TABLE_NAME_RE = re.compile(r"^[a-zA-Z0-9_]+$")
|
||||
|
||||
_DISTANCE_FUNC_MAP = {
|
||||
"l2": l2_distance,
|
||||
"cosine": cosine_distance,
|
||||
"inner_product": inner_product,
|
||||
}
|
||||
DEFAULT_OCEANBASE_VECTOR_METRIC_TYPE = "l2"
|
||||
|
||||
|
||||
class OceanBaseVectorConfig(BaseModel):
|
||||
@ -37,14 +32,6 @@ class OceanBaseVectorConfig(BaseModel):
|
||||
password: str
|
||||
database: str
|
||||
enable_hybrid_search: bool = False
|
||||
batch_size: int = 100
|
||||
metric_type: Literal["l2", "cosine", "inner_product"] = "l2"
|
||||
hnsw_m: int = 16
|
||||
hnsw_ef_construction: int = 256
|
||||
hnsw_ef_search: int = -1
|
||||
pool_size: int = 5
|
||||
max_overflow: int = 10
|
||||
hnsw_refresh_threshold: int = 1000
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@ -62,23 +49,14 @@ class OceanBaseVectorConfig(BaseModel):
|
||||
|
||||
class OceanBaseVector(BaseVector):
|
||||
def __init__(self, collection_name: str, config: OceanBaseVectorConfig):
|
||||
if not _VALID_TABLE_NAME_RE.match(collection_name):
|
||||
raise ValueError(
|
||||
f"Invalid collection name '{collection_name}': "
|
||||
"only alphanumeric characters and underscores are allowed."
|
||||
)
|
||||
super().__init__(collection_name)
|
||||
self._config = config
|
||||
self._hnsw_ef_search = self._config.hnsw_ef_search
|
||||
self._hnsw_ef_search = -1
|
||||
self._client = ObVecClient(
|
||||
uri=f"{self._config.host}:{self._config.port}",
|
||||
user=self._config.user,
|
||||
password=self._config.password,
|
||||
db_name=self._config.database,
|
||||
pool_size=self._config.pool_size,
|
||||
max_overflow=self._config.max_overflow,
|
||||
pool_recycle=3600,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
self._fields: list[str] = [] # List of fields in the collection
|
||||
if self._client.check_table_exists(collection_name):
|
||||
@ -158,8 +136,8 @@ class OceanBaseVector(BaseVector):
|
||||
field_name="vector",
|
||||
index_type=OCEANBASE_SUPPORTED_VECTOR_INDEX_TYPE,
|
||||
index_name="vector_index",
|
||||
metric_type=self._config.metric_type,
|
||||
params={"M": self._config.hnsw_m, "efConstruction": self._config.hnsw_ef_construction},
|
||||
metric_type=DEFAULT_OCEANBASE_VECTOR_METRIC_TYPE,
|
||||
params=DEFAULT_OCEANBASE_HNSW_BUILD_PARAM,
|
||||
)
|
||||
|
||||
self._client.create_table_with_index_params(
|
||||
@ -200,17 +178,6 @@ class OceanBaseVector(BaseVector):
|
||||
else:
|
||||
logger.debug("DEBUG: Hybrid search is NOT enabled for '%s'", self._collection_name)
|
||||
|
||||
try:
|
||||
self._client.perform_raw_text_sql(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_metadata_doc_id ON `{self._collection_name}` "
|
||||
f"((CAST(metadata->>'$.document_id' AS CHAR(64))))"
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
logger.warning(
|
||||
"Failed to create metadata functional index on '%s'; metadata queries may be slow without it.",
|
||||
self._collection_name,
|
||||
)
|
||||
|
||||
self._client.refresh_metadata([self._collection_name])
|
||||
self._load_collection_fields()
|
||||
redis_client.set(collection_exist_cache_key, 1, ex=3600)
|
||||
@ -238,49 +205,24 @@ class OceanBaseVector(BaseVector):
|
||||
|
||||
def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs):
|
||||
ids = self._get_uuids(documents)
|
||||
batch_size = self._config.batch_size
|
||||
total = len(documents)
|
||||
|
||||
all_data = [
|
||||
{
|
||||
"id": doc_id,
|
||||
"vector": emb,
|
||||
"text": doc.page_content,
|
||||
"metadata": doc.metadata,
|
||||
}
|
||||
for doc_id, doc, emb in zip(ids, documents, embeddings)
|
||||
]
|
||||
|
||||
for start in range(0, total, batch_size):
|
||||
batch = all_data[start : start + batch_size]
|
||||
for id, doc, emb in zip(ids, documents, embeddings):
|
||||
try:
|
||||
self._client.insert(
|
||||
table_name=self._collection_name,
|
||||
data=batch,
|
||||
data={
|
||||
"id": id,
|
||||
"vector": emb,
|
||||
"text": doc.page_content,
|
||||
"metadata": doc.metadata,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to insert batch [%d:%d] into collection '%s'",
|
||||
start,
|
||||
start + len(batch),
|
||||
self._collection_name,
|
||||
)
|
||||
raise Exception(
|
||||
f"Failed to insert batch [{start}:{start + len(batch)}] into collection '{self._collection_name}'"
|
||||
) from e
|
||||
|
||||
if self._config.hnsw_refresh_threshold > 0 and total >= self._config.hnsw_refresh_threshold:
|
||||
try:
|
||||
self._client.refresh_index(
|
||||
table_name=self._collection_name,
|
||||
index_name="vector_index",
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
logger.warning(
|
||||
"Failed to refresh HNSW index after inserting %d documents into '%s'",
|
||||
total,
|
||||
"Failed to insert document with id '%s' in collection '%s'",
|
||||
id,
|
||||
self._collection_name,
|
||||
)
|
||||
raise Exception(f"Failed to insert document with id '{id}'") from e
|
||||
|
||||
def text_exists(self, id: str) -> bool:
|
||||
try:
|
||||
@ -470,7 +412,7 @@ class OceanBaseVector(BaseVector):
|
||||
vec_column_name="vector",
|
||||
vec_data=query_vector,
|
||||
topk=topk,
|
||||
distance_func=self._get_distance_func(),
|
||||
distance_func=l2_distance,
|
||||
output_column_names=["text", "metadata"],
|
||||
with_dist=True,
|
||||
where_clause=_where_clause,
|
||||
@ -482,31 +424,14 @@ class OceanBaseVector(BaseVector):
|
||||
)
|
||||
raise Exception(f"Vector search failed for collection '{self._collection_name}'") from e
|
||||
|
||||
# Convert distance to score and prepare results for processing
|
||||
results = []
|
||||
for _text, metadata_str, distance in cur:
|
||||
score = self._distance_to_score(distance)
|
||||
score = 1 - distance / math.sqrt(2)
|
||||
results.append((_text, metadata_str, score))
|
||||
|
||||
return self._process_search_results(results, score_threshold=score_threshold)
|
||||
|
||||
def _get_distance_func(self):
|
||||
func = _DISTANCE_FUNC_MAP.get(self._config.metric_type)
|
||||
if func is None:
|
||||
raise ValueError(
|
||||
f"Unsupported metric_type '{self._config.metric_type}'. Supported: {', '.join(_DISTANCE_FUNC_MAP)}"
|
||||
)
|
||||
return func
|
||||
|
||||
def _distance_to_score(self, distance: float) -> float:
|
||||
metric = self._config.metric_type
|
||||
if metric == "l2":
|
||||
return 1.0 / (1.0 + distance)
|
||||
elif metric == "cosine":
|
||||
return 1.0 - distance
|
||||
elif metric == "inner_product":
|
||||
return -distance
|
||||
raise ValueError(f"Unsupported metric_type '{metric}'")
|
||||
|
||||
def delete(self):
|
||||
try:
|
||||
self._client.drop_table_if_exist(self._collection_name)
|
||||
@ -539,13 +464,5 @@ class OceanBaseVectorFactory(AbstractVectorFactory):
|
||||
password=(dify_config.OCEANBASE_VECTOR_PASSWORD or ""),
|
||||
database=dify_config.OCEANBASE_VECTOR_DATABASE or "",
|
||||
enable_hybrid_search=dify_config.OCEANBASE_ENABLE_HYBRID_SEARCH or False,
|
||||
batch_size=dify_config.OCEANBASE_VECTOR_BATCH_SIZE,
|
||||
metric_type=dify_config.OCEANBASE_VECTOR_METRIC_TYPE,
|
||||
hnsw_m=dify_config.OCEANBASE_HNSW_M,
|
||||
hnsw_ef_construction=dify_config.OCEANBASE_HNSW_EF_CONSTRUCTION,
|
||||
hnsw_ef_search=dify_config.OCEANBASE_HNSW_EF_SEARCH,
|
||||
pool_size=dify_config.OCEANBASE_VECTOR_POOL_SIZE,
|
||||
max_overflow=dify_config.OCEANBASE_VECTOR_MAX_OVERFLOW,
|
||||
hnsw_refresh_threshold=dify_config.OCEANBASE_HNSW_REFRESH_THRESHOLD,
|
||||
),
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.13.0"
|
||||
version = "1.12.1"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
@ -67,7 +67,7 @@ dependencies = [
|
||||
"pycryptodome==3.23.0",
|
||||
"pydantic~=2.11.4",
|
||||
"pydantic-extra-types~=2.10.3",
|
||||
"pydantic-settings~=2.12.0",
|
||||
"pydantic-settings~=2.11.0",
|
||||
"pyjwt~=2.10.1",
|
||||
"pypdfium2==5.2.0",
|
||||
"python-docx~=1.1.0",
|
||||
|
||||
@ -264,15 +264,9 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
|
||||
batch_size: int,
|
||||
run_types: Sequence[WorkflowType] | None = None,
|
||||
tenant_ids: Sequence[str] | None = None,
|
||||
workflow_ids: Sequence[str] | None = None,
|
||||
) -> Sequence[WorkflowRun]:
|
||||
"""
|
||||
Fetch ended workflow runs in a time window for archival and clean batching.
|
||||
|
||||
Optional filters:
|
||||
- run_types
|
||||
- tenant_ids
|
||||
- workflow_ids
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@ -386,7 +386,6 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
|
||||
batch_size: int,
|
||||
run_types: Sequence[WorkflowType] | None = None,
|
||||
tenant_ids: Sequence[str] | None = None,
|
||||
workflow_ids: Sequence[str] | None = None,
|
||||
) -> Sequence[WorkflowRun]:
|
||||
"""
|
||||
Fetch ended workflow runs in a time window for archival and clean batching.
|
||||
@ -395,7 +394,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
|
||||
- created_at in [start_from, end_before)
|
||||
- type in run_types (when provided)
|
||||
- status is an ended state
|
||||
- optional tenant_id, workflow_id filters and cursor (last_seen) for pagination
|
||||
- optional tenant_id filter and cursor (last_seen) for pagination
|
||||
"""
|
||||
with self._session_maker() as session:
|
||||
stmt = (
|
||||
@ -418,9 +417,6 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
|
||||
if tenant_ids:
|
||||
stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids))
|
||||
|
||||
if workflow_ids:
|
||||
stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids))
|
||||
|
||||
if last_seen:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
|
||||
@ -4,6 +4,7 @@ import time
|
||||
from collections.abc import Sequence
|
||||
|
||||
import click
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import app
|
||||
@ -12,7 +13,6 @@ from extensions.ext_database import db
|
||||
from models.model import (
|
||||
AppAnnotationHitHistory,
|
||||
Conversation,
|
||||
DatasetRetrieverResource,
|
||||
Message,
|
||||
MessageAgentThought,
|
||||
MessageAnnotation,
|
||||
@ -20,10 +20,7 @@ from models.model import (
|
||||
MessageFeedback,
|
||||
MessageFile,
|
||||
)
|
||||
from models.web import SavedMessage
|
||||
from models.workflow import ConversationVariable, WorkflowRun
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
|
||||
from models.workflow import ConversationVariable, WorkflowAppLog, WorkflowNodeExecutionModel, WorkflowRun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -32,15 +29,8 @@ MAX_RETRIES = 3
|
||||
BATCH_SIZE = dify_config.WORKFLOW_LOG_CLEANUP_BATCH_SIZE
|
||||
|
||||
|
||||
def _get_specific_workflow_ids() -> list[str]:
|
||||
workflow_ids_str = dify_config.WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS.strip()
|
||||
if not workflow_ids_str:
|
||||
return []
|
||||
return [wid.strip() for wid in workflow_ids_str.split(",") if wid.strip()]
|
||||
|
||||
|
||||
@app.celery.task(queue="retention")
|
||||
def clean_workflow_runlogs_precise() -> None:
|
||||
@app.celery.task(queue="dataset")
|
||||
def clean_workflow_runlogs_precise():
|
||||
"""Clean expired workflow run logs with retry mechanism and complete message cascade"""
|
||||
|
||||
click.echo(click.style("Start clean workflow run logs (precise mode with complete cascade).", fg="green"))
|
||||
@ -49,48 +39,48 @@ def clean_workflow_runlogs_precise() -> None:
|
||||
retention_days = dify_config.WORKFLOW_LOG_RETENTION_DAYS
|
||||
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=retention_days)
|
||||
session_factory = sessionmaker(db.engine, expire_on_commit=False)
|
||||
workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory)
|
||||
workflow_ids = _get_specific_workflow_ids()
|
||||
workflow_ids_filter = workflow_ids or None
|
||||
|
||||
try:
|
||||
with session_factory.begin() as session:
|
||||
total_workflow_runs = session.query(WorkflowRun).where(WorkflowRun.created_at < cutoff_date).count()
|
||||
if total_workflow_runs == 0:
|
||||
logger.info("No expired workflow run logs found")
|
||||
return
|
||||
logger.info("Found %s expired workflow run logs to clean", total_workflow_runs)
|
||||
|
||||
total_deleted = 0
|
||||
failed_batches = 0
|
||||
batch_count = 0
|
||||
last_seen: tuple[datetime.datetime, str] | None = None
|
||||
while True:
|
||||
run_rows = workflow_run_repo.get_runs_batch_by_time_range(
|
||||
start_from=None,
|
||||
end_before=cutoff_date,
|
||||
last_seen=last_seen,
|
||||
batch_size=BATCH_SIZE,
|
||||
workflow_ids=workflow_ids_filter,
|
||||
)
|
||||
|
||||
if not run_rows:
|
||||
if batch_count == 0:
|
||||
logger.info("No expired workflow run logs found")
|
||||
break
|
||||
|
||||
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
|
||||
batch_count += 1
|
||||
with session_factory.begin() as session:
|
||||
success = _delete_batch(session, workflow_run_repo, run_rows, failed_batches)
|
||||
workflow_run_ids = session.scalars(
|
||||
select(WorkflowRun.id)
|
||||
.where(WorkflowRun.created_at < cutoff_date)
|
||||
.order_by(WorkflowRun.created_at, WorkflowRun.id)
|
||||
.limit(BATCH_SIZE)
|
||||
).all()
|
||||
|
||||
if success:
|
||||
total_deleted += len(run_rows)
|
||||
failed_batches = 0
|
||||
else:
|
||||
failed_batches += 1
|
||||
if failed_batches >= MAX_RETRIES:
|
||||
logger.error("Failed to delete batch after %s retries, aborting cleanup for today", MAX_RETRIES)
|
||||
if not workflow_run_ids:
|
||||
break
|
||||
|
||||
batch_count += 1
|
||||
|
||||
success = _delete_batch(session, workflow_run_ids, failed_batches)
|
||||
|
||||
if success:
|
||||
total_deleted += len(workflow_run_ids)
|
||||
failed_batches = 0
|
||||
else:
|
||||
# Calculate incremental delay times: 5, 10, 15 minutes
|
||||
retry_delay_minutes = failed_batches * 5
|
||||
logger.warning("Batch deletion failed, retrying in %s minutes...", retry_delay_minutes)
|
||||
time.sleep(retry_delay_minutes * 60)
|
||||
continue
|
||||
failed_batches += 1
|
||||
if failed_batches >= MAX_RETRIES:
|
||||
logger.error("Failed to delete batch after %s retries, aborting cleanup for today", MAX_RETRIES)
|
||||
break
|
||||
else:
|
||||
# Calculate incremental delay times: 5, 10, 15 minutes
|
||||
retry_delay_minutes = failed_batches * 5
|
||||
logger.warning("Batch deletion failed, retrying in %s minutes...", retry_delay_minutes)
|
||||
time.sleep(retry_delay_minutes * 60)
|
||||
continue
|
||||
|
||||
logger.info("Cleanup completed: %s expired workflow run logs deleted", total_deleted)
|
||||
|
||||
@ -103,16 +93,10 @@ def clean_workflow_runlogs_precise() -> None:
|
||||
click.echo(click.style(f"Cleaned workflow run logs from db success latency: {execution_time:.2f}s", fg="green"))
|
||||
|
||||
|
||||
def _delete_batch(
|
||||
session: Session,
|
||||
workflow_run_repo,
|
||||
workflow_runs: Sequence[WorkflowRun],
|
||||
attempt_count: int,
|
||||
) -> bool:
|
||||
def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_count: int) -> bool:
|
||||
"""Delete a single batch of workflow runs and all related data within a nested transaction."""
|
||||
try:
|
||||
with session.begin_nested():
|
||||
workflow_run_ids = [run.id for run in workflow_runs]
|
||||
message_data = (
|
||||
session.query(Message.id, Message.conversation_id)
|
||||
.where(Message.workflow_run_id.in_(workflow_run_ids))
|
||||
@ -123,13 +107,11 @@ def _delete_batch(
|
||||
if message_id_list:
|
||||
message_related_models = [
|
||||
AppAnnotationHitHistory,
|
||||
DatasetRetrieverResource,
|
||||
MessageAgentThought,
|
||||
MessageChain,
|
||||
MessageFile,
|
||||
MessageAnnotation,
|
||||
MessageFeedback,
|
||||
SavedMessage,
|
||||
]
|
||||
for model in message_related_models:
|
||||
session.query(model).where(model.message_id.in_(message_id_list)).delete(synchronize_session=False) # type: ignore
|
||||
@ -140,6 +122,14 @@ def _delete_batch(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
session.query(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(workflow_run_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
session.query(WorkflowNodeExecutionModel).where(
|
||||
WorkflowNodeExecutionModel.workflow_run_id.in_(workflow_run_ids)
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
if conversation_id_list:
|
||||
session.query(ConversationVariable).where(
|
||||
ConversationVariable.conversation_id.in_(conversation_id_list)
|
||||
@ -149,22 +139,7 @@ def _delete_batch(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
def _delete_node_executions(active_session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
|
||||
run_ids = [run.id for run in runs]
|
||||
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
|
||||
session_maker=sessionmaker(bind=active_session.get_bind(), expire_on_commit=False)
|
||||
)
|
||||
return repo.delete_by_runs(active_session, run_ids)
|
||||
|
||||
def _delete_trigger_logs(active_session: Session, run_ids: Sequence[str]) -> int:
|
||||
trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(active_session)
|
||||
return trigger_repo.delete_by_run_ids(run_ids)
|
||||
|
||||
workflow_run_repo.delete_runs_with_related(
|
||||
workflow_runs,
|
||||
delete_node_executions=_delete_node_executions,
|
||||
delete_trigger_logs=_delete_trigger_logs,
|
||||
)
|
||||
session.query(WorkflowRun).where(WorkflowRun.id.in_(workflow_run_ids)).delete(synchronize_session=False)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@ -289,11 +289,6 @@ class AccountService:
|
||||
|
||||
TenantService.create_owner_tenant_if_not_exist(account=account)
|
||||
|
||||
# Enterprise-only: best-effort add the account to the default workspace (does not switch current workspace).
|
||||
from services.enterprise.enterprise_service import try_join_default_workspace
|
||||
|
||||
try_join_default_workspace(str(account.id))
|
||||
|
||||
return account
|
||||
|
||||
@staticmethod
|
||||
@ -1412,11 +1407,6 @@ class RegisterService:
|
||||
tenant_was_created.send(tenant)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Enterprise-only: best-effort add the account to the default workspace (does not switch current workspace).
|
||||
from services.enterprise.enterprise_service import try_join_default_workspace
|
||||
|
||||
try_join_default_workspace(str(account.id))
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
db.session.rollback()
|
||||
logger.exception("Register failed")
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from configs import dify_config
|
||||
from services.enterprise.base import EnterpriseRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebAppSettings(BaseModel):
|
||||
access_mode: str = Field(
|
||||
@ -35,47 +30,6 @@ class WorkspacePermission(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class DefaultWorkspaceJoinResult(BaseModel):
|
||||
"""
|
||||
Result of ensuring an account is a member of the enterprise default workspace.
|
||||
|
||||
- joined=True is idempotent (already a member also returns True)
|
||||
- joined=False means enterprise default workspace is not configured or invalid/archived
|
||||
"""
|
||||
|
||||
workspace_id: str = ""
|
||||
joined: bool = False
|
||||
message: str = ""
|
||||
|
||||
|
||||
def try_join_default_workspace(account_id: str) -> None:
|
||||
"""
|
||||
Enterprise-only side-effect: ensure account is a member of the default workspace.
|
||||
|
||||
This is a best-effort integration. Failures must not block user registration.
|
||||
"""
|
||||
|
||||
if not dify_config.ENTERPRISE_ENABLED:
|
||||
return
|
||||
|
||||
try:
|
||||
result = EnterpriseService.join_default_workspace(account_id=account_id)
|
||||
if result.joined:
|
||||
logger.info(
|
||||
"Joined enterprise default workspace for account %s (workspace_id=%s)",
|
||||
account_id,
|
||||
result.workspace_id,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Skipped joining enterprise default workspace for account %s (message=%s)",
|
||||
account_id,
|
||||
result.message,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to join enterprise default workspace for account %s", account_id, exc_info=True)
|
||||
|
||||
|
||||
class EnterpriseService:
|
||||
@classmethod
|
||||
def get_info(cls):
|
||||
@ -85,23 +39,6 @@ class EnterpriseService:
|
||||
def get_workspace_info(cls, tenant_id: str):
|
||||
return EnterpriseRequest.send_request("GET", f"/workspace/{tenant_id}/info")
|
||||
|
||||
@classmethod
|
||||
def join_default_workspace(cls, *, account_id: str) -> DefaultWorkspaceJoinResult:
|
||||
"""
|
||||
Call enterprise inner API to add an account to the default workspace.
|
||||
|
||||
NOTE: EnterpriseRequest.base_url is expected to already include the `/inner/api` prefix,
|
||||
so the endpoint here is `/default-workspace/members`.
|
||||
"""
|
||||
|
||||
# Ensure we are sending a UUID-shaped string (enterprise side validates too).
|
||||
uuid.UUID(account_id)
|
||||
|
||||
data = EnterpriseRequest.send_request("POST", "/default-workspace/members", json={"account_id": account_id})
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid response format from enterprise default workspace API")
|
||||
return DefaultWorkspaceJoinResult.model_validate(data)
|
||||
|
||||
@classmethod
|
||||
def get_app_sso_settings_last_update_time(cls) -> datetime:
|
||||
data = EnterpriseRequest.send_request("GET", "/sso/app/last-update-time")
|
||||
|
||||
@ -220,8 +220,8 @@ class MetadataService:
|
||||
doc_metadata[BuiltInField.source] = MetadataDataSource[document.data_source_type]
|
||||
document.doc_metadata = doc_metadata
|
||||
db.session.add(document)
|
||||
|
||||
# deal metadata binding (in the same transaction as the doc_metadata update)
|
||||
db.session.commit()
|
||||
# deal metadata binding
|
||||
if not operation.partial_update:
|
||||
db.session.query(DatasetMetadataBinding).filter_by(document_id=operation.document_id).delete()
|
||||
|
||||
@ -247,9 +247,7 @@ class MetadataService:
|
||||
db.session.add(dataset_metadata_binding)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
logger.exception("Update documents metadata failed")
|
||||
raise
|
||||
finally:
|
||||
redis_client.delete(lock_key)
|
||||
|
||||
|
||||
@ -1,241 +0,0 @@
|
||||
"""
|
||||
Benchmark: OceanBase vector store — old (single-row) vs new (batch) insertion,
|
||||
metadata query with/without functional index, and vector search across metrics.
|
||||
|
||||
Usage:
|
||||
uv run --project api python -m tests.integration_tests.vdb.oceanbase.bench_oceanbase
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import statistics
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from pyobvector import VECTOR, ObVecClient, cosine_distance, inner_product, l2_distance
|
||||
from sqlalchemy import JSON, Column, String, text
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 2881
|
||||
USER = "root@test"
|
||||
PASSWORD = "difyai123456"
|
||||
DATABASE = "test"
|
||||
|
||||
VEC_DIM = 1536
|
||||
HNSW_BUILD = {"M": 16, "efConstruction": 256}
|
||||
DISTANCE_FUNCS = {"l2": l2_distance, "cosine": cosine_distance, "inner_product": inner_product}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_client(**extra):
|
||||
return ObVecClient(
|
||||
uri=f"{HOST}:{PORT}",
|
||||
user=USER,
|
||||
password=PASSWORD,
|
||||
db_name=DATABASE,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def _rand_vec():
|
||||
return [random.uniform(-1, 1) for _ in range(VEC_DIM)] # noqa: S311
|
||||
|
||||
|
||||
def _drop(client, table):
|
||||
client.drop_table_if_exist(table)
|
||||
|
||||
|
||||
def _create_table(client, table, metric="l2"):
|
||||
cols = [
|
||||
Column("id", String(36), primary_key=True, autoincrement=False),
|
||||
Column("vector", VECTOR(VEC_DIM)),
|
||||
Column("text", LONGTEXT),
|
||||
Column("metadata", JSON),
|
||||
]
|
||||
vidx = client.prepare_index_params()
|
||||
vidx.add_index(
|
||||
field_name="vector",
|
||||
index_type="HNSW",
|
||||
index_name="vector_index",
|
||||
metric_type=metric,
|
||||
params=HNSW_BUILD,
|
||||
)
|
||||
client.create_table_with_index_params(table_name=table, columns=cols, vidxs=vidx)
|
||||
client.refresh_metadata([table])
|
||||
|
||||
|
||||
def _gen_rows(n):
|
||||
doc_id = str(uuid.uuid4())
|
||||
rows = []
|
||||
for _ in range(n):
|
||||
rows.append(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"vector": _rand_vec(),
|
||||
"text": f"benchmark text {uuid.uuid4().hex[:12]}",
|
||||
"metadata": json.dumps({"document_id": doc_id, "dataset_id": str(uuid.uuid4())}),
|
||||
}
|
||||
)
|
||||
return rows, doc_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Benchmark: Insertion
|
||||
# ---------------------------------------------------------------------------
|
||||
def bench_insert_single(client, table, rows):
|
||||
"""Old approach: one INSERT per row."""
|
||||
t0 = time.perf_counter()
|
||||
for row in rows:
|
||||
client.insert(table_name=table, data=row)
|
||||
return time.perf_counter() - t0
|
||||
|
||||
|
||||
def bench_insert_batch(client, table, rows, batch_size=100):
|
||||
"""New approach: batch INSERT."""
|
||||
t0 = time.perf_counter()
|
||||
for start in range(0, len(rows), batch_size):
|
||||
batch = rows[start : start + batch_size]
|
||||
client.insert(table_name=table, data=batch)
|
||||
return time.perf_counter() - t0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Benchmark: Metadata query
|
||||
# ---------------------------------------------------------------------------
|
||||
def bench_metadata_query(client, table, doc_id, with_index=False):
|
||||
"""Query by metadata->>'$.document_id' with/without functional index."""
|
||||
if with_index:
|
||||
try:
|
||||
client.perform_raw_text_sql(f"CREATE INDEX idx_metadata_doc_id ON `{table}` ((metadata->>'$.document_id'))")
|
||||
except Exception:
|
||||
pass # already exists
|
||||
|
||||
sql = text(f"SELECT id FROM `{table}` WHERE metadata->>'$.document_id' = :val")
|
||||
times = []
|
||||
with client.engine.connect() as conn:
|
||||
for _ in range(10):
|
||||
t0 = time.perf_counter()
|
||||
result = conn.execute(sql, {"val": doc_id})
|
||||
_ = result.fetchall()
|
||||
times.append(time.perf_counter() - t0)
|
||||
return times
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Benchmark: Vector search
|
||||
# ---------------------------------------------------------------------------
|
||||
def bench_vector_search(client, table, metric, topk=10, n_queries=20):
|
||||
dist_func = DISTANCE_FUNCS[metric]
|
||||
times = []
|
||||
for _ in range(n_queries):
|
||||
q = _rand_vec()
|
||||
t0 = time.perf_counter()
|
||||
cur = client.ann_search(
|
||||
table_name=table,
|
||||
vec_column_name="vector",
|
||||
vec_data=q,
|
||||
topk=topk,
|
||||
distance_func=dist_func,
|
||||
output_column_names=["text", "metadata"],
|
||||
with_dist=True,
|
||||
)
|
||||
_ = list(cur)
|
||||
times.append(time.perf_counter() - t0)
|
||||
return times
|
||||
|
||||
|
||||
def _fmt(times):
|
||||
"""Format list of durations as 'mean ± stdev'."""
|
||||
m = statistics.mean(times) * 1000
|
||||
s = statistics.stdev(times) * 1000 if len(times) > 1 else 0
|
||||
return f"{m:.1f} ± {s:.1f} ms"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
client = _make_client()
|
||||
client_pooled = _make_client(pool_size=5, max_overflow=10, pool_recycle=3600, pool_pre_ping=True)
|
||||
|
||||
print("=" * 70)
|
||||
print("OceanBase Vector Store — Performance Benchmark")
|
||||
print(f" Endpoint : {HOST}:{PORT}")
|
||||
print(f" Vec dim : {VEC_DIM}")
|
||||
print("=" * 70)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Insertion benchmark
|
||||
# ------------------------------------------------------------------
|
||||
for n_docs in [100, 500, 1000]:
|
||||
rows, doc_id = _gen_rows(n_docs)
|
||||
tbl_single = f"bench_single_{n_docs}"
|
||||
tbl_batch = f"bench_batch_{n_docs}"
|
||||
|
||||
_drop(client, tbl_single)
|
||||
_drop(client, tbl_batch)
|
||||
_create_table(client, tbl_single)
|
||||
_create_table(client, tbl_batch)
|
||||
|
||||
t_single = bench_insert_single(client, tbl_single, rows)
|
||||
t_batch = bench_insert_batch(client_pooled, tbl_batch, rows, batch_size=100)
|
||||
|
||||
speedup = t_single / t_batch if t_batch > 0 else float("inf")
|
||||
print(f"\n[Insert {n_docs} docs]")
|
||||
print(f" Single-row : {t_single:.2f}s")
|
||||
print(f" Batch(100) : {t_batch:.2f}s")
|
||||
print(f" Speedup : {speedup:.1f}x")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Metadata query benchmark (use the 1000-doc batch table)
|
||||
# ------------------------------------------------------------------
|
||||
tbl_meta = "bench_batch_1000"
|
||||
rows_1000, doc_id_1000 = _gen_rows(1000)
|
||||
# The table already has 1000 rows from step 1; use that doc_id
|
||||
# Re-query doc_id from one of the rows we inserted
|
||||
with client.engine.connect() as conn:
|
||||
res = conn.execute(text(f"SELECT metadata->>'$.document_id' FROM `{tbl_meta}` LIMIT 1"))
|
||||
doc_id_1000 = res.fetchone()[0]
|
||||
|
||||
print("\n[Metadata filter query — 1000 rows, by document_id]")
|
||||
times_no_idx = bench_metadata_query(client, tbl_meta, doc_id_1000, with_index=False)
|
||||
print(f" Without index : {_fmt(times_no_idx)}")
|
||||
times_with_idx = bench_metadata_query(client, tbl_meta, doc_id_1000, with_index=True)
|
||||
print(f" With index : {_fmt(times_with_idx)}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Vector search benchmark — across metrics
|
||||
# ------------------------------------------------------------------
|
||||
print("\n[Vector search — top-10, 20 queries each, on 1000 rows]")
|
||||
|
||||
for metric in ["l2", "cosine", "inner_product"]:
|
||||
tbl_vs = f"bench_vs_{metric}"
|
||||
_drop(client_pooled, tbl_vs)
|
||||
_create_table(client_pooled, tbl_vs, metric=metric)
|
||||
# Insert 1000 rows
|
||||
rows_vs, _ = _gen_rows(1000)
|
||||
bench_insert_batch(client_pooled, tbl_vs, rows_vs, batch_size=100)
|
||||
times = bench_vector_search(client_pooled, tbl_vs, metric, topk=10, n_queries=20)
|
||||
print(f" {metric:15s}: {_fmt(times)}")
|
||||
_drop(client_pooled, tbl_vs)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ------------------------------------------------------------------
|
||||
for n in [100, 500, 1000]:
|
||||
_drop(client, f"bench_single_{n}")
|
||||
_drop(client, f"bench_batch_{n}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Benchmark complete.")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -21,7 +21,6 @@ def oceanbase_vector():
|
||||
database="test",
|
||||
password="difyai123456",
|
||||
enable_hybrid_search=True,
|
||||
batch_size=10,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -914,6 +914,9 @@ class TestMetadataService:
|
||||
metadata_args = MetadataArgs(type="string", name="test_metadata")
|
||||
metadata = MetadataService.create_metadata(dataset.id, metadata_args)
|
||||
|
||||
# Mock DocumentService.get_document to return None (document not found)
|
||||
mock_external_service_dependencies["document_service"].get_document.return_value = None
|
||||
|
||||
# Create metadata operation data
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
DocumentMetadataOperation,
|
||||
@ -923,17 +926,16 @@ class TestMetadataService:
|
||||
|
||||
metadata_detail = MetadataDetail(id=metadata.id, name=metadata.name, value="test_value")
|
||||
|
||||
# Use a valid UUID format that does not exist in the database
|
||||
operation = DocumentMetadataOperation(
|
||||
document_id="00000000-0000-0000-0000-000000000000", metadata_list=[metadata_detail]
|
||||
)
|
||||
operation = DocumentMetadataOperation(document_id="non-existent-document-id", metadata_list=[metadata_detail])
|
||||
|
||||
operation_data = MetadataOperationData(operation_data=[operation])
|
||||
|
||||
# Act & Assert: The method should raise ValueError("Document not found.")
|
||||
# because the exception is now re-raised after rollback
|
||||
with pytest.raises(ValueError, match="Document not found"):
|
||||
MetadataService.update_documents_metadata(dataset, operation_data)
|
||||
# Act: Execute the method under test
|
||||
# The method should handle the error gracefully and continue
|
||||
MetadataService.update_documents_metadata(dataset, operation_data)
|
||||
|
||||
# Assert: Verify the method completes without raising exceptions
|
||||
# The main functionality (error handling) is verified
|
||||
|
||||
def test_knowledge_base_metadata_lock_check_dataset_id(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
|
||||
@ -1,125 +0,0 @@
|
||||
"""Unit tests for enterprise service integrations.
|
||||
|
||||
This module covers the enterprise-only default workspace auto-join behavior:
|
||||
- Enterprise mode disabled: no external calls
|
||||
- Successful join / skipped join: no errors
|
||||
- Failures (network/invalid response/invalid UUID): soft-fail wrapper must not raise
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.enterprise.enterprise_service import (
|
||||
DefaultWorkspaceJoinResult,
|
||||
EnterpriseService,
|
||||
try_join_default_workspace,
|
||||
)
|
||||
|
||||
|
||||
class TestJoinDefaultWorkspace:
|
||||
def test_join_default_workspace_success(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
response = {"workspace_id": "22222222-2222-2222-2222-222222222222", "joined": True, "message": "ok"}
|
||||
|
||||
with patch("services.enterprise.enterprise_service.EnterpriseRequest.send_request") as mock_send_request:
|
||||
mock_send_request.return_value = response
|
||||
|
||||
result = EnterpriseService.join_default_workspace(account_id=account_id)
|
||||
|
||||
assert isinstance(result, DefaultWorkspaceJoinResult)
|
||||
assert result.workspace_id == response["workspace_id"]
|
||||
assert result.joined is True
|
||||
assert result.message == "ok"
|
||||
|
||||
mock_send_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/default-workspace/members",
|
||||
json={"account_id": account_id},
|
||||
)
|
||||
|
||||
def test_join_default_workspace_invalid_response_format_raises(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
with patch("services.enterprise.enterprise_service.EnterpriseRequest.send_request") as mock_send_request:
|
||||
mock_send_request.return_value = "not-a-dict"
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid response format"):
|
||||
EnterpriseService.join_default_workspace(account_id=account_id)
|
||||
|
||||
def test_join_default_workspace_invalid_account_id_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
EnterpriseService.join_default_workspace(account_id="not-a-uuid")
|
||||
|
||||
|
||||
class TestTryJoinDefaultWorkspace:
|
||||
def test_try_join_default_workspace_enterprise_disabled_noop(self):
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
|
||||
try_join_default_workspace("11111111-1111-1111-1111-111111111111")
|
||||
|
||||
mock_join.assert_not_called()
|
||||
|
||||
def test_try_join_default_workspace_successful_join_does_not_raise(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
mock_join.return_value = DefaultWorkspaceJoinResult(
|
||||
workspace_id="22222222-2222-2222-2222-222222222222",
|
||||
joined=True,
|
||||
message="ok",
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
try_join_default_workspace(account_id)
|
||||
|
||||
mock_join.assert_called_once_with(account_id=account_id)
|
||||
|
||||
def test_try_join_default_workspace_skipped_join_does_not_raise(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
mock_join.return_value = DefaultWorkspaceJoinResult(
|
||||
workspace_id="",
|
||||
joined=False,
|
||||
message="no default workspace configured",
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
try_join_default_workspace(account_id)
|
||||
|
||||
mock_join.assert_called_once_with(account_id=account_id)
|
||||
|
||||
def test_try_join_default_workspace_api_failure_soft_fails(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
mock_join.side_effect = Exception("network failure")
|
||||
|
||||
# Should not raise
|
||||
try_join_default_workspace(account_id)
|
||||
|
||||
mock_join.assert_called_once_with(account_id=account_id)
|
||||
|
||||
def test_try_join_default_workspace_invalid_account_id_soft_fails(self):
|
||||
with patch("services.enterprise.enterprise_service.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
# Should not raise even though UUID parsing fails inside join_default_workspace
|
||||
try_join_default_workspace("not-a-uuid")
|
||||
@ -62,9 +62,6 @@ class FakeRepo:
|
||||
end_before: datetime.datetime,
|
||||
last_seen: tuple[datetime.datetime, str] | None,
|
||||
batch_size: int,
|
||||
run_types=None,
|
||||
tenant_ids=None,
|
||||
workflow_ids=None,
|
||||
) -> list[FakeRun]:
|
||||
if self.call_idx >= len(self.batches):
|
||||
return []
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.dataset import Dataset, Document
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
DocumentMetadataOperation,
|
||||
@ -150,38 +148,6 @@ class TestMetadataPartialUpdate(unittest.TestCase):
|
||||
# If it were added, there would be 2 calls. If skipped, 1 call.
|
||||
assert mock_db.session.add.call_count == 1
|
||||
|
||||
@patch("services.metadata_service.db")
|
||||
@patch("services.metadata_service.DocumentService")
|
||||
@patch("services.metadata_service.current_account_with_tenant")
|
||||
@patch("services.metadata_service.redis_client")
|
||||
def test_rollback_called_on_commit_failure(self, mock_redis, mock_current_account, mock_document_service, mock_db):
|
||||
"""When db.session.commit() raises, rollback must be called and the exception must propagate."""
|
||||
# Setup mocks
|
||||
mock_redis.get.return_value = None
|
||||
mock_document_service.get_document.return_value = self.document
|
||||
mock_current_account.return_value = (MagicMock(id="user_id"), "tenant_id")
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
# Make commit raise an exception
|
||||
mock_db.session.commit.side_effect = RuntimeError("database connection lost")
|
||||
|
||||
operation = DocumentMetadataOperation(
|
||||
document_id="doc_id",
|
||||
metadata_list=[MetadataDetail(id="meta_id", name="key", value="value")],
|
||||
partial_update=True,
|
||||
)
|
||||
metadata_args = MetadataOperationData(operation_data=[operation])
|
||||
|
||||
# Act & Assert: the exception must propagate
|
||||
with pytest.raises(RuntimeError, match="database connection lost"):
|
||||
MetadataService.update_documents_metadata(self.dataset, metadata_args)
|
||||
|
||||
# Verify rollback was called
|
||||
mock_db.session.rollback.assert_called_once()
|
||||
|
||||
# Verify the lock key was cleaned up despite the failure
|
||||
mock_redis.delete.assert_called_with("document_metadata_lock_doc_id")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
72
api/uv.lock
generated
72
api/uv.lock
generated
@ -1366,7 +1366,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.13.0"
|
||||
version = "1.12.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
@ -1635,7 +1635,7 @@ requires-dist = [
|
||||
{ name = "pycryptodome", specifier = "==3.23.0" },
|
||||
{ name = "pydantic", specifier = "~=2.11.4" },
|
||||
{ name = "pydantic-extra-types", specifier = "~=2.10.3" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.12.0" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.11.0" },
|
||||
{ name = "pyjwt", specifier = "~=2.10.1" },
|
||||
{ name = "pypdfium2", specifier = "==5.2.0" },
|
||||
{ name = "python-docx", specifier = "~=1.1.0" },
|
||||
@ -4473,39 +4473,39 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
version = "12.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4900,16 +4900,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.12.0"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -1073,8 +1073,6 @@ WORKFLOW_LOG_CLEANUP_ENABLED=false
|
||||
WORKFLOW_LOG_RETENTION_DAYS=30
|
||||
# Batch size for workflow log cleanup operations (default: 100)
|
||||
WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
|
||||
# Comma-separated list of workflow IDs to clean logs for
|
||||
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
|
||||
|
||||
# Aliyun SLS Logstore Configuration
|
||||
# Aliyun Access Key ID
|
||||
|
||||
@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.0
|
||||
image: langgenius/dify-web:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@ -470,7 +470,6 @@ x-shared-env: &shared-api-worker-env
|
||||
WORKFLOW_LOG_CLEANUP_ENABLED: ${WORKFLOW_LOG_CLEANUP_ENABLED:-false}
|
||||
WORKFLOW_LOG_RETENTION_DAYS: ${WORKFLOW_LOG_RETENTION_DAYS:-30}
|
||||
WORKFLOW_LOG_CLEANUP_BATCH_SIZE: ${WORKFLOW_LOG_CLEANUP_BATCH_SIZE:-100}
|
||||
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: ${WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS:-}
|
||||
ALIYUN_SLS_ACCESS_KEY_ID: ${ALIYUN_SLS_ACCESS_KEY_ID:-}
|
||||
ALIYUN_SLS_ACCESS_KEY_SECRET: ${ALIYUN_SLS_ACCESS_KEY_SECRET:-}
|
||||
ALIYUN_SLS_ENDPOINT: ${ALIYUN_SLS_ENDPOINT:-}
|
||||
@ -716,7 +715,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -758,7 +757,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -797,7 +796,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -827,7 +826,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.0
|
||||
image: langgenius/dify-web:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@ -1,462 +0,0 @@
|
||||
/**
|
||||
* Integration test: App Card Operations Flow
|
||||
*
|
||||
* Tests the end-to-end user flows for app card operations:
|
||||
* - Editing app info
|
||||
* - Duplicating an app
|
||||
* - Deleting an app
|
||||
* - Exporting app DSL
|
||||
* - Navigation on card click
|
||||
* - Access mode icons
|
||||
*/
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppCard from '@/app/components/apps/app-card'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
let mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
const mockOnPlanInfoChanged = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock headless UI Popover so it renders content without transition
|
||||
vi.mock('@headlessui/react', async () => {
|
||||
const actual = await vi.importActual<typeof import('@headlessui/react')>('@headlessui/react')
|
||||
return {
|
||||
...actual,
|
||||
Popover: ({ children, className }: { children: ((bag: { open: boolean }) => React.ReactNode) | React.ReactNode, className?: string }) => (
|
||||
<div className={className} data-testid="popover-wrapper">
|
||||
{typeof children === 'function' ? children({ open: true }) : children}
|
||||
</div>
|
||||
),
|
||||
PopoverButton: ({ children, className, ref: _ref, ...rest }: Record<string, unknown>) => (
|
||||
<button className={className as string} {...rest}>{children as React.ReactNode}</button>
|
||||
),
|
||||
PopoverPanel: ({ children, className }: { children: ((bag: { close: () => void }) => React.ReactNode) | React.ReactNode, className?: string }) => (
|
||||
<div className={className}>
|
||||
{typeof children === 'function' ? children({ close: vi.fn() }) : children}
|
||||
</div>
|
||||
),
|
||||
Transition: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
|
||||
let Component: React.ComponentType<Record<string, unknown>> | null = null
|
||||
loader().then((mod) => {
|
||||
Component = mod.default as React.ComponentType<Record<string, unknown>>
|
||||
}).catch(() => {})
|
||||
const Wrapper = (props: Record<string, unknown>) => {
|
||||
if (Component)
|
||||
return <Component {...props} />
|
||||
return null
|
||||
}
|
||||
Wrapper.displayName = 'DynamicWrapper'
|
||||
return Wrapper
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
if (typeof selector === 'function')
|
||||
return selector(state)
|
||||
return mockSystemFeatures
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: mockOnPlanInfoChanged,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the ToastContext used via useContext from use-context-selector
|
||||
vi.mock('use-context-selector', async () => {
|
||||
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
|
||||
return {
|
||||
...actual,
|
||||
useContext: () => ({ notify: mockNotify }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
tagList: [],
|
||||
showTagManagementModal: false,
|
||||
setTagList: vi.fn(),
|
||||
setShowTagManagementModal: vi.fn(),
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
deleteApp: vi.fn().mockResolvedValue({}),
|
||||
updateAppInfo: vi.fn().mockResolvedValue({}),
|
||||
copyApp: vi.fn().mockResolvedValue({ id: 'new-app-id', mode: 'chat' }),
|
||||
exportAppConfig: vi.fn().mockResolvedValue({ data: 'yaml-content' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchInstalledAppList: vi.fn().mockResolvedValue({ installed_apps: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock modals loaded via next/dynamic
|
||||
vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
default: ({ show, onConfirm, onHide, appName }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="edit-app-modal">
|
||||
<span data-testid="modal-app-name">{appName as string}</span>
|
||||
<button
|
||||
data-testid="confirm-edit"
|
||||
onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({
|
||||
name: 'Updated App Name',
|
||||
icon_type: 'emoji',
|
||||
icon: '🔥',
|
||||
icon_background: '#fff',
|
||||
description: 'Updated description',
|
||||
})}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="cancel-edit" onClick={onHide as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/duplicate-modal', () => ({
|
||||
default: ({ show, onConfirm, onHide }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="duplicate-app-modal">
|
||||
<button
|
||||
data-testid="confirm-duplicate"
|
||||
onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({
|
||||
name: 'Copied App',
|
||||
icon_type: 'emoji',
|
||||
icon: '📋',
|
||||
icon_background: '#fff',
|
||||
})}
|
||||
>
|
||||
Confirm Duplicate
|
||||
</button>
|
||||
<button data-testid="cancel-duplicate" onClick={onHide as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/switch-app-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="switch-app-modal">
|
||||
<button data-testid="confirm-switch" onClick={onSuccess as () => void}>Confirm Switch</button>
|
||||
<button data-testid="cancel-switch" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onConfirm, onCancel, title }: Record<string, unknown>) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="confirm-delete-modal">
|
||||
<span>{title as string}</span>
|
||||
<button data-testid="confirm-delete" onClick={onConfirm as () => void}>Delete</button>
|
||||
<button data-testid="cancel-delete" onClick={onCancel as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
<div data-testid="dsl-export-confirm-modal">
|
||||
<button data-testid="export-include" onClick={() => (onConfirm as (include: boolean) => void)(true)}>Include</button>
|
||||
<button data-testid="export-close" onClick={onClose as () => void}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
<div data-testid="access-control-modal">
|
||||
<button data-testid="confirm-access" onClick={onConfirm as () => void}>Confirm</button>
|
||||
<button data-testid="cancel-access" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
name: overrides.name ?? 'Test Chat App',
|
||||
description: overrides.description ?? 'A chat application',
|
||||
author_name: overrides.author_name ?? 'Test Author',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '🤖',
|
||||
icon_background: overrides.icon_background ?? '#FFEAD5',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
|
||||
mode: overrides.mode ?? AppModeEnum.CHAT,
|
||||
enable_site: overrides.enable_site ?? true,
|
||||
enable_api: overrides.enable_api ?? true,
|
||||
api_rpm: overrides.api_rpm ?? 60,
|
||||
api_rph: overrides.api_rph ?? 3600,
|
||||
is_demo: overrides.is_demo ?? false,
|
||||
model_config: overrides.model_config ?? {} as App['model_config'],
|
||||
app_model_config: overrides.app_model_config ?? {} as App['app_model_config'],
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
updated_at: overrides.updated_at ?? 1700001000,
|
||||
site: overrides.site ?? {} as App['site'],
|
||||
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
|
||||
tags: overrides.tags ?? [],
|
||||
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
|
||||
max_active_requests: overrides.max_active_requests ?? null,
|
||||
})
|
||||
|
||||
const mockOnRefresh = vi.fn()
|
||||
|
||||
const renderAppCard = (app?: Partial<App>) => {
|
||||
return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />)
|
||||
}
|
||||
|
||||
describe('App Card Operations Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceEditor = true
|
||||
mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Card Rendering', () => {
|
||||
it('should render app name and description', () => {
|
||||
renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' })
|
||||
|
||||
expect(screen.getByText('My AI Bot')).toBeInTheDocument()
|
||||
expect(screen.getByText('An intelligent assistant')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render author name', () => {
|
||||
renderAppCard({ author_name: 'John Doe' })
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should navigate to app config page when card is clicked', () => {
|
||||
renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT })
|
||||
|
||||
const card = screen.getByText('Test Chat App').closest('[class*="cursor-pointer"]')
|
||||
if (card)
|
||||
fireEvent.click(card)
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/configuration')
|
||||
})
|
||||
|
||||
it('should navigate to workflow page for workflow apps', () => {
|
||||
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
|
||||
|
||||
const card = screen.getByText('WF App').closest('[class*="cursor-pointer"]')
|
||||
if (card)
|
||||
fireEvent.click(card)
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-wf/workflow')
|
||||
})
|
||||
})
|
||||
|
||||
// -- Delete flow --
|
||||
describe('Delete App Flow', () => {
|
||||
it('should show delete confirmation and call API on confirm', async () => {
|
||||
renderAppCard({ id: 'app-to-delete', name: 'Deletable App' })
|
||||
|
||||
// Find and click the more button (popover trigger)
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteBtn = screen.queryByText('common.operation.delete')
|
||||
if (deleteBtn)
|
||||
fireEvent.click(deleteBtn)
|
||||
})
|
||||
|
||||
const confirmBtn = screen.queryByTestId('confirm-delete')
|
||||
if (confirmBtn) {
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteApp).toHaveBeenCalledWith('app-to-delete')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -- Edit flow --
|
||||
describe('Edit App Flow', () => {
|
||||
it('should open edit modal and call updateAppInfo on confirm', async () => {
|
||||
renderAppCard({ id: 'app-edit', name: 'Editable App' })
|
||||
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
const editBtn = screen.queryByText('app.editApp')
|
||||
if (editBtn)
|
||||
fireEvent.click(editBtn)
|
||||
})
|
||||
|
||||
const confirmEdit = screen.queryByTestId('confirm-edit')
|
||||
if (confirmEdit) {
|
||||
fireEvent.click(confirmEdit)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAppInfo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appID: 'app-edit',
|
||||
name: 'Updated App Name',
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -- Export flow --
|
||||
describe('Export App Flow', () => {
|
||||
it('should call exportAppConfig for completion apps', async () => {
|
||||
renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' })
|
||||
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
const exportBtn = screen.queryByText('app.export')
|
||||
if (exportBtn)
|
||||
fireEvent.click(exportBtn)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exportAppConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ appID: 'app-export' }),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -- Access mode display --
|
||||
describe('Access Mode Display', () => {
|
||||
it('should not render operations menu for non-editor users', () => {
|
||||
mockIsCurrentWorkspaceEditor = false
|
||||
renderAppCard({ name: 'Readonly App' })
|
||||
|
||||
expect(screen.queryByText('app.editApp')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Switch mode (only for CHAT/COMPLETION) --
|
||||
describe('Switch App Mode', () => {
|
||||
it('should show switch option for chat mode apps', async () => {
|
||||
renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT })
|
||||
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('app.switch')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should not show switch option for workflow apps', async () => {
|
||||
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
|
||||
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,442 +0,0 @@
|
||||
/**
|
||||
* Integration test: App List Browsing Flow
|
||||
*
|
||||
* Tests the end-to-end user flow of browsing, filtering, searching,
|
||||
* and tab switching in the apps list page.
|
||||
*
|
||||
* Covers: List, Empty, Footer, AppCardSkeleton, useAppsQueryState, NewAppCard
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
let mockIsCurrentWorkspaceDatasetOperator = false
|
||||
let mockIsLoadingCurrentWorkspace = false
|
||||
|
||||
let mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
|
||||
let mockPages: AppListResponse[] = []
|
||||
let mockIsLoading = false
|
||||
let mockIsFetching = false
|
||||
let mockIsFetchingNextPage = false
|
||||
let mockHasNextPage = false
|
||||
let mockError: Error | null = null
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
|
||||
let mockShowTagManagementModal = false
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockRouterReplace = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
replace: mockRouterReplace,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (_loader: () => Promise<{ default: React.ComponentType }>) => {
|
||||
const LazyComponent = (props: Record<string, unknown>) => {
|
||||
return <div data-testid="dynamic-component" {...props} />
|
||||
}
|
||||
LazyComponent.displayName = 'DynamicComponent'
|
||||
return LazyComponent
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator,
|
||||
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
tagList: [],
|
||||
showTagManagementModal: mockShowTagManagementModal,
|
||||
setTagList: vi.fn(),
|
||||
setShowTagManagementModal: vi.fn(),
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useInfiniteAppList: () => ({
|
||||
data: { pages: mockPages },
|
||||
isLoading: mockIsLoading,
|
||||
isFetching: mockIsFetching,
|
||||
isFetchingNextPage: mockIsFetchingNextPage,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: mockHasNextPage,
|
||||
error: mockError,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-pay', () => ({
|
||||
CheckModal: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useDebounceFn: (fn: (...args: unknown[]) => void) => {
|
||||
const fnRef = React.useRef(fn)
|
||||
fnRef.current = fn
|
||||
return {
|
||||
run: (...args: unknown[]) => fnRef.current(...args),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
name: overrides.name ?? 'My Chat App',
|
||||
description: overrides.description ?? 'A chat application',
|
||||
author_name: overrides.author_name ?? 'Test Author',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '🤖',
|
||||
icon_background: overrides.icon_background ?? '#FFEAD5',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
|
||||
mode: overrides.mode ?? AppModeEnum.CHAT,
|
||||
enable_site: overrides.enable_site ?? true,
|
||||
enable_api: overrides.enable_api ?? true,
|
||||
api_rpm: overrides.api_rpm ?? 60,
|
||||
api_rph: overrides.api_rph ?? 3600,
|
||||
is_demo: overrides.is_demo ?? false,
|
||||
model_config: overrides.model_config ?? {} as App['model_config'],
|
||||
app_model_config: overrides.app_model_config ?? {} as App['app_model_config'],
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
updated_at: overrides.updated_at ?? 1700001000,
|
||||
site: overrides.site ?? {} as App['site'],
|
||||
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
|
||||
tags: overrides.tags ?? [],
|
||||
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
|
||||
max_active_requests: overrides.max_active_requests ?? null,
|
||||
})
|
||||
|
||||
const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse => ({
|
||||
data: apps,
|
||||
has_more: hasMore,
|
||||
limit: 30,
|
||||
page,
|
||||
total: apps.length,
|
||||
})
|
||||
|
||||
const renderList = (searchParams?: Record<string, string>) => {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('App List Browsing Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceEditor = true
|
||||
mockIsCurrentWorkspaceDatasetOperator = false
|
||||
mockIsLoadingCurrentWorkspace = false
|
||||
mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
mockPages = []
|
||||
mockIsLoading = false
|
||||
mockIsFetching = false
|
||||
mockIsFetchingNextPage = false
|
||||
mockHasNextPage = false
|
||||
mockError = null
|
||||
mockShowTagManagementModal = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Loading and Empty States', () => {
|
||||
it('should show skeleton cards during initial loading', () => {
|
||||
mockIsLoading = true
|
||||
renderList()
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show empty state when no apps exist', () => {
|
||||
mockPages = [createPage([])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should transition from loading to content when data loads', () => {
|
||||
mockIsLoading = true
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
|
||||
// Data loads
|
||||
mockIsLoading = false
|
||||
mockPages = [createPage([
|
||||
createMockApp({ id: 'app-1', name: 'Loaded App' }),
|
||||
])]
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Loaded App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Rendering apps --
|
||||
describe('App List Rendering', () => {
|
||||
it('should render all app cards from the data', () => {
|
||||
mockPages = [createPage([
|
||||
createMockApp({ id: 'app-1', name: 'Chat Bot' }),
|
||||
createMockApp({ id: 'app-2', name: 'Workflow Engine', mode: AppModeEnum.WORKFLOW }),
|
||||
createMockApp({ id: 'app-3', name: 'Completion Tool', mode: AppModeEnum.COMPLETION }),
|
||||
])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Chat Bot')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow Engine')).toBeInTheDocument()
|
||||
expect(screen.getByText('Completion Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app descriptions', () => {
|
||||
mockPages = [createPage([
|
||||
createMockApp({ name: 'My App', description: 'A powerful AI assistant' }),
|
||||
])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('A powerful AI assistant')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the NewAppCard for workspace editors', () => {
|
||||
mockPages = [createPage([
|
||||
createMockApp({ name: 'Test App' }),
|
||||
])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide NewAppCard when user is not a workspace editor', () => {
|
||||
mockIsCurrentWorkspaceEditor = false
|
||||
mockPages = [createPage([
|
||||
createMockApp({ name: 'Test App' }),
|
||||
])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.createApp')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Footer visibility --
|
||||
describe('Footer Visibility', () => {
|
||||
it('should show footer when branding is disabled', () => {
|
||||
mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: false } }
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.join')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.communityIntro')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide footer when branding is enabled', () => {
|
||||
mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: true } }
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.join')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- DSL drag-drop hint --
|
||||
describe('DSL Drag-Drop Hint', () => {
|
||||
it('should show drag-drop hint for workspace editors', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide drag-drop hint for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor = false
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Tab navigation --
|
||||
describe('Tab Navigation', () => {
|
||||
it('should render all category tabs', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Search --
|
||||
describe('Search Filtering', () => {
|
||||
it('should render search input', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const input = document.querySelector('input')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow typing in search input', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const input = document.querySelector('input')!
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
expect(input.value).toBe('test search')
|
||||
})
|
||||
})
|
||||
|
||||
// -- "Created by me" filter --
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render the "created by me" checkbox', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle the "created by me" filter on click', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Fetching next page skeleton --
|
||||
describe('Pagination Loading', () => {
|
||||
it('should show skeleton when fetching next page', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
mockIsFetchingNextPage = true
|
||||
|
||||
renderList()
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// -- Dataset operator redirect --
|
||||
describe('Dataset Operator Redirect', () => {
|
||||
it('should redirect dataset operators to /datasets', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator = true
|
||||
renderList()
|
||||
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
})
|
||||
|
||||
// -- Multiple pages of data --
|
||||
describe('Multi-page Data', () => {
|
||||
it('should render apps from multiple pages', () => {
|
||||
mockPages = [
|
||||
createPage([
|
||||
createMockApp({ id: 'app-1', name: 'Page One App' }),
|
||||
], true, 1),
|
||||
createPage([
|
||||
createMockApp({ id: 'app-2', name: 'Page Two App' }),
|
||||
], false, 2),
|
||||
]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Page One App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page Two App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- controlRefreshList triggers refetch --
|
||||
describe('Refresh List', () => {
|
||||
it('should call refetch when controlRefreshList increments', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={1} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,464 +0,0 @@
|
||||
/**
|
||||
* Integration test: Create App Flow
|
||||
*
|
||||
* Tests the end-to-end user flows for creating new apps:
|
||||
* - Creating from blank via NewAppCard
|
||||
* - Creating from template via NewAppCard
|
||||
* - Creating from DSL import via NewAppCard
|
||||
* - Apps page top-level state management
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
let mockIsCurrentWorkspaceDatasetOperator = false
|
||||
let mockIsLoadingCurrentWorkspace = false
|
||||
let mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
|
||||
let mockPages: AppListResponse[] = []
|
||||
let mockIsLoading = false
|
||||
let mockIsFetching = false
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
let mockShowTagManagementModal = false
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockRouterReplace = vi.fn()
|
||||
const mockOnPlanInfoChanged = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
replace: mockRouterReplace,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator,
|
||||
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: mockOnPlanInfoChanged,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
tagList: [],
|
||||
showTagManagementModal: mockShowTagManagementModal,
|
||||
setTagList: vi.fn(),
|
||||
setShowTagManagementModal: vi.fn(),
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useInfiniteAppList: () => ({
|
||||
data: { pages: mockPages },
|
||||
isLoading: mockIsLoading,
|
||||
isFetching: mockIsFetching,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-pay', () => ({
|
||||
CheckModal: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useDebounceFn: (fn: (...args: unknown[]) => void) => {
|
||||
const fnRef = React.useRef(fn)
|
||||
fnRef.current = fn
|
||||
return {
|
||||
run: (...args: unknown[]) => fnRef.current(...args),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Mock dynamically loaded modals with test stubs
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
|
||||
let Component: React.ComponentType<Record<string, unknown>> | null = null
|
||||
loader().then((mod) => {
|
||||
Component = mod.default as React.ComponentType<Record<string, unknown>>
|
||||
}).catch(() => {})
|
||||
const Wrapper = (props: Record<string, unknown>) => {
|
||||
if (Component)
|
||||
return <Component {...props} />
|
||||
return null
|
||||
}
|
||||
Wrapper.displayName = 'DynamicWrapper'
|
||||
return Wrapper
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-app-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="create-app-modal">
|
||||
<button data-testid="create-blank-confirm" onClick={onSuccess as () => void}>Create Blank</button>
|
||||
{!!onCreateFromTemplate && (
|
||||
<button data-testid="switch-to-template" onClick={onCreateFromTemplate as () => void}>From Template</button>
|
||||
)}
|
||||
<button data-testid="create-blank-cancel" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-app-dialog', () => ({
|
||||
default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="template-dialog">
|
||||
<button data-testid="template-confirm" onClick={onSuccess as () => void}>Create from Template</button>
|
||||
{!!onCreateFromBlank && (
|
||||
<button data-testid="switch-to-blank" onClick={onCreateFromBlank as () => void}>From Blank</button>
|
||||
)}
|
||||
<button data-testid="template-cancel" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="create-from-dsl-modal">
|
||||
<button data-testid="dsl-import-confirm" onClick={onSuccess as () => void}>Import DSL</button>
|
||||
<button data-testid="dsl-import-cancel" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
CreateFromDSLModalTab: {
|
||||
FROM_URL: 'from-url',
|
||||
FROM_FILE: 'from-file',
|
||||
},
|
||||
}))
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
name: overrides.name ?? 'Test App',
|
||||
description: overrides.description ?? 'A test app',
|
||||
author_name: overrides.author_name ?? 'Author',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '🤖',
|
||||
icon_background: overrides.icon_background ?? '#FFEAD5',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
|
||||
mode: overrides.mode ?? AppModeEnum.CHAT,
|
||||
enable_site: overrides.enable_site ?? true,
|
||||
enable_api: overrides.enable_api ?? true,
|
||||
api_rpm: overrides.api_rpm ?? 60,
|
||||
api_rph: overrides.api_rph ?? 3600,
|
||||
is_demo: overrides.is_demo ?? false,
|
||||
model_config: overrides.model_config ?? {} as App['model_config'],
|
||||
app_model_config: overrides.app_model_config ?? {} as App['app_model_config'],
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
updated_at: overrides.updated_at ?? 1700001000,
|
||||
site: overrides.site ?? {} as App['site'],
|
||||
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
|
||||
tags: overrides.tags ?? [],
|
||||
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
|
||||
max_active_requests: overrides.max_active_requests ?? null,
|
||||
})
|
||||
|
||||
const createPage = (apps: App[]): AppListResponse => ({
|
||||
data: apps,
|
||||
has_more: false,
|
||||
limit: 30,
|
||||
page: 1,
|
||||
total: apps.length,
|
||||
})
|
||||
|
||||
const renderList = () => {
|
||||
return render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Create App Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceEditor = true
|
||||
mockIsCurrentWorkspaceDatasetOperator = false
|
||||
mockIsLoadingCurrentWorkspace = false
|
||||
mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
mockIsLoading = false
|
||||
mockIsFetching = false
|
||||
mockShowTagManagementModal = false
|
||||
})
|
||||
|
||||
describe('NewAppCard Rendering', () => {
|
||||
it('should render the "Create App" card with all options', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.importDSL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render NewAppCard when user is not an editor', () => {
|
||||
mockIsCurrentWorkspaceEditor = false
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.createApp')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state when workspace is loading', () => {
|
||||
mockIsLoadingCurrentWorkspace = true
|
||||
renderList()
|
||||
|
||||
// NewAppCard renders but with loading style (pointer-events-none opacity-50)
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Create from blank --
|
||||
describe('Create from Blank Flow', () => {
|
||||
it('should open the create app modal when "Start from Blank" is clicked', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close the create app modal on cancel', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('create-blank-cancel'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onPlanInfoChanged and refetch on successful creation', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('create-blank-confirm'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -- Create from template --
|
||||
describe('Create from Template Flow', () => {
|
||||
it('should open template dialog when "Start from Template" is clicked', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow switching from template to blank modal', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('switch-to-blank'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('template-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow switching from blank to template dialog', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('switch-to-template'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -- Create from DSL import (via NewAppCard button) --
|
||||
describe('Create from DSL Import Flow', () => {
|
||||
it('should open DSL import modal when "Import DSL" is clicked', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close DSL import modal on cancel', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('dsl-import-cancel'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onPlanInfoChanged and refetch on successful DSL import', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('dsl-import-confirm'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -- DSL drag-and-drop flow (via List component) --
|
||||
describe('DSL Drag-Drop Flow', () => {
|
||||
it('should show drag-drop hint in the list', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open create-from-DSL modal when DSL file is dropped', async () => {
|
||||
const { act } = await import('@testing-library/react')
|
||||
renderList()
|
||||
|
||||
const container = document.querySelector('[class*="overflow-y-auto"]')
|
||||
if (container) {
|
||||
const yamlFile = new File(['app: test'], 'app.yaml', { type: 'application/yaml' })
|
||||
|
||||
// Simulate the full drag-drop sequence wrapped in act
|
||||
await act(async () => {
|
||||
const dragEnterEvent = new Event('dragenter', { bubbles: true })
|
||||
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
|
||||
value: { types: ['Files'], files: [] },
|
||||
})
|
||||
Object.defineProperty(dragEnterEvent, 'preventDefault', { value: vi.fn() })
|
||||
Object.defineProperty(dragEnterEvent, 'stopPropagation', { value: vi.fn() })
|
||||
container.dispatchEvent(dragEnterEvent)
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true })
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: { files: [yamlFile], types: ['Files'] },
|
||||
})
|
||||
Object.defineProperty(dropEvent, 'preventDefault', { value: vi.fn() })
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', { value: vi.fn() })
|
||||
container.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.queryByTestId('create-from-dsl-modal')
|
||||
if (modal)
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -- Edge cases --
|
||||
describe('Edge Cases', () => {
|
||||
it('should not show create options when no data and user is editor', () => {
|
||||
mockPages = [createPage([])]
|
||||
renderList()
|
||||
|
||||
// NewAppCard should still be visible even with no apps
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple rapid clicks on create buttons without crashing', async () => {
|
||||
renderList()
|
||||
|
||||
// Rapidly click different create options
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
|
||||
// Should not crash, and some modal should be present
|
||||
await waitFor(() => {
|
||||
const anyModal = screen.queryByTestId('create-app-modal')
|
||||
|| screen.queryByTestId('template-dialog')
|
||||
|| screen.queryByTestId('create-from-dsl-modal')
|
||||
expect(anyModal).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,991 +0,0 @@
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import Billing from '@/app/components/billing/billing-page'
|
||||
import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
|
||||
import PriorityLabel from '@/app/components/billing/priority-label'
|
||||
import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
||||
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ──────────────────────────────────────────────────────────
|
||||
const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' })
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
const mockRouterPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow }: { isShow: boolean }) =>
|
||||
isShow ? <div data-testid="verify-state-modal" /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/utils/util', () => ({
|
||||
mailToSupport: () => 'mailto:support@test.com',
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record<string, unknown> = {}) => {
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Vitest hoists vi.mock() calls, so imports above will use mocked modules
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. Billing Page + Plan Component Integration
|
||||
// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Billing Page + Plan Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Verify that the billing page renders PlanComp with all 7 usage items
|
||||
describe('Rendering complete plan information', () => {
|
||||
it('should display all 7 usage metrics for sandbox plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: {
|
||||
buildApps: 3,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 10,
|
||||
vectorSpace: 20,
|
||||
annotatedResponse: 5,
|
||||
triggerEvents: 1000,
|
||||
apiRateLimit: 2000,
|
||||
},
|
||||
total: {
|
||||
buildApps: 5,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 50,
|
||||
vectorSpace: 50,
|
||||
annotatedResponse: 10,
|
||||
triggerEvents: 3000,
|
||||
apiRateLimit: 5000,
|
||||
},
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
// Plan name
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
|
||||
// All 7 usage items should be visible
|
||||
expect(screen.getByText(/usagePage\.buildApps/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.teamMembers/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.documentsUploadQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.annotationQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.triggerEvents/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plansCommon\.apiRateLimit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display usage values as "usage / total" format', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 3, teamMembers: 1 },
|
||||
total: { buildApps: 5, teamMembers: 1 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Check that the buildApps usage fraction "3 / 5" is rendered
|
||||
const usageContainers = screen.getAllByText('3')
|
||||
expect(usageContainers.length).toBeGreaterThan(0)
|
||||
const totalContainers = screen.getAllByText('5')
|
||||
expect(totalContainers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show "unlimited" for infinite quotas (professional API rate limit)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { apiRateLimit: NUM_INFINITE },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display reset days for trigger events when applicable', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 7 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Reset text should be visible
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: true })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when user is not workspace manager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. Plan Type Display Integration
|
||||
// Tests that different plan types render correct visual elements
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Plan Type Display Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render sandbox plan with upgrade button (premium badge)', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.sandbox\.for/i)).toBeInTheDocument()
|
||||
// Sandbox shows premium badge upgrade button (not plain)
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render professional plan with plain upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
// Professional shows plain button because it's not team
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render team plan with plain-style upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
|
||||
// Team plan has isPlain=true, so shows "upgradeBtn.plain" text
|
||||
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render upgrade button for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show education verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. Upgrade Flow Integration
|
||||
// Tests the flow: UpgradeBtn click → setShowPricingModal
|
||||
// and PlanUpgradeModal → close + trigger pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Upgrade Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
})
|
||||
|
||||
// UpgradeBtn triggers pricing modal
|
||||
describe('UpgradeBtn triggers pricing modal', () => {
|
||||
it('should call setShowPricingModal when clicking premium badge upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setShowPricingModal when clicking plain upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use custom onClick when provided instead of setShowPricingModal', async () => {
|
||||
const customOnClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn onClick={customOnClick} />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(customOnClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire gtag event with loc parameter when clicked', async () => {
|
||||
const mockGtag = vi.fn()
|
||||
;(window as unknown as Record<string, unknown>).gtag = mockGtag
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn loc="billing-page" />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'billing-page' })
|
||||
delete (window as unknown as Record<string, unknown>).gtag
|
||||
})
|
||||
})
|
||||
|
||||
// PlanUpgradeModal integration: close modal and trigger pricing
|
||||
describe('PlanUpgradeModal upgrade flow', () => {
|
||||
it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Upgrade Required"
|
||||
description="You need a better plan"
|
||||
/>,
|
||||
)
|
||||
|
||||
// The modal should show title and description
|
||||
expect(screen.getByText('Upgrade Required')).toBeInTheDocument()
|
||||
expect(screen.getByText('You need a better plan')).toBeInTheDocument()
|
||||
|
||||
// Click the upgrade button inside the modal
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
// Should close the current modal first
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// Then open pricing modal
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose and custom onUpgrade when provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
// Custom onUpgrade replaces default setShowPricingModal
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when clicking dismiss button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i)
|
||||
await user.click(dismissBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Upgrade from PlanComp: clicking upgrade button in plan component triggers pricing
|
||||
describe('PlanComp upgrade button triggers pricing', () => {
|
||||
it('should open pricing modal when clicking upgrade in sandbox plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test-loc" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Capacity Full Components Integration
|
||||
// Tests AppsFull, VectorSpaceFull, AnnotationFull, TriggerEventsLimitModal
|
||||
// with real child components (UsageInfo, ProgressBar, UpgradeBtn)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Capacity Full Components Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// AppsFull renders with correct messaging and components
|
||||
describe('AppsFull integration', () => {
|
||||
it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Should show "full" tip
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
// Should show usage/total fraction "5/5"
|
||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
||||
// Should have a progress bar rendered
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display upgrade tip and upgrade button for professional plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { buildApps: 48 },
|
||||
total: { buildApps: 50 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display contact tip and contact button for team plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.team,
|
||||
usage: { buildApps: 200 },
|
||||
total: { buildApps: 200 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Team plan shows different tip
|
||||
expect(screen.getByText(/apps\.fullTip2$/i)).toBeInTheDocument()
|
||||
// Team plan shows "Contact Us" instead of upgrade
|
||||
expect(screen.getByText(/apps\.contactUs/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render progress bar with correct color based on usage percentage', () => {
|
||||
// 100% usage should show error color
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
const progressBar = screen.getByTestId('billing-progress-bar')
|
||||
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
|
||||
// VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn
|
||||
describe('VectorSpaceFull integration', () => {
|
||||
it('should display full tip, upgrade button, and vector space usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
// Should show full tip
|
||||
expect(screen.getByText(/vectorSpace\.fullTip/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/vectorSpace\.fullSolution/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Should show vector space usage info
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFull renders with Usage component and UpgradeBtn
|
||||
describe('AnnotationFull integration', () => {
|
||||
it('should display annotation full tip, upgrade button, and usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine2/i)).toBeInTheDocument()
|
||||
// UpgradeBtn rendered
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Usage component should show annotation quota
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFullModal shows modal with usage and upgrade button
|
||||
describe('AnnotationFullModal integration', () => {
|
||||
it('should render modal with annotation info and upgrade button when show is true', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content when show is false', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo
|
||||
describe('TriggerEventsLimitModal integration', () => {
|
||||
it('should display trigger limit title, usage info, and upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={vi.fn()}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={18000}
|
||||
total={20000}
|
||||
resetInDays={5}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Modal title and description
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
|
||||
// Embedded UsageInfo with trigger events data
|
||||
expect(screen.getByText(/triggerLimitModal\.usageTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('18000')).toBeInTheDocument()
|
||||
expect(screen.getByText('20000')).toBeInTheDocument()
|
||||
// Reset info
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
// Upgrade and dismiss buttons
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose and onUpgrade when clicking upgrade', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. Header Billing Button Integration
|
||||
// Tests HeaderBillingBtn behavior for different plan states
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Header Billing Button Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render UpgradeBtn (premium badge) for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "pro" badge for professional plan', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('pro')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "team" badge for team plan', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('team')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should return null when plan is not fetched yet', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should call onClick when clicking pro/team badge in non-display-only mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClick when isDisplayOnly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 6. PriorityLabel Integration
|
||||
// Tests priority badge display for different plan types
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('PriorityLabel Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should display "standard" priority for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "priority" for professional plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.priority/i)).toBeInTheDocument()
|
||||
// Professional plan should show the priority icon
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for team plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 7. Usage Display Edge Cases
|
||||
// Tests storage mode, threshold logic, and progress bar color integration
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Usage Display Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Vector space storage mode behavior
|
||||
describe('VectorSpace storage mode in PlanComp', () => {
|
||||
it('should show "< 50" for sandbox plan with low vector space usage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Storage mode: usage below threshold shows "< 50"
|
||||
expect(screen.getByText(/</)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show indeterminate progress bar for usage below threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Should have an indeterminate progress bar
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show actual usage for pro plan above threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { vectorSpace: 1024 },
|
||||
total: { vectorSpace: 5120 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Pro plan above threshold shows actual value
|
||||
expect(screen.getByText('1024')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Progress bar color logic through real components
|
||||
describe('Progress bar color reflects usage severity', () => {
|
||||
it('should show normal color for low usage percentage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 1 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// 20% usage - normal color
|
||||
const progressBars = screen.getAllByTestId('billing-progress-bar')
|
||||
// At least one should have the normal progress color
|
||||
const hasNormalColor = progressBars.some(bar =>
|
||||
bar.classList.contains('bg-components-progress-bar-progress-solid'),
|
||||
)
|
||||
expect(hasNormalColor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Reset days calculation in PlanComp
|
||||
describe('Reset days integration', () => {
|
||||
it('should not show reset for sandbox trigger events (no reset_date)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
total: { triggerEvents: 3000 },
|
||||
reset: { triggerEvents: null },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Find the trigger events section - should not have reset text
|
||||
const triggerSection = screen.getByText(/usagePage\.triggerEvents/i)
|
||||
const parent = triggerSection.closest('[class*="flex flex-col"]')
|
||||
// No reset text should appear (sandbox doesn't show reset for triggerEvents)
|
||||
expect(parent?.textContent).not.toContain('usagePage.resetsIn')
|
||||
})
|
||||
|
||||
it('should show reset for professional trigger events with reset date', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 14 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Professional plan with finite triggerEvents should show reset
|
||||
const resetTexts = screen.getAllByText(/usagePage\.resetsIn/i)
|
||||
expect(resetTexts.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 8. Cross-Component Upgrade Flow (End-to-End)
|
||||
// Tests the complete chain: capacity alert → upgrade button → pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Cross-Component Upgrade Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should trigger pricing from AppsFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="app-create" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from VectorSpaceFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
// TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal
|
||||
// PlanUpgradeModal's upgrade button calls onClose then onUpgrade
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFullModal upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,296 +0,0 @@
|
||||
/**
|
||||
* Integration test: Cloud Plan Payment Flow
|
||||
*
|
||||
* Tests the payment flow for cloud plan items:
|
||||
* CloudPlanItem → Button click → permission check → fetch URL → redirect
|
||||
*
|
||||
* Covers plan comparison, downgrade prevention, monthly/yearly pricing,
|
||||
* and workspace manager permission enforcement.
|
||||
*/
|
||||
import type { BasicPlan } from '@/app/components/billing/type'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { ALL_PLANS } from '@/app/components/billing/config'
|
||||
import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher'
|
||||
import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockFetchSubscriptionUrls = vi.fn()
|
||||
const mockInvoices = vi.fn()
|
||||
const mockOpenAsyncWindow = vi.fn()
|
||||
const mockToastNotify = vi.fn()
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/billing', () => ({
|
||||
fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
billing: {
|
||||
invoices: () => mockInvoices(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => mockOpenAsyncWindow,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (args: unknown) => mockToastNotify(args) },
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
type RenderCloudPlanItemOptions = {
|
||||
currentPlan?: BasicPlan
|
||||
plan?: BasicPlan
|
||||
planRange?: PlanRange
|
||||
canPay?: boolean
|
||||
}
|
||||
|
||||
const renderCloudPlanItem = ({
|
||||
currentPlan = Plan.sandbox,
|
||||
plan = Plan.professional,
|
||||
planRange = PlanRange.monthly,
|
||||
canPay = true,
|
||||
}: RenderCloudPlanItemOptions = {}) => {
|
||||
return render(
|
||||
<CloudPlanItem
|
||||
currentPlan={currentPlan}
|
||||
plan={plan}
|
||||
planRange={planRange}
|
||||
canPay={canPay}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Cloud Plan Payment Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupAppContext()
|
||||
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' })
|
||||
mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' })
|
||||
})
|
||||
|
||||
// ─── 1. Plan Display ────────────────────────────────────────────────────
|
||||
describe('Plan display', () => {
|
||||
it('should render plan name and description', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.professional\.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Free" price for sandbox plan', () => {
|
||||
renderCloudPlanItem({ plan: Plan.sandbox })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show monthly price for paid plans', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.monthly })
|
||||
|
||||
expect(screen.getByText(`$${ALL_PLANS.professional.price}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show yearly discounted price (10 months) and strikethrough original (12 months)', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.yearly })
|
||||
|
||||
const yearlyPrice = ALL_PLANS.professional.price * 10
|
||||
const originalPrice = ALL_PLANS.professional.price * 12
|
||||
|
||||
expect(screen.getByText(`$${yearlyPrice}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`$${originalPrice}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "most popular" badge for professional plan', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show "most popular" badge for sandbox or team plans', () => {
|
||||
const { unmount } = renderCloudPlanItem({ plan: Plan.sandbox })
|
||||
expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
renderCloudPlanItem({ plan: Plan.team })
|
||||
expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Button Text Logic ───────────────────────────────────────────────
|
||||
describe('Button text logic', () => {
|
||||
it('should show "Current Plan" when plan matches current plan', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Start for Free" for sandbox plan when not current', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.startForFree/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Start Building" for professional plan when not current', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Get Started" for team plan when not current', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Downgrade Prevention ────────────────────────────────────────────
|
||||
describe('Downgrade prevention', () => {
|
||||
it('should disable sandbox button when user is on professional plan (downgrade)', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable sandbox and professional buttons when user is on team plan', () => {
|
||||
const { unmount } = renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.sandbox })
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
unmount()
|
||||
|
||||
renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.professional })
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable current paid plan button (for invoice management)', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable higher-tier plan buttons for upgrade', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Payment URL Flow ────────────────────────────────────────────────
|
||||
describe('Payment URL flow', () => {
|
||||
it('should call fetchSubscriptionUrls with plan and "month" for monthly range', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Simulate clicking on a professional plan button (user is on sandbox)
|
||||
renderCloudPlanItem({
|
||||
currentPlan: Plan.sandbox,
|
||||
plan: Plan.professional,
|
||||
planRange: PlanRange.monthly,
|
||||
})
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call fetchSubscriptionUrls with plan and "year" for yearly range', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({
|
||||
currentPlan: Plan.sandbox,
|
||||
plan: Plan.team,
|
||||
planRange: PlanRange.yearly,
|
||||
})
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
|
||||
})
|
||||
})
|
||||
|
||||
it('should open invoice management for current paid plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAsyncWindow).toHaveBeenCalled()
|
||||
})
|
||||
// Should NOT call fetchSubscriptionUrls (invoice, not subscription)
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not do anything when clicking on sandbox free plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.sandbox })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Wait a tick and verify no actions were taken
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
expect(mockOpenAsyncWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Permission Check ────────────────────────────────────────────────
|
||||
describe('Permission check', () => {
|
||||
it('should show error toast when non-manager clicks upgrade button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Should not proceed with payment
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,318 +0,0 @@
|
||||
/**
|
||||
* Integration test: Education Verification Flow
|
||||
*
|
||||
* Tests the education plan verification flow in PlanComp:
|
||||
* PlanComp → handleVerify → useEducationVerify → router.push → education-apply
|
||||
* PlanComp → handleVerify → error → show VerifyStateModal
|
||||
*
|
||||
* Also covers education button visibility based on context flags.
|
||||
*/
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockMutateAsync = vi.fn()
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow, title, content, email, showLink }: {
|
||||
isShow: boolean
|
||||
title?: string
|
||||
content?: string
|
||||
email?: string
|
||||
showLink?: boolean
|
||||
}) =>
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="verify-state-modal">
|
||||
{title && <span data-testid="modal-title">{title}</span>}
|
||||
{content && <span data-testid="modal-content">{content}</span>}
|
||||
{email && <span data-testid="modal-email">{email}</span>}
|
||||
{showLink && <span data-testid="modal-show-link">link</span>}
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupContexts = (
|
||||
planOverrides: PlanOverrides = {},
|
||||
providerOverrides: Record<string, unknown> = {},
|
||||
appOverrides: Record<string, unknown> = {},
|
||||
) => {
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
...providerOverrides,
|
||||
}
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'student@university.edu' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...appOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Education Verification Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupContexts()
|
||||
})
|
||||
|
||||
// ─── 1. Education Button Visibility ─────────────────────────────────────
|
||||
describe('Education button visibility', () => {
|
||||
it('should not show verify button when enableEducationPlan is false', () => {
|
||||
setupContexts({}, { enableEducationPlan: false })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show verify button when already verified and not about to expire', () => {
|
||||
setupContexts({}, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: true,
|
||||
allowRefreshEducationVerify: false,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show verify button when about to expire (allowRefreshEducationVerify is true)', () => {
|
||||
setupContexts({}, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: true,
|
||||
allowRefreshEducationVerify: true,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Shown because isAboutToExpire = allowRefreshEducationVerify = true
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Successful Verification Flow ────────────────────────────────────
|
||||
describe('Successful verification flow', () => {
|
||||
it('should navigate to education-apply with token on successful verification', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' })
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
const verifyButton = screen.getByText(/toVerified/i)
|
||||
await user.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove education verifying flag from localStorage on success', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ token: 'token-xyz' })
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('educationVerifying')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Failed Verification Flow ────────────────────────────────────────
|
||||
describe('Failed verification flow', () => {
|
||||
it('should show VerifyStateModal with rejection info on error', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('Verification failed'))
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Modal should not be visible initially
|
||||
expect(screen.queryByTestId('verify-state-modal')).not.toBeInTheDocument()
|
||||
|
||||
const verifyButton = screen.getByText(/toVerified/i)
|
||||
await user.click(verifyButton)
|
||||
|
||||
// Modal should appear after verification failure
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Modal should display rejection title and content
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(/rejectTitle/i)
|
||||
expect(screen.getByTestId('modal-content')).toHaveTextContent(/rejectContent/i)
|
||||
})
|
||||
|
||||
it('should show email and link in VerifyStateModal', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('fail'))
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-email')).toHaveTextContent('student@university.edu')
|
||||
expect(screen.getByTestId('modal-show-link')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not redirect on verification failure', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('fail'))
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should NOT navigate
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Education + Upgrade Coexistence ─────────────────────────────────
|
||||
describe('Education and upgrade button coexistence', () => {
|
||||
it('should show both education verify and upgrade buttons for sandbox user', () => {
|
||||
setupContexts(
|
||||
{ type: Plan.sandbox },
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show upgrade button for enterprise plan', () => {
|
||||
setupContexts(
|
||||
{ type: Plan.enterprise },
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show team plan with plain upgrade button and education button', () => {
|
||||
setupContexts(
|
||||
{ type: Plan.team },
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,326 +0,0 @@
|
||||
/**
|
||||
* Integration test: Partner Stack Flow
|
||||
*
|
||||
* Tests the PartnerStack integration:
|
||||
* PartnerStack component → usePSInfo hook → cookie management → bind API call
|
||||
*
|
||||
* Covers URL param reading, cookie persistence, API bind on mount,
|
||||
* cookie cleanup after successful bind, and error handling for 400 status.
|
||||
*/
|
||||
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import * as React from 'react'
|
||||
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
const mockMutateAsync = vi.fn()
|
||||
|
||||
// ─── Module mocks ────────────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => mockSearchParams,
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBindPartnerStackInfo: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
}),
|
||||
useBillingUrl: () => ({
|
||||
data: '',
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
PARTNER_STACK_CONFIG: {
|
||||
cookieName: 'partner_stack_info',
|
||||
saveCookieDays: 90,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Cookie helpers ──────────────────────────────────────────────────────────
|
||||
const getCookieData = () => {
|
||||
const raw = Cookies.get(PARTNER_STACK_CONFIG.cookieName)
|
||||
if (!raw)
|
||||
return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const setCookieData = (data: Record<string, string>) => {
|
||||
Cookies.set(PARTNER_STACK_CONFIG.cookieName, JSON.stringify(data))
|
||||
}
|
||||
|
||||
const clearCookie = () => {
|
||||
Cookies.remove(PARTNER_STACK_CONFIG.cookieName)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Partner Stack Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
clearCookie()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
mockMutateAsync.mockResolvedValue({})
|
||||
})
|
||||
|
||||
// ─── 1. URL Param Reading ───────────────────────────────────────────────
|
||||
describe('URL param reading', () => {
|
||||
it('should read ps_partner_key and ps_xid from URL search params', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'partner-123',
|
||||
ps_xid: 'click-456',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('partner-123')
|
||||
expect(result.current.psClickId).toBe('click-456')
|
||||
})
|
||||
|
||||
it('should fall back to cookie when URL params are not present', () => {
|
||||
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
||||
expect(result.current.psClickId).toBe('cookie-click')
|
||||
})
|
||||
|
||||
it('should prefer URL params over cookie values', () => {
|
||||
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'url-partner',
|
||||
ps_xid: 'url-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('url-partner')
|
||||
expect(result.current.psClickId).toBe('url-click')
|
||||
})
|
||||
|
||||
it('should return null for both values when no params and no cookie', () => {
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBeUndefined()
|
||||
expect(result.current.psClickId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Cookie Persistence (saveOrUpdate) ───────────────────────────────
|
||||
describe('Cookie persistence via saveOrUpdate', () => {
|
||||
it('should save PS info to cookie when URL params provide new values', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'new-partner',
|
||||
ps_xid: 'new-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
const cookieData = getCookieData()
|
||||
expect(cookieData).toEqual({
|
||||
partnerKey: 'new-partner',
|
||||
clickId: 'new-click',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not update cookie when values have not changed', () => {
|
||||
setCookieData({ partnerKey: 'same-partner', clickId: 'same-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'same-partner',
|
||||
ps_xid: 'same-click',
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
// Should not call set because values haven't changed
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
cookieSetSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not save to cookie when partner key is missing', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_xid: 'click-only',
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
cookieSetSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not save to cookie when click ID is missing', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'partner-only',
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
cookieSetSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Bind API Flow ──────────────────────────────────────────────────
|
||||
describe('Bind API flow', () => {
|
||||
it('should call mutateAsync with partnerKey and clickId on bind', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
partnerKey: 'bind-partner',
|
||||
clickId: 'bind-click',
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove cookie after successful bind', async () => {
|
||||
setCookieData({ partnerKey: 'rm-partner', clickId: 'rm-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'rm-partner',
|
||||
ps_xid: 'rm-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
// Cookie should be removed after successful bind
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove cookie on 400 error (already bound)', async () => {
|
||||
mockMutateAsync.mockRejectedValue({ status: 400 })
|
||||
setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'err-partner',
|
||||
ps_xid: 'err-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
// Cookie should be removed even on 400
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not remove cookie on non-400 errors', async () => {
|
||||
mockMutateAsync.mockRejectedValue({ status: 500 })
|
||||
setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'keep-partner',
|
||||
ps_xid: 'keep-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
// Cookie should still exist for non-400 errors
|
||||
const cookieData = getCookieData()
|
||||
expect(cookieData).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not call bind when partner key is missing', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_xid: 'click-only',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call bind a second time (idempotency)', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'partner-once',
|
||||
ps_xid: 'click-once',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
// First bind
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second bind should be skipped (hasBind = true)
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. PartnerStack Component Mount ────────────────────────────────────
|
||||
describe('PartnerStack component mount behavior', () => {
|
||||
it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'mount-partner',
|
||||
ps_xid: 'mount-click',
|
||||
})
|
||||
|
||||
// Use lazy import so the mocks are applied
|
||||
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
|
||||
|
||||
render(<PartnerStack />)
|
||||
|
||||
// The component calls saveOrUpdate and bind in useEffect
|
||||
await waitFor(() => {
|
||||
// Bind should have been called
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
partnerKey: 'mount-partner',
|
||||
clickId: 'mount-click',
|
||||
})
|
||||
})
|
||||
|
||||
// Cookie should have been saved (saveOrUpdate was called before bind)
|
||||
// After bind succeeds, cookie is removed
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should render nothing (return null)', async () => {
|
||||
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
|
||||
|
||||
const { container } = render(<PartnerStack />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,327 +0,0 @@
|
||||
/**
|
||||
* Integration test: Pricing Modal Flow
|
||||
*
|
||||
* Tests the full Pricing modal lifecycle:
|
||||
* Pricing → PlanSwitcher (category + range toggle) → Plans (cloud / self-hosted)
|
||||
* → CloudPlanItem / SelfHostedPlanItem → Footer
|
||||
*
|
||||
* Validates cross-component state propagation when the user switches between
|
||||
* cloud / self-hosted categories and monthly / yearly plan ranges.
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { ALL_PLANS } from '@/app/components/billing/config'
|
||||
import Pricing from '@/app/components/billing/pricing'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/billing', () => ({
|
||||
fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
billing: {
|
||||
invoices: vi.fn().mockResolvedValue({ url: 'https://invoice.example.com' }),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks (lightweight) ─────────────────────────────────
|
||||
vi.mock('@/app/components/base/icons/src/public/billing', () => ({
|
||||
Azure: () => <span data-testid="icon-azure" />,
|
||||
GoogleCloud: () => <span data-testid="icon-gcloud" />,
|
||||
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
|
||||
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
// Self-hosted List uses t() with returnObjects which returns string in mock;
|
||||
// mock it to avoid deep i18n dependency (unit tests cover this component)
|
||||
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
const defaultPlanData = {
|
||||
type: Plan.sandbox,
|
||||
usage: {
|
||||
buildApps: 1,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 0,
|
||||
vectorSpace: 10,
|
||||
annotatedResponse: 1,
|
||||
triggerEvents: 0,
|
||||
apiRateLimit: 0,
|
||||
},
|
||||
total: {
|
||||
buildApps: 5,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 50,
|
||||
vectorSpace: 50,
|
||||
annotatedResponse: 10,
|
||||
triggerEvents: 3000,
|
||||
apiRateLimit: 5000,
|
||||
},
|
||||
}
|
||||
|
||||
const setupContexts = (planOverrides: Record<string, unknown> = {}, appOverrides: Record<string, unknown> = {}) => {
|
||||
mockProviderCtx = {
|
||||
plan: { ...defaultPlanData, ...planOverrides },
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
}
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...appOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Pricing Modal Flow', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupContexts()
|
||||
})
|
||||
|
||||
// ─── 1. Initial Rendering ────────────────────────────────────────────────
|
||||
describe('Initial rendering', () => {
|
||||
it('should render header with close button and footer with pricing link', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Header close button exists (multiple plan buttons also exist)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
// Footer pricing link
|
||||
expect(screen.getByText(/plansCommon\.comparePlanAndFeatures/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should default to cloud category with three cloud plans', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Three cloud plans: sandbox, professional, team
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show plan range switcher (annual billing toggle) by default for cloud', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tax tip in footer for cloud category', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Use exact match to avoid matching taxTipSecond
|
||||
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Category Switching ───────────────────────────────────────────────
|
||||
describe('Category switching', () => {
|
||||
it('should switch to self-hosted plans when clicking self-hosted tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Click the self-hosted tab
|
||||
const selfTab = screen.getByText(/plansCommon\.self/i)
|
||||
await user.click(selfTab)
|
||||
|
||||
// Self-hosted plans should appear
|
||||
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
|
||||
|
||||
// Cloud plans should disappear
|
||||
expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide plan range switcher for self-hosted category', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
// Annual billing toggle should not be visible
|
||||
expect(screen.queryByText(/plansCommon\.annualBilling/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide tax tip in footer for self-hosted category', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch back to cloud plans when clicking cloud tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Switch to self-hosted
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument()
|
||||
|
||||
// Switch back to cloud
|
||||
await user.click(screen.getByText(/plansCommon\.cloud/i))
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Plan Range Switching (Monthly ↔ Yearly) ──────────────────────────
|
||||
describe('Plan range switching', () => {
|
||||
it('should show monthly prices by default', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Professional monthly price: $59
|
||||
const proPriceStr = `$${ALL_PLANS.professional.price}`
|
||||
expect(screen.getByText(proPriceStr)).toBeInTheDocument()
|
||||
|
||||
// Team monthly price: $159
|
||||
const teamPriceStr = `$${ALL_PLANS.team.price}`
|
||||
expect(screen.getByText(teamPriceStr)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Free" for sandbox plan regardless of range', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "most popular" badge only for professional plan', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Cloud Plan Button States ─────────────────────────────────────────
|
||||
describe('Cloud plan button states', () => {
|
||||
it('should show "Current Plan" for the current plan (sandbox)', () => {
|
||||
setupContexts({ type: Plan.sandbox })
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show specific button text for non-current plans', () => {
|
||||
setupContexts({ type: Plan.sandbox })
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Professional button text
|
||||
expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument()
|
||||
// Team button text
|
||||
expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should mark sandbox as "Current Plan" for professional user (enterprise normalized to team)', () => {
|
||||
setupContexts({ type: Plan.enterprise })
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Enterprise is normalized to team for display, so team is "Current Plan"
|
||||
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Self-Hosted Plan Details ─────────────────────────────────────────
|
||||
describe('Self-hosted plan details', () => {
|
||||
it('should show cloud provider icons only for premium plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
// Premium plan should show Azure and Google Cloud icons
|
||||
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "coming soon" text for premium plan cloud providers', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
expect(screen.getByText(/plans\.premium\.comingSoon/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 6. Close Handling ───────────────────────────────────────────────────
|
||||
describe('Close handling', () => {
|
||||
it('should call onCancel when pressing ESC key', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// ahooks useKeyPress listens on document for keydown events
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
keyCode: 27,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 7. Pricing URL ─────────────────────────────────────────────────────
|
||||
describe('Pricing page URL', () => {
|
||||
it('should render pricing link with correct URL', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
const link = screen.getByText(/plansCommon\.comparePlanAndFeatures/i)
|
||||
expect(link.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://dify.ai/en/pricing#plans-and-features',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,225 +0,0 @@
|
||||
/**
|
||||
* Integration test: Self-Hosted Plan Flow
|
||||
*
|
||||
* Tests the self-hosted plan items:
|
||||
* SelfHostedPlanItem → Button click → permission check → redirect to external URL
|
||||
*
|
||||
* Covers community/premium/enterprise plan rendering, external URL navigation,
|
||||
* and workspace manager permission enforcement.
|
||||
*/
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config'
|
||||
import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item'
|
||||
import { SelfHostedPlan } from '@/app/components/billing/type'
|
||||
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockToastNotify = vi.fn()
|
||||
|
||||
const originalLocation = window.location
|
||||
let assignedHref = ''
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/billing', () => ({
|
||||
Azure: () => <span data-testid="icon-azure" />,
|
||||
GoogleCloud: () => <span data-testid="icon-gcloud" />,
|
||||
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
|
||||
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (args: unknown) => mockToastNotify(args) },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Self-Hosted Plan Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupAppContext()
|
||||
|
||||
// Mock window.location with minimal getter/setter (Location props are non-enumerable)
|
||||
assignedHref = ''
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get href() { return assignedHref },
|
||||
set href(value: string) { assignedHref = value },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original location
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 1. Plan Rendering ──────────────────────────────────────────────────
|
||||
describe('Plan rendering', () => {
|
||||
it('should render community plan with name and description', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render premium plan with cloud provider icons', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render enterprise plan without cloud provider icons', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show price tip for community (free) plan', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show price tip for premium plan', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render features list for each plan', () => {
|
||||
const { unmount: unmount1 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument()
|
||||
unmount1()
|
||||
|
||||
const { unmount: unmount2 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument()
|
||||
unmount2()
|
||||
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show AWS marketplace icon for premium plan button', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Navigation Flow ─────────────────────────────────────────────────
|
||||
describe('Navigation flow', () => {
|
||||
it('should redirect to GitHub when clicking community plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(assignedHref).toBe(getStartedWithCommunityUrl)
|
||||
})
|
||||
|
||||
it('should redirect to AWS Marketplace when clicking premium plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(assignedHref).toBe(getWithPremiumUrl)
|
||||
})
|
||||
|
||||
it('should redirect to Typeform when clicking enterprise plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(assignedHref).toBe(contactSalesUrl)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Permission Check ────────────────────────────────────────────────
|
||||
describe('Permission check', () => {
|
||||
it('should show error toast when non-manager clicks community button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
// Should NOT redirect
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
|
||||
it('should show error toast when non-manager clicks premium button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
|
||||
it('should show error toast when non-manager clicks enterprise button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Create Dataset Flow
|
||||
*
|
||||
* Tests cross-module data flow: step-one data → step-two hooks → creation params → API call
|
||||
* Validates data contracts between steps.
|
||||
*/
|
||||
|
||||
import type { CustomFile } from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
|
||||
const mockCreateFirstDocument = vi.fn()
|
||||
const mockCreateDocument = vi.fn()
|
||||
vi.mock('@/service/knowledge/use-create-dataset', () => ({
|
||||
useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }),
|
||||
useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }),
|
||||
getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({
|
||||
workspace_id: 'ws-1',
|
||||
pages: pages.map(p => p.page_id),
|
||||
notion_credential_id: credentialId,
|
||||
}),
|
||||
getWebsiteInfo: (opts: { websitePages: { url: string }[], websiteCrawlProvider: string }) => ({
|
||||
urls: opts.websitePages.map(p => p.url),
|
||||
only_main_content: true,
|
||||
provider: opts.websiteCrawlProvider,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import hooks after mocks
|
||||
const { useSegmentationState, DEFAULT_SEGMENT_IDENTIFIER, DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP }
|
||||
= await import('@/app/components/datasets/create/step-two/hooks')
|
||||
const { useDocumentCreation, IndexingType }
|
||||
= await import('@/app/components/datasets/create/step-two/hooks')
|
||||
|
||||
const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 1024,
|
||||
extension: '.txt',
|
||||
mime_type: 'text/plain',
|
||||
created_at: 0,
|
||||
created_by: '',
|
||||
...overrides,
|
||||
} as CustomFile)
|
||||
|
||||
describe('Create Dataset Flow - Cross-Step Data Contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Step-One → Step-Two: Segmentation Defaults', () => {
|
||||
it('should initialise with correct default segmentation values', () => {
|
||||
const { result } = renderHook(() => useSegmentationState())
|
||||
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
|
||||
expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
|
||||
expect(result.current.overlap).toBe(DEFAULT_OVERLAP)
|
||||
expect(result.current.segmentationType).toBe(ProcessMode.general)
|
||||
})
|
||||
|
||||
it('should produce valid process rule for general chunking', () => {
|
||||
const { result } = renderHook(() => useSegmentationState())
|
||||
const processRule = result.current.getProcessRule(ChunkingMode.text)
|
||||
|
||||
// mode should be segmentationType = ProcessMode.general = 'custom'
|
||||
expect(processRule.mode).toBe('custom')
|
||||
expect(processRule.rules.segmentation).toEqual({
|
||||
separator: '\n\n', // unescaped from \\n\\n
|
||||
max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH,
|
||||
chunk_overlap: DEFAULT_OVERLAP,
|
||||
})
|
||||
// rules is empty initially since no default config loaded
|
||||
expect(processRule.rules.pre_processing_rules).toEqual([])
|
||||
})
|
||||
|
||||
it('should produce valid process rule for parent-child chunking', () => {
|
||||
const { result } = renderHook(() => useSegmentationState())
|
||||
const processRule = result.current.getProcessRule(ChunkingMode.parentChild)
|
||||
|
||||
expect(processRule.mode).toBe('hierarchical')
|
||||
expect(processRule.rules.parent_mode).toBe('paragraph')
|
||||
expect(processRule.rules.segmentation).toEqual({
|
||||
separator: '\n\n',
|
||||
max_tokens: 1024,
|
||||
})
|
||||
expect(processRule.rules.subchunk_segmentation).toEqual({
|
||||
separator: '\n',
|
||||
max_tokens: 512,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Step-Two → Creation API: Params Building', () => {
|
||||
it('should build valid creation params for file upload workflow', () => {
|
||||
const files = [createMockFile()]
|
||||
const { result: segResult } = renderHook(() => useSegmentationState())
|
||||
const { result: creationResult } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files,
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
|
||||
const retrievalConfig: RetrievalConfig = {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
}
|
||||
|
||||
const params = creationResult.current.buildCreationParams(
|
||||
ChunkingMode.text,
|
||||
'English',
|
||||
processRule,
|
||||
retrievalConfig,
|
||||
{ provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
IndexingType.QUALIFIED,
|
||||
)
|
||||
|
||||
expect(params).not.toBeNull()
|
||||
// File IDs come from file.id (not file.file.id)
|
||||
expect(params!.data_source.type).toBe(DataSourceType.FILE)
|
||||
expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1')
|
||||
|
||||
expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED)
|
||||
expect(params!.doc_form).toBe(ChunkingMode.text)
|
||||
expect(params!.doc_language).toBe('English')
|
||||
expect(params!.embedding_model).toBe('text-embedding-ada-002')
|
||||
expect(params!.embedding_model_provider).toBe('openai')
|
||||
expect(params!.process_rule.mode).toBe('custom')
|
||||
})
|
||||
|
||||
it('should validate params: overlap must not exceed maxChunkLength', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files: [createMockFile()],
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
// validateParams returns false (invalid) when overlap > maxChunkLength for general mode
|
||||
const isValid = result.current.validateParams({
|
||||
segmentationType: 'general',
|
||||
maxChunkLength: 100,
|
||||
limitMaxChunkLength: 4000,
|
||||
overlap: 200, // overlap > maxChunkLength
|
||||
indexType: IndexingType.QUALIFIED,
|
||||
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
rerankModelList: [],
|
||||
retrievalConfig: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
})
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate params: maxChunkLength must not exceed limit', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files: [createMockFile()],
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const isValid = result.current.validateParams({
|
||||
segmentationType: 'general',
|
||||
maxChunkLength: 5000,
|
||||
limitMaxChunkLength: 4000, // limit < maxChunkLength
|
||||
overlap: 50,
|
||||
indexType: IndexingType.QUALIFIED,
|
||||
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
rerankModelList: [],
|
||||
retrievalConfig: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
})
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Flow: Segmentation State → Process Rule → Creation Params Consistency', () => {
|
||||
it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => {
|
||||
const files = [createMockFile()]
|
||||
const { result: segResult } = renderHook(() => useSegmentationState())
|
||||
const { result: creationResult } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files,
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
// Change segmentation settings
|
||||
act(() => {
|
||||
segResult.current.setMaxChunkLength(2048)
|
||||
segResult.current.setOverlap(100)
|
||||
})
|
||||
|
||||
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
|
||||
expect(processRule.rules.segmentation.max_tokens).toBe(2048)
|
||||
expect(processRule.rules.segmentation.chunk_overlap).toBe(100)
|
||||
|
||||
const params = creationResult.current.buildCreationParams(
|
||||
ChunkingMode.text,
|
||||
'Chinese',
|
||||
processRule,
|
||||
{
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
{ provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
IndexingType.QUALIFIED,
|
||||
)
|
||||
|
||||
expect(params).not.toBeNull()
|
||||
expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048)
|
||||
expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100)
|
||||
expect(params!.doc_language).toBe('Chinese')
|
||||
})
|
||||
|
||||
it('should support parent-child mode through the full pipeline', () => {
|
||||
const files = [createMockFile()]
|
||||
const { result: segResult } = renderHook(() => useSegmentationState())
|
||||
const { result: creationResult } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files,
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild)
|
||||
const params = creationResult.current.buildCreationParams(
|
||||
ChunkingMode.parentChild,
|
||||
'English',
|
||||
processRule,
|
||||
{
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
{ provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
IndexingType.QUALIFIED,
|
||||
)
|
||||
|
||||
expect(params).not.toBeNull()
|
||||
expect(params!.doc_form).toBe(ChunkingMode.parentChild)
|
||||
expect(params!.process_rule.mode).toBe('hierarchical')
|
||||
expect(params!.process_rule.rules.parent_mode).toBe('paragraph')
|
||||
expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,451 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Dataset Settings Flow
|
||||
*
|
||||
* Tests cross-module data contracts in the dataset settings form:
|
||||
* useFormState hook ↔ index method config ↔ retrieval config ↔ permission state.
|
||||
*
|
||||
* The unit-level use-form-state.spec.ts validates the hook in isolation.
|
||||
* This integration test verifies that changing one configuration dimension
|
||||
* correctly cascades to dependent parts (index method → retrieval config,
|
||||
* permission → member list visibility, embedding model → embedding available state).
|
||||
*/
|
||||
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const mockMutateDatasets = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
|
||||
isReRankModelSelected: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
// --- Dataset factory ---
|
||||
|
||||
const createMockDataset = (overrides?: Partial<DataSet>): DataSet => ({
|
||||
id: 'ds-settings-1',
|
||||
name: 'Settings Test Dataset',
|
||||
description: 'Integration test dataset',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
indexing_technique: 'high_quality',
|
||||
indexing_status: 'completed',
|
||||
data_source_type: DataSourceType.FILE,
|
||||
doc_form: ChunkingMode.text,
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
embedding_available: true,
|
||||
app_count: 2,
|
||||
document_count: 10,
|
||||
total_document_count: 10,
|
||||
word_count: 5000,
|
||||
provider: 'vendor',
|
||||
tags: [],
|
||||
partial_member_list: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 2,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
} as RetrievalConfig,
|
||||
retrieval_model: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
} as RetrievalConfig,
|
||||
built_in_field_enabled: false,
|
||||
keyword_number: 10,
|
||||
created_by: 'user-1',
|
||||
updated_by: 'user-1',
|
||||
updated_at: Date.now(),
|
||||
runtime_mode: 'general',
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
let mockDataset: DataSet = createMockDataset()
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (
|
||||
selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown,
|
||||
) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }),
|
||||
}))
|
||||
|
||||
// Import after mocks are registered
|
||||
const { useFormState } = await import(
|
||||
'@/app/components/datasets/settings/form/hooks/use-form-state',
|
||||
)
|
||||
|
||||
describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdateDatasetSetting.mockResolvedValue({})
|
||||
mockDataset = createMockDataset()
|
||||
})
|
||||
|
||||
describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => {
|
||||
it('should initialise all form dimensions from a QUALIFIED dataset', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.name).toBe('Settings Test Dataset')
|
||||
expect(result.current.description).toBe('Integration test dataset')
|
||||
expect(result.current.indexMethod).toBe('high_quality')
|
||||
expect(result.current.embeddingModel).toEqual({
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-ada-002',
|
||||
})
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic)
|
||||
})
|
||||
|
||||
it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => {
|
||||
mockDataset = createMockDataset({
|
||||
indexing_technique: IndexingType.ECONOMICAL,
|
||||
embedding_model: '',
|
||||
embedding_model_provider: '',
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
} as RetrievalConfig,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
|
||||
expect(result.current.embeddingModel).toEqual({ provider: '', model: '' })
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Index Method Change → Retrieval Config Sync', () => {
|
||||
it('should allow switching index method from QUALIFIED to ECONOMICAL', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.indexMethod).toBe('high_quality')
|
||||
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
it('should allow updating retrieval config after index method switch', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setRetrievalConfig({
|
||||
...result.current.retrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
reranking_enable: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
|
||||
expect(result.current.retrievalConfig.reranking_enable).toBe(false)
|
||||
})
|
||||
|
||||
it('should preserve retrieval config when switching back to QUALIFIED', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
const originalConfig = { ...result.current.retrievalConfig }
|
||||
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.QUALIFIED)
|
||||
})
|
||||
|
||||
expect(result.current.indexMethod).toBe('high_quality')
|
||||
expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Change → Member List Visibility Logic', () => {
|
||||
it('should start with onlyMe permission and empty member selection', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.permission).toBe(DatasetPermission.onlyMe)
|
||||
expect(result.current.selectedMemberIDs).toEqual([])
|
||||
})
|
||||
|
||||
it('should enable member selection when switching to partialMembers', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
})
|
||||
|
||||
expect(result.current.permission).toBe(DatasetPermission.partialMembers)
|
||||
expect(result.current.memberList).toHaveLength(3)
|
||||
expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3'])
|
||||
})
|
||||
|
||||
it('should persist member selection through permission toggle', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
result.current.setSelectedMemberIDs(['user-1', 'user-3'])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.allTeamMembers)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
})
|
||||
|
||||
expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3'])
|
||||
})
|
||||
|
||||
it('should include partial_member_list in save payload only for partialMembers', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
result.current.setSelectedMemberIDs(['user-2'])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-settings-1',
|
||||
body: expect.objectContaining({
|
||||
permission: DatasetPermission.partialMembers,
|
||||
partial_member_list: [
|
||||
expect.objectContaining({ user_id: 'user-2', role: 'admin' }),
|
||||
],
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should not include partial_member_list for allTeamMembers permission', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.allTeamMembers)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
const savedBody = mockUpdateDatasetSetting.mock.calls[0][0].body as Record<string, unknown>
|
||||
expect(savedBody).not.toHaveProperty('partial_member_list')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission Validation → All Fields Together', () => {
|
||||
it('should reject empty name on save', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setName('')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should include all configuration dimensions in a successful save', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setName('Updated Name')
|
||||
result.current.setDescription('Updated Description')
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
result.current.setKeywordNumber(15)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-settings-1',
|
||||
body: expect.objectContaining({
|
||||
name: 'Updated Name',
|
||||
description: 'Updated Description',
|
||||
indexing_technique: 'economy',
|
||||
keyword_number: 15,
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should call mutateDatasets and invalidDatasetList after successful save', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateDatasets).toHaveBeenCalled()
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embedding Model Change → Retrieval Config Cascade', () => {
|
||||
it('should update embedding model independently of retrieval config', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
const originalRetrievalConfig = { ...result.current.retrievalConfig }
|
||||
|
||||
act(() => {
|
||||
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' })
|
||||
})
|
||||
|
||||
expect(result.current.embeddingModel).toEqual({
|
||||
provider: 'cohere',
|
||||
model: 'embed-english-v3.0',
|
||||
})
|
||||
expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method)
|
||||
})
|
||||
|
||||
it('should propagate embedding model into weighted retrieval config on save', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' })
|
||||
result.current.setRetrievalConfig({
|
||||
...result.current.retrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
weights: {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.6,
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: '',
|
||||
},
|
||||
keyword_setting: { keyword_weight: 0.4 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-settings-1',
|
||||
body: expect.objectContaining({
|
||||
embedding_model: 'embed-v3',
|
||||
embedding_model_provider: 'cohere',
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'cohere',
|
||||
embedding_model_name: 'embed-v3',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle switching from semantic to hybrid search with embedding model', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setRetrievalConfig({
|
||||
...result.current.retrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v3.0',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid)
|
||||
expect(result.current.retrievalConfig.reranking_enable).toBe(true)
|
||||
expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,335 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Document Management Flow
|
||||
*
|
||||
* Tests cross-module interactions: query state (URL-based) → document list sorting →
|
||||
* document selection → status filter utilities.
|
||||
* Validates the data contract between documents page hooks and list component hooks.
|
||||
*/
|
||||
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(''),
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => '/datasets/ds-1/documents',
|
||||
}))
|
||||
|
||||
const { sanitizeStatusValue, normalizeStatusForQuery } = await import(
|
||||
'@/app/components/datasets/documents/status-filter',
|
||||
)
|
||||
|
||||
const { useDocumentSort } = await import(
|
||||
'@/app/components/datasets/documents/components/document-list/hooks/use-document-sort',
|
||||
)
|
||||
const { useDocumentSelection } = await import(
|
||||
'@/app/components/datasets/documents/components/document-list/hooks/use-document-selection',
|
||||
)
|
||||
const { default: useDocumentListQueryState } = await import(
|
||||
'@/app/components/datasets/documents/hooks/use-document-list-query-state',
|
||||
)
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({
|
||||
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: 'test-doc.txt',
|
||||
word_count: 500,
|
||||
hit_count: 10,
|
||||
created_at: Date.now() / 1000,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
display_status: 'available',
|
||||
indexing_status: 'completed',
|
||||
enabled: true,
|
||||
archived: false,
|
||||
doc_type: null,
|
||||
doc_metadata: null,
|
||||
position: 1,
|
||||
dataset_process_rule_id: 'rule-1',
|
||||
...overrides,
|
||||
} as LocalDoc)
|
||||
|
||||
describe('Document Management Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Status Filter Utilities', () => {
|
||||
it('should sanitize valid status values', () => {
|
||||
expect(sanitizeStatusValue('all')).toBe('all')
|
||||
expect(sanitizeStatusValue('available')).toBe('available')
|
||||
expect(sanitizeStatusValue('error')).toBe('error')
|
||||
})
|
||||
|
||||
it('should fallback to "all" for invalid values', () => {
|
||||
expect(sanitizeStatusValue(null)).toBe('all')
|
||||
expect(sanitizeStatusValue(undefined)).toBe('all')
|
||||
expect(sanitizeStatusValue('')).toBe('all')
|
||||
expect(sanitizeStatusValue('nonexistent')).toBe('all')
|
||||
})
|
||||
|
||||
it('should handle URL aliases', () => {
|
||||
// 'active' is aliased to 'available'
|
||||
expect(sanitizeStatusValue('active')).toBe('available')
|
||||
})
|
||||
|
||||
it('should normalize status for API query', () => {
|
||||
expect(normalizeStatusForQuery('all')).toBe('all')
|
||||
// 'enabled' normalized to 'available' for query
|
||||
expect(normalizeStatusForQuery('enabled')).toBe('available')
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL-based Query State', () => {
|
||||
it('should parse default query from empty URL params', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update query and push to router', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test', page: 2 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// The push call should contain the updated query params
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toContain('keyword=test')
|
||||
expect(pushUrl).toContain('page=2')
|
||||
})
|
||||
|
||||
it('should reset query to defaults', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// Default query omits default values from URL
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toBe('/datasets/ds-1/documents')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Sort Integration', () => {
|
||||
it('should return documents unsorted when no sort field set', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }),
|
||||
createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }),
|
||||
createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
}))
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
expect(result.current.sortedDocuments).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should sort by name descending', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'Banana.txt' }),
|
||||
createDoc({ id: 'doc-2', name: 'Apple.txt' }),
|
||||
createDoc({ id: 'doc-3', name: 'Cherry.txt' }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
expect(result.current.sortField).toBe('name')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
const names = result.current.sortedDocuments.map(d => d.name)
|
||||
expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt'])
|
||||
})
|
||||
|
||||
it('should toggle sort order on same field click', () => {
|
||||
const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
}))
|
||||
|
||||
act(() => result.current.handleSort('name'))
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
|
||||
act(() => result.current.handleSort('name'))
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
})
|
||||
|
||||
it('should filter by status before sorting', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }),
|
||||
createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }),
|
||||
createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: 'available',
|
||||
remoteSortValue: '-created_at',
|
||||
}))
|
||||
|
||||
// Only 'available' documents should remain
|
||||
expect(result.current.sortedDocuments).toHaveLength(2)
|
||||
expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Selection Integration', () => {
|
||||
it('should manage selection state externally', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1' }),
|
||||
createDoc({ id: 'doc-2' }),
|
||||
createDoc({ id: 'doc-3' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange,
|
||||
}))
|
||||
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
expect(result.current.isSomeSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should select all documents', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1' }),
|
||||
createDoc({ id: 'doc-2' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectAll()
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['doc-1', 'doc-2']),
|
||||
)
|
||||
})
|
||||
|
||||
it('should detect all-selected state', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1' }),
|
||||
createDoc({ id: 'doc-2' }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc-1', 'doc-2'],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.isAllSelected).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect partial selection', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1' }),
|
||||
createDoc({ id: 'doc-2' }),
|
||||
createDoc({ id: 'doc-3' }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc-1'],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.isSomeSelected).toBe(true)
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify downloadable selected documents (FILE type only)', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }),
|
||||
createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc-1', 'doc-2'],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.downloadableSelectedIds).toEqual(['doc-1'])
|
||||
})
|
||||
|
||||
it('should clear selection', () => {
|
||||
const onSelectedIdChange = vi.fn()
|
||||
const docs = [createDoc({ id: 'doc-1' })]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc-1'],
|
||||
onSelectedIdChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.clearSelection()
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
|
||||
it('should maintain consistent default state across all hooks', () => {
|
||||
const docs = [createDoc({ id: 'doc-1' })]
|
||||
const { result: queryResult } = renderHook(() => useDocumentListQueryState())
|
||||
const { result: sortResult } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: queryResult.current.query.status,
|
||||
remoteSortValue: queryResult.current.query.sort,
|
||||
}))
|
||||
const { result: selResult } = renderHook(() => useDocumentSelection({
|
||||
documents: sortResult.current.sortedDocuments,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
|
||||
// Query defaults
|
||||
expect(queryResult.current.query.sort).toBe('-created_at')
|
||||
expect(queryResult.current.query.status).toBe('all')
|
||||
|
||||
// Sort inherits 'all' status → no filtering applied
|
||||
expect(sortResult.current.sortedDocuments).toHaveLength(1)
|
||||
|
||||
// Selection starts empty
|
||||
expect(selResult.current.isAllSelected).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Integration Test: External Knowledge Base Creation Flow
|
||||
*
|
||||
* Tests the data contract, validation logic, and API interaction
|
||||
* for external knowledge base creation.
|
||||
*/
|
||||
|
||||
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// --- Factory ---
|
||||
const createFormData = (overrides?: Partial<CreateKnowledgeBaseReq>): CreateKnowledgeBaseReq => ({
|
||||
name: 'My External KB',
|
||||
description: 'A test external knowledge base',
|
||||
external_knowledge_api_id: 'api-1',
|
||||
external_knowledge_id: 'ext-kb-123',
|
||||
external_retrieval_model: {
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
provider: 'external',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('External Knowledge Base Creation Flow', () => {
|
||||
describe('Data Contract: CreateKnowledgeBaseReq', () => {
|
||||
it('should define a complete form structure', () => {
|
||||
const form = createFormData()
|
||||
|
||||
expect(form).toHaveProperty('name')
|
||||
expect(form).toHaveProperty('external_knowledge_api_id')
|
||||
expect(form).toHaveProperty('external_knowledge_id')
|
||||
expect(form).toHaveProperty('external_retrieval_model')
|
||||
expect(form).toHaveProperty('provider')
|
||||
expect(form.provider).toBe('external')
|
||||
})
|
||||
|
||||
it('should include retrieval model settings', () => {
|
||||
const form = createFormData()
|
||||
|
||||
expect(form.external_retrieval_model).toEqual({
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow partial overrides', () => {
|
||||
const form = createFormData({
|
||||
name: 'Custom Name',
|
||||
external_retrieval_model: {
|
||||
top_k: 10,
|
||||
score_threshold: 0.8,
|
||||
score_threshold_enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(form.name).toBe('Custom Name')
|
||||
expect(form.external_retrieval_model.top_k).toBe(10)
|
||||
expect(form.external_retrieval_model.score_threshold_enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Validation Logic', () => {
|
||||
const isFormValid = (form: CreateKnowledgeBaseReq): boolean => {
|
||||
return (
|
||||
form.name.trim() !== ''
|
||||
&& form.external_knowledge_api_id !== ''
|
||||
&& form.external_knowledge_id !== ''
|
||||
&& form.external_retrieval_model.top_k !== undefined
|
||||
&& form.external_retrieval_model.score_threshold !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
it('should validate a complete form', () => {
|
||||
const form = createFormData()
|
||||
expect(isFormValid(form)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject empty name', () => {
|
||||
const form = createFormData({ name: '' })
|
||||
expect(isFormValid(form)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject whitespace-only name', () => {
|
||||
const form = createFormData({ name: ' ' })
|
||||
expect(isFormValid(form)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty external_knowledge_api_id', () => {
|
||||
const form = createFormData({ external_knowledge_api_id: '' })
|
||||
expect(isFormValid(form)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty external_knowledge_id', () => {
|
||||
const form = createFormData({ external_knowledge_id: '' })
|
||||
expect(isFormValid(form)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form State Transitions', () => {
|
||||
it('should start with empty default state', () => {
|
||||
const defaultForm: CreateKnowledgeBaseReq = {
|
||||
name: '',
|
||||
description: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_id: '',
|
||||
external_retrieval_model: {
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
provider: 'external',
|
||||
}
|
||||
|
||||
// Verify default state matches component's initial useState
|
||||
expect(defaultForm.name).toBe('')
|
||||
expect(defaultForm.external_knowledge_api_id).toBe('')
|
||||
expect(defaultForm.external_knowledge_id).toBe('')
|
||||
expect(defaultForm.provider).toBe('external')
|
||||
})
|
||||
|
||||
it('should support immutable form updates', () => {
|
||||
const form = createFormData({ name: '' })
|
||||
const updated = { ...form, name: 'Updated Name' }
|
||||
|
||||
expect(form.name).toBe('')
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
// Other fields should remain unchanged
|
||||
expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id)
|
||||
})
|
||||
|
||||
it('should support retrieval model updates', () => {
|
||||
const form = createFormData()
|
||||
const updated = {
|
||||
...form,
|
||||
external_retrieval_model: {
|
||||
...form.external_retrieval_model,
|
||||
top_k: 10,
|
||||
score_threshold_enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
expect(updated.external_retrieval_model.top_k).toBe(10)
|
||||
expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true)
|
||||
// Unchanged field
|
||||
expect(updated.external_retrieval_model.score_threshold).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Call Data Contract', () => {
|
||||
it('should produce a valid API payload from form data', () => {
|
||||
const form = createFormData()
|
||||
|
||||
// The API expects the full CreateKnowledgeBaseReq
|
||||
expect(form.name).toBeTruthy()
|
||||
expect(form.external_knowledge_api_id).toBeTruthy()
|
||||
expect(form.external_knowledge_id).toBeTruthy()
|
||||
expect(form.provider).toBe('external')
|
||||
expect(typeof form.external_retrieval_model.top_k).toBe('number')
|
||||
expect(typeof form.external_retrieval_model.score_threshold).toBe('number')
|
||||
expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should support optional description', () => {
|
||||
const formWithDesc = createFormData({ description: 'Some description' })
|
||||
const formWithoutDesc = createFormData({ description: '' })
|
||||
|
||||
expect(formWithDesc.description).toBe('Some description')
|
||||
expect(formWithoutDesc.description).toBe('')
|
||||
})
|
||||
|
||||
it('should validate retrieval model bounds', () => {
|
||||
const form = createFormData({
|
||||
external_retrieval_model: {
|
||||
top_k: 0,
|
||||
score_threshold: 0,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(form.external_retrieval_model.top_k).toBe(0)
|
||||
expect(form.external_retrieval_model.score_threshold).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('External API List Integration', () => {
|
||||
it('should validate API item structure', () => {
|
||||
const apiItem = {
|
||||
id: 'api-1',
|
||||
name: 'Production API',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com',
|
||||
api_key: 'key-123',
|
||||
},
|
||||
}
|
||||
|
||||
expect(apiItem).toHaveProperty('id')
|
||||
expect(apiItem).toHaveProperty('name')
|
||||
expect(apiItem).toHaveProperty('settings')
|
||||
expect(apiItem.settings).toHaveProperty('endpoint')
|
||||
expect(apiItem.settings).toHaveProperty('api_key')
|
||||
})
|
||||
|
||||
it('should link API selection to form data', () => {
|
||||
const selectedApi = { id: 'api-2', name: 'Staging API' }
|
||||
const form = createFormData({
|
||||
external_knowledge_api_id: selectedApi.id,
|
||||
})
|
||||
|
||||
expect(form.external_knowledge_api_id).toBe('api-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,404 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Hit Testing Flow
|
||||
*
|
||||
* Tests the query submission → API response → callback chain flow
|
||||
* by rendering the actual QueryInput component and triggering user interactions.
|
||||
* Validates that the production onSubmit logic correctly constructs payloads
|
||||
* and invokes callbacks on success/failure.
|
||||
*/
|
||||
|
||||
import type {
|
||||
HitTestingResponse,
|
||||
Query,
|
||||
} from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import QueryInput from '@/app/components/datasets/hit-testing/components/query-input'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
default: {},
|
||||
useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })),
|
||||
useDatasetDetailContextWithSelector: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(() => ({})),
|
||||
useContextSelector: vi.fn(() => false),
|
||||
createContext: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({
|
||||
default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => (
|
||||
<div data-testid="image-uploader-mock">
|
||||
{textArea}
|
||||
{actionButton}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// --- Factories ---
|
||||
|
||||
const createRetrievalConfig = (overrides = {}): RetrievalConfig => ({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_mode: undefined,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
weights: undefined,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
...overrides,
|
||||
} as RetrievalConfig)
|
||||
|
||||
const createHitTestingResponse = (numResults: number): HitTestingResponse => ({
|
||||
query: {
|
||||
content: 'What is Dify?',
|
||||
tsne_position: { x: 0, y: 0 },
|
||||
},
|
||||
records: Array.from({ length: numResults }, (_, i) => ({
|
||||
segment: {
|
||||
id: `seg-${i}`,
|
||||
document: {
|
||||
id: `doc-${i}`,
|
||||
data_source_type: 'upload_file',
|
||||
name: `document-${i}.txt`,
|
||||
doc_type: null as unknown as import('@/models/datasets').DocType,
|
||||
},
|
||||
content: `Result content ${i}`,
|
||||
sign_content: `Result content ${i}`,
|
||||
position: i + 1,
|
||||
word_count: 100 + i * 50,
|
||||
tokens: 50 + i * 25,
|
||||
keywords: ['test', 'dify'],
|
||||
hit_count: i * 5,
|
||||
index_node_hash: `hash-${i}`,
|
||||
answer: '',
|
||||
},
|
||||
content: {
|
||||
id: `seg-${i}`,
|
||||
document: {
|
||||
id: `doc-${i}`,
|
||||
data_source_type: 'upload_file',
|
||||
name: `document-${i}.txt`,
|
||||
doc_type: null as unknown as import('@/models/datasets').DocType,
|
||||
},
|
||||
content: `Result content ${i}`,
|
||||
sign_content: `Result content ${i}`,
|
||||
position: i + 1,
|
||||
word_count: 100 + i * 50,
|
||||
tokens: 50 + i * 25,
|
||||
keywords: ['test', 'dify'],
|
||||
hit_count: i * 5,
|
||||
index_node_hash: `hash-${i}`,
|
||||
answer: '',
|
||||
},
|
||||
score: 0.95 - i * 0.1,
|
||||
tsne_position: { x: 0, y: 0 },
|
||||
child_chunks: null,
|
||||
files: [],
|
||||
})),
|
||||
})
|
||||
|
||||
const createTextQuery = (content: string): Query[] => [
|
||||
{ content, content_type: 'text_query', file_info: null },
|
||||
]
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const findSubmitButton = () => {
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
|
||||
expect(submitButton).toBeTruthy()
|
||||
return submitButton!
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('Hit Testing Flow', () => {
|
||||
const mockHitTestingMutation = vi.fn()
|
||||
const mockExternalMutation = vi.fn()
|
||||
const mockSetHitResult = vi.fn()
|
||||
const mockSetExternalHitResult = vi.fn()
|
||||
const mockOnUpdateList = vi.fn()
|
||||
const mockSetQueries = vi.fn()
|
||||
const mockOnClickRetrievalMethod = vi.fn()
|
||||
const mockOnSubmit = vi.fn()
|
||||
|
||||
const createDefaultProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
onUpdateList: mockOnUpdateList,
|
||||
setHitResult: mockSetHitResult,
|
||||
setExternalHitResult: mockSetExternalHitResult,
|
||||
loading: false,
|
||||
queries: [] as Query[],
|
||||
setQueries: mockSetQueries,
|
||||
isExternal: false,
|
||||
onClickRetrievalMethod: mockOnClickRetrievalMethod,
|
||||
retrievalConfig: createRetrievalConfig(),
|
||||
isEconomy: false,
|
||||
onSubmit: mockOnSubmit,
|
||||
hitTestingMutation: mockHitTestingMutation,
|
||||
externalKnowledgeBaseHitTestingMutation: mockExternalMutation,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Query Submission → API Call', () => {
|
||||
it('should call hitTestingMutation with correct payload including retrieval model', async () => {
|
||||
const retrievalConfig = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
})
|
||||
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3))
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('How does RAG work?'),
|
||||
retrievalConfig,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHitTestingMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: 'How does RAG work?',
|
||||
attachment_ids: [],
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should override search_method to keywordSearch when isEconomy is true', async () => {
|
||||
const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic })
|
||||
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1))
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('test query'),
|
||||
retrievalConfig,
|
||||
isEconomy: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHitTestingMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty results by calling setHitResult with empty records', async () => {
|
||||
const emptyResponse = createHitTestingResponse(0)
|
||||
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
|
||||
options?.onSuccess?.(emptyResponse)
|
||||
return emptyResponse
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('nonexistent topic'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetHitResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ records: [] }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call success callbacks when mutation resolves without onSuccess', async () => {
|
||||
// Simulate a mutation that resolves but does not invoke the onSuccess callback
|
||||
mockHitTestingMutation.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('test'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHitTestingMutation).toHaveBeenCalled()
|
||||
})
|
||||
// Success callbacks should not fire when onSuccess is not invoked
|
||||
expect(mockSetHitResult).not.toHaveBeenCalled()
|
||||
expect(mockOnUpdateList).not.toHaveBeenCalled()
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Response → Results Data Contract', () => {
|
||||
it('should produce results with required segment fields for rendering', () => {
|
||||
const response = createHitTestingResponse(3)
|
||||
|
||||
// Validate each result has the fields needed by ResultItem component
|
||||
response.records.forEach((record) => {
|
||||
expect(record.segment).toHaveProperty('id')
|
||||
expect(record.segment).toHaveProperty('content')
|
||||
expect(record.segment).toHaveProperty('position')
|
||||
expect(record.segment).toHaveProperty('word_count')
|
||||
expect(record.segment).toHaveProperty('document')
|
||||
expect(record.segment.document).toHaveProperty('name')
|
||||
expect(record.score).toBeGreaterThanOrEqual(0)
|
||||
expect(record.score).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain correct score ordering', () => {
|
||||
const response = createHitTestingResponse(5)
|
||||
|
||||
for (let i = 1; i < response.records.length; i++) {
|
||||
expect(response.records[i - 1].score).toBeGreaterThanOrEqual(response.records[i].score)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include document metadata for result item display', () => {
|
||||
const response = createHitTestingResponse(1)
|
||||
const record = response.records[0]
|
||||
|
||||
expect(record.segment.document.name).toBeTruthy()
|
||||
expect(record.segment.document.data_source_type).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Successful Submission → Callback Chain', () => {
|
||||
it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => {
|
||||
const response = createHitTestingResponse(3)
|
||||
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
|
||||
options?.onSuccess?.(response)
|
||||
return response
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('Test query'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetHitResult).toHaveBeenCalledWith(response)
|
||||
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSubmit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should trigger records list refresh via onUpdateList after query', async () => {
|
||||
const response = createHitTestingResponse(1)
|
||||
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
|
||||
options?.onSuccess?.(response)
|
||||
return response
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('new query'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('External KB Hit Testing', () => {
|
||||
it('should use external mutation with correct payload for external datasets', async () => {
|
||||
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
|
||||
const response = { records: [] }
|
||||
options?.onSuccess?.(response)
|
||||
return response
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('test'),
|
||||
isExternal: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExternalMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: 'test',
|
||||
external_retrieval_model: expect.objectContaining({
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
// Internal mutation should NOT be called
|
||||
expect(mockHitTestingMutation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call setExternalHitResult and onUpdateList on successful external submission', async () => {
|
||||
const externalResponse = { records: [] }
|
||||
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
|
||||
options?.onSuccess?.(externalResponse)
|
||||
return externalResponse
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('external query'),
|
||||
isExternal: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse)
|
||||
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,337 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Metadata Management Flow
|
||||
*
|
||||
* Tests the cross-module composition of metadata name validation, type constraints,
|
||||
* and duplicate detection across the metadata management hooks.
|
||||
*
|
||||
* The unit-level use-check-metadata-name.spec.ts tests the validation hook alone.
|
||||
* This integration test verifies:
|
||||
* - Name validation combined with existing metadata list (duplicate detection)
|
||||
* - Metadata type enum constraints matching expected data model
|
||||
* - Full add/rename workflow: validate name → check duplicates → allow or reject
|
||||
* - Name uniqueness logic: existing metadata keeps its own name, cannot take another's
|
||||
*/
|
||||
|
||||
import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { DataType } from '@/app/components/datasets/metadata/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { default: useCheckMetadataName } = await import(
|
||||
'@/app/components/datasets/metadata/hooks/use-check-metadata-name',
|
||||
)
|
||||
|
||||
// --- Factory functions ---
|
||||
|
||||
const createMetadataItem = (
|
||||
id: string,
|
||||
name: string,
|
||||
type = DataType.string,
|
||||
count = 0,
|
||||
): MetadataItemWithValueLength => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
count,
|
||||
})
|
||||
|
||||
const createMetadataList = (): MetadataItemWithValueLength[] => [
|
||||
createMetadataItem('meta-1', 'author', DataType.string, 5),
|
||||
createMetadataItem('meta-2', 'created_date', DataType.time, 10),
|
||||
createMetadataItem('meta-3', 'page_count', DataType.number, 3),
|
||||
createMetadataItem('meta-4', 'source_url', DataType.string, 8),
|
||||
createMetadataItem('meta-5', 'version', DataType.number, 2),
|
||||
]
|
||||
|
||||
describe('Metadata Management Flow - Cross-Module Validation Composition', () => {
|
||||
describe('Name Validation Flow: Format Rules', () => {
|
||||
it('should accept valid lowercase names with underscores', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
expect(result.current.checkName('valid_name').errorMsg).toBe('')
|
||||
expect(result.current.checkName('author').errorMsg).toBe('')
|
||||
expect(result.current.checkName('page_count').errorMsg).toBe('')
|
||||
expect(result.current.checkName('v2_field').errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should reject empty names', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
expect(result.current.checkName('').errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject names with invalid characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
expect(result.current.checkName('Author').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('my-field').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('field name').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('1field').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('_private').errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject names exceeding 255 characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
const longName = 'a'.repeat(256)
|
||||
expect(result.current.checkName(longName).errorMsg).toBeTruthy()
|
||||
|
||||
const maxName = 'a'.repeat(255)
|
||||
expect(result.current.checkName(maxName).errorMsg).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Metadata Type Constraints: Enum Values Match Expected Set', () => {
|
||||
it('should define exactly three data types', () => {
|
||||
const typeValues = Object.values(DataType)
|
||||
expect(typeValues).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should include string, number, and time types', () => {
|
||||
expect(DataType.string).toBe('string')
|
||||
expect(DataType.number).toBe('number')
|
||||
expect(DataType.time).toBe('time')
|
||||
})
|
||||
|
||||
it('should use consistent types in metadata items', () => {
|
||||
const metadataList = createMetadataList()
|
||||
|
||||
const stringItems = metadataList.filter(m => m.type === DataType.string)
|
||||
const numberItems = metadataList.filter(m => m.type === DataType.number)
|
||||
const timeItems = metadataList.filter(m => m.type === DataType.time)
|
||||
|
||||
expect(stringItems).toHaveLength(2)
|
||||
expect(numberItems).toHaveLength(2)
|
||||
expect(timeItems).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should enforce type-safe metadata item construction', () => {
|
||||
const item = createMetadataItem('test-1', 'test_field', DataType.number, 0)
|
||||
|
||||
expect(item.id).toBe('test-1')
|
||||
expect(item.name).toBe('test_field')
|
||||
expect(item.type).toBe(DataType.number)
|
||||
expect(item.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => {
|
||||
it('should detect duplicate names against an existing metadata list', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const checkDuplicate = (newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return existingMetadata.some(m => m.name === newName)
|
||||
}
|
||||
|
||||
expect(checkDuplicate('author')).toBe(true)
|
||||
expect(checkDuplicate('created_date')).toBe(true)
|
||||
expect(checkDuplicate('page_count')).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow names that do not conflict with existing metadata', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isNameAvailable = (newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName)
|
||||
}
|
||||
|
||||
expect(isNameAvailable('category')).toBe(true)
|
||||
expect(isNameAvailable('file_size')).toBe(true)
|
||||
expect(isNameAvailable('language')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject names that fail format validation before duplicate check', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return { valid: false, reason: 'format' }
|
||||
return { valid: true, reason: '' }
|
||||
}
|
||||
|
||||
expect(validateAndCheckDuplicate('Author').reason).toBe('format')
|
||||
expect(validateAndCheckDuplicate('').reason).toBe('format')
|
||||
expect(validateAndCheckDuplicate('valid_name').valid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Name Uniqueness Across Edits: Rename Workflow', () => {
|
||||
it('should allow an existing metadata item to keep its own name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
// Allow keeping the same name (skip self in duplicate check)
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
// Author keeping its own name should be valid
|
||||
expect(isRenameValid('meta-1', 'author')).toBe(true)
|
||||
// page_count keeping its own name should be valid
|
||||
expect(isRenameValid('meta-3', 'page_count')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject renaming to another existing metadata name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
// Author trying to rename to "page_count" (taken by meta-3)
|
||||
expect(isRenameValid('meta-1', 'page_count')).toBe(false)
|
||||
// version trying to rename to "source_url" (taken by meta-4)
|
||||
expect(isRenameValid('meta-5', 'source_url')).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow renaming to a completely new valid name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
expect(isRenameValid('meta-1', 'document_author')).toBe(true)
|
||||
expect(isRenameValid('meta-2', 'publish_date')).toBe(true)
|
||||
expect(isRenameValid('meta-3', 'total_pages')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject renaming with an invalid format even if name is unique', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
expect(isRenameValid('meta-1', 'New Author')).toBe(false)
|
||||
expect(isRenameValid('meta-2', '2024_date')).toBe(false)
|
||||
expect(isRenameValid('meta-3', '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Metadata Management Workflow', () => {
|
||||
it('should support a complete add-validate-check-duplicate cycle', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const addMetadataField = (
|
||||
name: string,
|
||||
type: DataType,
|
||||
): { success: boolean, error?: string } => {
|
||||
const formatCheck = result.current.checkName(name)
|
||||
if (formatCheck.errorMsg)
|
||||
return { success: false, error: 'invalid_format' }
|
||||
|
||||
if (existingMetadata.some(m => m.name === name))
|
||||
return { success: false, error: 'duplicate_name' }
|
||||
|
||||
existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Add a valid new field
|
||||
const result1 = addMetadataField('department', DataType.string)
|
||||
expect(result1.success).toBe(true)
|
||||
expect(existingMetadata).toHaveLength(6)
|
||||
|
||||
// Try to add a duplicate
|
||||
const result2 = addMetadataField('author', DataType.string)
|
||||
expect(result2.success).toBe(false)
|
||||
expect(result2.error).toBe('duplicate_name')
|
||||
expect(existingMetadata).toHaveLength(6)
|
||||
|
||||
// Try to add an invalid name
|
||||
const result3 = addMetadataField('Invalid Name', DataType.string)
|
||||
expect(result3.success).toBe(false)
|
||||
expect(result3.error).toBe('invalid_format')
|
||||
expect(existingMetadata).toHaveLength(6)
|
||||
|
||||
// Add another valid field
|
||||
const result4 = addMetadataField('priority_level', DataType.number)
|
||||
expect(result4.success).toBe(true)
|
||||
expect(existingMetadata).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should support a complete rename workflow with validation chain', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const renameMetadataField = (
|
||||
itemId: string,
|
||||
newName: string,
|
||||
): { success: boolean, error?: string } => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return { success: false, error: 'invalid_format' }
|
||||
|
||||
if (existingMetadata.some(m => m.name === newName && m.id !== itemId))
|
||||
return { success: false, error: 'duplicate_name' }
|
||||
|
||||
const item = existingMetadata.find(m => m.id === itemId)
|
||||
if (!item)
|
||||
return { success: false, error: 'not_found' }
|
||||
|
||||
// Simulate the rename in-place
|
||||
const index = existingMetadata.indexOf(item)
|
||||
existingMetadata[index] = { ...item, name: newName }
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Rename author to document_author
|
||||
expect(renameMetadataField('meta-1', 'document_author').success).toBe(true)
|
||||
expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author')
|
||||
|
||||
// Try renaming created_date to page_count (already taken)
|
||||
expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name')
|
||||
|
||||
// Rename to invalid format
|
||||
expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format')
|
||||
|
||||
// Rename non-existent item
|
||||
expect(renameMetadataField('meta-999', 'something').error).toBe('not_found')
|
||||
})
|
||||
|
||||
it('should maintain validation consistency across multiple operations', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
// Validate the same name multiple times for consistency
|
||||
const name = 'consistent_field'
|
||||
const results = Array.from({ length: 5 }, () => result.current.checkName(name))
|
||||
|
||||
expect(results.every(r => r.errorMsg === '')).toBe(true)
|
||||
|
||||
// Validate an invalid name multiple times
|
||||
const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid'))
|
||||
expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,477 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Pipeline Data Source Store Composition
|
||||
*
|
||||
* Tests cross-slice interactions in the pipeline data source Zustand store.
|
||||
* The unit-level slice specs test each slice in isolation.
|
||||
* This integration test verifies:
|
||||
* - Store initialization produces correct defaults across all slices
|
||||
* - Cross-slice coordination (e.g. credential shared across slices)
|
||||
* - State isolation: changes in one slice do not affect others
|
||||
* - Full workflow simulation through credential → source → data path
|
||||
*/
|
||||
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import type { CrawlResultItem, FileItem } from '@/models/datasets'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
|
||||
import { CrawlStep } from '@/models/datasets'
|
||||
import { OnlineDriveFileType } from '@/models/pipeline'
|
||||
|
||||
// --- Factory functions ---
|
||||
|
||||
const createFileItem = (id: string): FileItem => ({
|
||||
fileID: id,
|
||||
file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'],
|
||||
progress: 100,
|
||||
})
|
||||
|
||||
const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({
|
||||
title: title ?? `Page: ${url}`,
|
||||
markdown: `# ${title ?? url}\n\nContent for ${url}`,
|
||||
description: `Description for ${url}`,
|
||||
source_url: url,
|
||||
})
|
||||
|
||||
const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({
|
||||
id,
|
||||
name,
|
||||
size: 2048,
|
||||
type,
|
||||
})
|
||||
|
||||
const createNotionPage = (pageId: string): NotionPage => ({
|
||||
page_id: pageId,
|
||||
page_name: `Page ${pageId}`,
|
||||
page_icon: null,
|
||||
is_bound: true,
|
||||
parent_id: 'parent-1',
|
||||
type: 'page',
|
||||
workspace_id: 'ws-1',
|
||||
})
|
||||
|
||||
describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => {
|
||||
describe('Store Initialization → All Slices Have Correct Defaults', () => {
|
||||
it('should create a store with all five slices combined', () => {
|
||||
const store = createDataSourceStore()
|
||||
const state = store.getState()
|
||||
|
||||
// Common slice defaults
|
||||
expect(state.currentCredentialId).toBe('')
|
||||
expect(state.currentNodeIdRef.current).toBe('')
|
||||
|
||||
// Local file slice defaults
|
||||
expect(state.localFileList).toEqual([])
|
||||
expect(state.currentLocalFile).toBeUndefined()
|
||||
|
||||
// Online document slice defaults
|
||||
expect(state.documentsData).toEqual([])
|
||||
expect(state.onlineDocuments).toEqual([])
|
||||
expect(state.searchValue).toBe('')
|
||||
expect(state.selectedPagesId).toEqual(new Set())
|
||||
|
||||
// Website crawl slice defaults
|
||||
expect(state.websitePages).toEqual([])
|
||||
expect(state.step).toBe(CrawlStep.init)
|
||||
expect(state.previewIndex).toBe(-1)
|
||||
|
||||
// Online drive slice defaults
|
||||
expect(state.breadcrumbs).toEqual([])
|
||||
expect(state.prefix).toEqual([])
|
||||
expect(state.keywords).toBe('')
|
||||
expect(state.selectedFileIds).toEqual([])
|
||||
expect(state.onlineDriveFileList).toEqual([])
|
||||
expect(state.bucket).toBe('')
|
||||
expect(state.hasBucket).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-Slice Coordination: Shared Credential', () => {
|
||||
it('should set credential that is accessible from the common slice', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setCurrentCredentialId('cred-abc-123')
|
||||
|
||||
expect(store.getState().currentCredentialId).toBe('cred-abc-123')
|
||||
})
|
||||
|
||||
it('should allow credential update independently of all other slices', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setLocalFileList([createFileItem('f1')])
|
||||
store.getState().setCurrentCredentialId('cred-xyz')
|
||||
|
||||
expect(store.getState().currentCredentialId).toBe('cred-xyz')
|
||||
expect(store.getState().localFileList).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local File Workflow: Set Files → Verify List → Clear', () => {
|
||||
it('should set and retrieve local file list', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')]
|
||||
|
||||
store.getState().setLocalFileList(files)
|
||||
|
||||
expect(store.getState().localFileList).toHaveLength(3)
|
||||
expect(store.getState().localFileList[0].fileID).toBe('f1')
|
||||
expect(store.getState().localFileList[2].fileID).toBe('f3')
|
||||
})
|
||||
|
||||
it('should update preview ref when setting file list', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [createFileItem('f-preview')]
|
||||
|
||||
store.getState().setLocalFileList(files)
|
||||
|
||||
expect(store.getState().previewLocalFileRef.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should clear files by setting empty list', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setLocalFileList([createFileItem('f1')])
|
||||
expect(store.getState().localFileList).toHaveLength(1)
|
||||
|
||||
store.getState().setLocalFileList([])
|
||||
expect(store.getState().localFileList).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should set and clear current local file selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const file = { id: 'current-file', name: 'current.txt' } as FileItem['file']
|
||||
|
||||
store.getState().setCurrentLocalFile(file)
|
||||
expect(store.getState().currentLocalFile).toBeDefined()
|
||||
expect(store.getState().currentLocalFile?.id).toBe('current-file')
|
||||
|
||||
store.getState().setCurrentLocalFile(undefined)
|
||||
expect(store.getState().currentLocalFile).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => {
|
||||
it('should set documents data and online documents', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [createNotionPage('page-1'), createNotionPage('page-2')]
|
||||
|
||||
store.getState().setOnlineDocuments(pages)
|
||||
|
||||
expect(store.getState().onlineDocuments).toHaveLength(2)
|
||||
expect(store.getState().onlineDocuments[0].page_id).toBe('page-1')
|
||||
})
|
||||
|
||||
it('should update preview ref when setting online documents', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [createNotionPage('page-preview')]
|
||||
|
||||
store.getState().setOnlineDocuments(pages)
|
||||
|
||||
expect(store.getState().previewOnlineDocumentRef.current).toBeDefined()
|
||||
expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview')
|
||||
})
|
||||
|
||||
it('should track selected page IDs', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')]
|
||||
|
||||
store.getState().setOnlineDocuments(pages)
|
||||
store.getState().setSelectedPagesId(new Set(['p1', 'p3']))
|
||||
|
||||
expect(store.getState().selectedPagesId.size).toBe(2)
|
||||
expect(store.getState().selectedPagesId.has('p1')).toBe(true)
|
||||
expect(store.getState().selectedPagesId.has('p2')).toBe(false)
|
||||
expect(store.getState().selectedPagesId.has('p3')).toBe(true)
|
||||
})
|
||||
|
||||
it('should manage search value for filtering documents', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setSearchValue('meeting notes')
|
||||
|
||||
expect(store.getState().searchValue).toBe('meeting notes')
|
||||
})
|
||||
|
||||
it('should set and clear current document selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const page = createNotionPage('current-page')
|
||||
|
||||
store.getState().setCurrentDocument(page)
|
||||
expect(store.getState().currentDocument?.page_id).toBe('current-page')
|
||||
|
||||
store.getState().setCurrentDocument(undefined)
|
||||
expect(store.getState().currentDocument).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => {
|
||||
it('should set website pages and update preview ref', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [
|
||||
createCrawlResultItem('https://example.com'),
|
||||
createCrawlResultItem('https://example.com/about'),
|
||||
]
|
||||
|
||||
store.getState().setWebsitePages(pages)
|
||||
|
||||
expect(store.getState().websitePages).toHaveLength(2)
|
||||
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('should manage crawl step transitions', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
expect(store.getState().step).toBe(CrawlStep.init)
|
||||
|
||||
store.getState().setStep(CrawlStep.running)
|
||||
expect(store.getState().step).toBe(CrawlStep.running)
|
||||
|
||||
store.getState().setStep(CrawlStep.finished)
|
||||
expect(store.getState().step).toBe(CrawlStep.finished)
|
||||
})
|
||||
|
||||
it('should set crawl result with data and timing', () => {
|
||||
const store = createDataSourceStore()
|
||||
const result = {
|
||||
data: [createCrawlResultItem('https://test.com')],
|
||||
time_consuming: 3.5,
|
||||
}
|
||||
|
||||
store.getState().setCrawlResult(result)
|
||||
|
||||
expect(store.getState().crawlResult?.data).toHaveLength(1)
|
||||
expect(store.getState().crawlResult?.time_consuming).toBe(3.5)
|
||||
})
|
||||
|
||||
it('should manage preview index for page navigation', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setPreviewIndex(2)
|
||||
expect(store.getState().previewIndex).toBe(2)
|
||||
|
||||
store.getState().setPreviewIndex(-1)
|
||||
expect(store.getState().previewIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('should set and clear current website selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const page = createCrawlResultItem('https://current.com')
|
||||
|
||||
store.getState().setCurrentWebsite(page)
|
||||
expect(store.getState().currentWebsite?.source_url).toBe('https://current.com')
|
||||
|
||||
store.getState().setCurrentWebsite(undefined)
|
||||
expect(store.getState().currentWebsite).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => {
|
||||
it('should manage breadcrumb navigation', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder'])
|
||||
|
||||
expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder'])
|
||||
})
|
||||
|
||||
it('should support breadcrumb push/pop pattern', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setBreadcrumbs(['root'])
|
||||
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1'])
|
||||
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2'])
|
||||
|
||||
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2'])
|
||||
|
||||
// Pop back one level
|
||||
store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1))
|
||||
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1'])
|
||||
})
|
||||
|
||||
it('should manage file list and selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [
|
||||
createOnlineDriveFile('drive-1', 'report.pdf'),
|
||||
createOnlineDriveFile('drive-2', 'data.csv'),
|
||||
createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder),
|
||||
]
|
||||
|
||||
store.getState().setOnlineDriveFileList(files)
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(3)
|
||||
|
||||
store.getState().setSelectedFileIds(['drive-1', 'drive-2'])
|
||||
expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2'])
|
||||
})
|
||||
|
||||
it('should update preview ref when selecting files', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [
|
||||
createOnlineDriveFile('drive-a', 'file-a.txt'),
|
||||
createOnlineDriveFile('drive-b', 'file-b.txt'),
|
||||
]
|
||||
|
||||
store.getState().setOnlineDriveFileList(files)
|
||||
store.getState().setSelectedFileIds(['drive-b'])
|
||||
|
||||
expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b')
|
||||
})
|
||||
|
||||
it('should manage bucket and prefix for S3-like navigation', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setBucket('my-data-bucket')
|
||||
store.getState().setPrefix(['data', '2024'])
|
||||
store.getState().setHasBucket(true)
|
||||
|
||||
expect(store.getState().bucket).toBe('my-data-bucket')
|
||||
expect(store.getState().prefix).toEqual(['data', '2024'])
|
||||
expect(store.getState().hasBucket).toBe(true)
|
||||
})
|
||||
|
||||
it('should manage keywords for search filtering', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setKeywords('quarterly report')
|
||||
expect(store.getState().keywords).toBe('quarterly report')
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Isolation: Changes to One Slice Do Not Affect Others', () => {
|
||||
it('should keep local file state independent from online document state', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setLocalFileList([createFileItem('local-1')])
|
||||
store.getState().setOnlineDocuments([createNotionPage('notion-1')])
|
||||
|
||||
expect(store.getState().localFileList).toHaveLength(1)
|
||||
expect(store.getState().onlineDocuments).toHaveLength(1)
|
||||
|
||||
// Clearing local files should not affect online documents
|
||||
store.getState().setLocalFileList([])
|
||||
expect(store.getState().localFileList).toHaveLength(0)
|
||||
expect(store.getState().onlineDocuments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should keep website crawl state independent from online drive state', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setWebsitePages([createCrawlResultItem('https://site.com')])
|
||||
store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')])
|
||||
|
||||
expect(store.getState().websitePages).toHaveLength(1)
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(1)
|
||||
|
||||
// Clearing website pages should not affect drive files
|
||||
store.getState().setWebsitePages([])
|
||||
expect(store.getState().websitePages).toHaveLength(0)
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should create fully independent store instances', () => {
|
||||
const storeA = createDataSourceStore()
|
||||
const storeB = createDataSourceStore()
|
||||
|
||||
storeA.getState().setCurrentCredentialId('cred-A')
|
||||
storeA.getState().setLocalFileList([createFileItem('fa-1')])
|
||||
|
||||
expect(storeA.getState().currentCredentialId).toBe('cred-A')
|
||||
expect(storeB.getState().currentCredentialId).toBe('')
|
||||
expect(storeB.getState().localFileList).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => {
|
||||
it('should support a complete local file upload workflow', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
// Step 1: Set credential
|
||||
store.getState().setCurrentCredentialId('upload-cred-1')
|
||||
|
||||
// Step 2: Set file list
|
||||
const files = [createFileItem('upload-1'), createFileItem('upload-2')]
|
||||
store.getState().setLocalFileList(files)
|
||||
|
||||
// Step 3: Select current file for preview
|
||||
store.getState().setCurrentLocalFile(files[0].file)
|
||||
|
||||
// Verify all state is consistent
|
||||
expect(store.getState().currentCredentialId).toBe('upload-cred-1')
|
||||
expect(store.getState().localFileList).toHaveLength(2)
|
||||
expect(store.getState().currentLocalFile?.id).toBe('upload-1')
|
||||
expect(store.getState().previewLocalFileRef.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should support a complete website crawl workflow', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
// Step 1: Set credential
|
||||
store.getState().setCurrentCredentialId('crawl-cred-1')
|
||||
|
||||
// Step 2: Init crawl
|
||||
store.getState().setStep(CrawlStep.running)
|
||||
|
||||
// Step 3: Crawl completes with results
|
||||
const crawledPages = [
|
||||
createCrawlResultItem('https://docs.example.com/guide'),
|
||||
createCrawlResultItem('https://docs.example.com/api'),
|
||||
createCrawlResultItem('https://docs.example.com/faq'),
|
||||
]
|
||||
store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 })
|
||||
store.getState().setStep(CrawlStep.finished)
|
||||
|
||||
// Step 4: Set website pages from results
|
||||
store.getState().setWebsitePages(crawledPages)
|
||||
|
||||
// Step 5: Set preview
|
||||
store.getState().setPreviewIndex(1)
|
||||
|
||||
// Verify all state
|
||||
expect(store.getState().currentCredentialId).toBe('crawl-cred-1')
|
||||
expect(store.getState().step).toBe(CrawlStep.finished)
|
||||
expect(store.getState().websitePages).toHaveLength(3)
|
||||
expect(store.getState().crawlResult?.time_consuming).toBe(12.5)
|
||||
expect(store.getState().previewIndex).toBe(1)
|
||||
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide')
|
||||
})
|
||||
|
||||
it('should support a complete online drive navigation workflow', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
// Step 1: Set credential
|
||||
store.getState().setCurrentCredentialId('drive-cred-1')
|
||||
|
||||
// Step 2: Set bucket
|
||||
store.getState().setBucket('company-docs')
|
||||
store.getState().setHasBucket(true)
|
||||
|
||||
// Step 3: Navigate into folders
|
||||
store.getState().setBreadcrumbs(['company-docs'])
|
||||
store.getState().setPrefix(['projects'])
|
||||
const folderFiles = [
|
||||
createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder),
|
||||
createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder),
|
||||
createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file),
|
||||
]
|
||||
store.getState().setOnlineDriveFileList(folderFiles)
|
||||
|
||||
// Step 4: Navigate deeper
|
||||
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha'])
|
||||
store.getState().setPrefix([...store.getState().prefix, 'project-alpha'])
|
||||
|
||||
// Step 5: Select files
|
||||
store.getState().setOnlineDriveFileList([
|
||||
createOnlineDriveFile('doc-1', 'spec.pdf'),
|
||||
createOnlineDriveFile('doc-2', 'design.fig'),
|
||||
])
|
||||
store.getState().setSelectedFileIds(['doc-1'])
|
||||
|
||||
// Verify full state
|
||||
expect(store.getState().currentCredentialId).toBe('drive-cred-1')
|
||||
expect(store.getState().bucket).toBe('company-docs')
|
||||
expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha'])
|
||||
expect(store.getState().prefix).toEqual(['projects', 'project-alpha'])
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(2)
|
||||
expect(store.getState().selectedFileIds).toEqual(['doc-1'])
|
||||
expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Segment CRUD Flow
|
||||
*
|
||||
* Tests segment selection, search/filter, and modal state management across hooks.
|
||||
* Validates cross-hook data contracts in the completed segment module.
|
||||
*/
|
||||
|
||||
import type { SegmentDetailModel } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state'
|
||||
import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter'
|
||||
import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection'
|
||||
|
||||
const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => ({
|
||||
id,
|
||||
position: 1,
|
||||
document_id: 'doc-1',
|
||||
content,
|
||||
sign_content: content,
|
||||
answer: '',
|
||||
word_count: 50,
|
||||
tokens: 25,
|
||||
keywords: ['test'],
|
||||
index_node_id: 'idx-1',
|
||||
index_node_hash: 'hash-1',
|
||||
hit_count: 0,
|
||||
enabled: true,
|
||||
disabled_at: 0,
|
||||
disabled_by: '',
|
||||
status: 'completed',
|
||||
created_by: 'user-1',
|
||||
created_at: Date.now(),
|
||||
indexing_at: Date.now(),
|
||||
completed_at: Date.now(),
|
||||
error: null,
|
||||
stopped_at: 0,
|
||||
updated_at: Date.now(),
|
||||
attachments: [],
|
||||
} as SegmentDetailModel)
|
||||
|
||||
describe('Segment CRUD Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Search and Filter → Segment List Query', () => {
|
||||
it('should manage search input with debounce', () => {
|
||||
vi.useFakeTimers()
|
||||
const onPageChange = vi.fn()
|
||||
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputChange('keyword')
|
||||
})
|
||||
|
||||
expect(result.current.inputValue).toBe('keyword')
|
||||
expect(result.current.searchValue).toBe('')
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(result.current.searchValue).toBe('keyword')
|
||||
expect(onPageChange).toHaveBeenCalledWith(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should manage status filter state', () => {
|
||||
const onPageChange = vi.fn()
|
||||
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
|
||||
|
||||
// status value 1 maps to !!1 = true (enabled)
|
||||
act(() => {
|
||||
result.current.onChangeStatus({ value: 1, name: 'enabled' })
|
||||
})
|
||||
// onChangeStatus converts: value === 'all' ? 'all' : !!value
|
||||
expect(result.current.selectedStatus).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onClearFilter()
|
||||
})
|
||||
expect(result.current.selectedStatus).toBe('all')
|
||||
expect(result.current.inputValue).toBe('')
|
||||
})
|
||||
|
||||
it('should provide status list for filter dropdown', () => {
|
||||
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
|
||||
expect(result.current.statusList).toBeInstanceOf(Array)
|
||||
expect(result.current.statusList.length).toBe(3) // all, disabled, enabled
|
||||
})
|
||||
|
||||
it('should compute selectDefaultValue based on selectedStatus', () => {
|
||||
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
|
||||
|
||||
// Initial state: 'all'
|
||||
expect(result.current.selectDefaultValue).toBe('all')
|
||||
|
||||
// Set to enabled (true)
|
||||
act(() => {
|
||||
result.current.onChangeStatus({ value: 1, name: 'enabled' })
|
||||
})
|
||||
expect(result.current.selectDefaultValue).toBe(1)
|
||||
|
||||
// Set to disabled (false)
|
||||
act(() => {
|
||||
result.current.onChangeStatus({ value: 0, name: 'disabled' })
|
||||
})
|
||||
expect(result.current.selectDefaultValue).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Segment Selection → Batch Operations', () => {
|
||||
const segments = [
|
||||
createSegment('seg-1'),
|
||||
createSegment('seg-2'),
|
||||
createSegment('seg-3'),
|
||||
]
|
||||
|
||||
it('should manage individual segment selection', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toContain('seg-1')
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-2')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toContain('seg-1')
|
||||
expect(result.current.selectedSegmentIds).toContain('seg-2')
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should toggle selection on repeated click', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toContain('seg-1')
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).not.toContain('seg-1')
|
||||
})
|
||||
|
||||
it('should support select all toggle', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectedAll()
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(3)
|
||||
expect(result.current.isAllSelected).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectedAll()
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(0)
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect partial selection via isSomeSelected', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
})
|
||||
|
||||
// After selecting one of three, isSomeSelected should be true
|
||||
expect(result.current.selectedSegmentIds).toEqual(['seg-1'])
|
||||
expect(result.current.isSomeSelected).toBe(true)
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should clear selection via onCancelBatchOperation', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
result.current.onSelected('seg-2')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(2)
|
||||
|
||||
act(() => {
|
||||
result.current.onCancelBatchOperation()
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal State Management', () => {
|
||||
const onNewSegmentModalChange = vi.fn()
|
||||
|
||||
it('should open segment detail modal on card click', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
const segment = createSegment('seg-detail-1', 'Detail content')
|
||||
act(() => {
|
||||
result.current.onClickCard(segment)
|
||||
})
|
||||
expect(result.current.currSegment.showModal).toBe(true)
|
||||
expect(result.current.currSegment.segInfo).toBeDefined()
|
||||
expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1')
|
||||
})
|
||||
|
||||
it('should close segment detail modal', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
const segment = createSegment('seg-1')
|
||||
act(() => {
|
||||
result.current.onClickCard(segment)
|
||||
})
|
||||
expect(result.current.currSegment.showModal).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onCloseSegmentDetail()
|
||||
})
|
||||
expect(result.current.currSegment.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should manage full screen toggle', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
expect(result.current.fullScreen).toBe(false)
|
||||
act(() => {
|
||||
result.current.toggleFullScreen()
|
||||
})
|
||||
expect(result.current.fullScreen).toBe(true)
|
||||
act(() => {
|
||||
result.current.toggleFullScreen()
|
||||
})
|
||||
expect(result.current.fullScreen).toBe(false)
|
||||
})
|
||||
|
||||
it('should manage collapsed state', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
expect(result.current.isCollapsed).toBe(true)
|
||||
act(() => {
|
||||
result.current.toggleCollapsed()
|
||||
})
|
||||
expect(result.current.isCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('should manage new child segment modal', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
expect(result.current.showNewChildSegmentModal).toBe(false)
|
||||
act(() => {
|
||||
result.current.handleAddNewChildChunk('chunk-parent-1')
|
||||
})
|
||||
expect(result.current.showNewChildSegmentModal).toBe(true)
|
||||
expect(result.current.currChunkId).toBe('chunk-parent-1')
|
||||
|
||||
act(() => {
|
||||
result.current.onCloseNewChildChunkModal()
|
||||
})
|
||||
expect(result.current.showNewChildSegmentModal).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-Hook Data Flow: Search → Selection → Modal', () => {
|
||||
it('should maintain independent state across all three hooks', () => {
|
||||
const segments = [createSegment('seg-1'), createSegment('seg-2')]
|
||||
|
||||
const { result: filterResult } = renderHook(() =>
|
||||
useSearchFilter({ onPageChange: vi.fn() }),
|
||||
)
|
||||
const { result: selectionResult } = renderHook(() =>
|
||||
useSegmentSelection(segments),
|
||||
)
|
||||
const { result: modalResult } = renderHook(() =>
|
||||
useModalState({ onNewSegmentModalChange: vi.fn() }),
|
||||
)
|
||||
|
||||
// Set search filter to enabled
|
||||
act(() => {
|
||||
filterResult.current.onChangeStatus({ value: 1, name: 'enabled' })
|
||||
})
|
||||
|
||||
// Select a segment
|
||||
act(() => {
|
||||
selectionResult.current.onSelected('seg-1')
|
||||
})
|
||||
|
||||
// Open detail modal
|
||||
act(() => {
|
||||
modalResult.current.onClickCard(segments[0])
|
||||
})
|
||||
|
||||
// All states should be independent
|
||||
expect(filterResult.current.selectedStatus).toBe(true) // !!1
|
||||
expect(selectionResult.current.selectedSegmentIds).toContain('seg-1')
|
||||
expect(modalResult.current.currSegment.showModal).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,192 +0,0 @@
|
||||
/**
|
||||
* Integration test: API Key management flow
|
||||
*
|
||||
* Tests the cross-component interaction:
|
||||
* ApiServer → SecretKeyButton → SecretKeyModal
|
||||
*
|
||||
* Renders real ApiServer, SecretKeyButton, and SecretKeyModal together
|
||||
* with only service-layer mocks. Deep modal interactions (create/delete)
|
||||
* are covered by unit tests in secret-key-modal.spec.tsx.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ApiServer from '@/app/components/develop/ApiServer'
|
||||
|
||||
// ---------- fake timers (HeadlessUI Dialog transitions) ----------
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushUI() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- mocks ----------
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((val: number) => `Time:${val}`),
|
||||
formatDate: vi.fn((val: string) => `Date:${val}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
const mockApiKeys = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsLoading = vi.fn().mockReturnValue(false)
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppApiKeys: () => ({
|
||||
data: mockApiKeys(),
|
||||
isLoading: mockIsLoading(),
|
||||
}),
|
||||
useInvalidateAppApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
|
||||
useInvalidateDatasetApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('API Key management flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApiKeys.mockReturnValue({ data: [] })
|
||||
mockIsLoading.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('ApiServer renders URL, status badge, and API Key button', () => {
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking API Key button opens SecretKeyModal with real modal content', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
// Click API Key button (rendered by SecretKeyButton)
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// SecretKeyModal should render with real HeadlessUI Dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('modal shows loading state when API keys are being fetched', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
mockIsLoading.mockReturnValue(true)
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Loading indicator should be present
|
||||
expect(document.body.querySelector('[role="status"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('modal can be closed by clicking X icon', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
// Open modal
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click X icon to close
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await user.click(closeIcon!)
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders correctly with different API URLs', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
const { rerender } = render(
|
||||
<ApiServer apiBaseUrl="http://localhost:5001/v1" appId="app-dev" />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument()
|
||||
|
||||
// Open modal and verify it works with the same appId
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close modal, update URL and re-verify
|
||||
const xIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
await act(async () => {
|
||||
await user.click(xIcon!)
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
rerender(
|
||||
<ApiServer apiBaseUrl="https://api.production.com/v1" appId="app-prod" />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,237 +0,0 @@
|
||||
/**
|
||||
* Integration test: DevelopMain page flow
|
||||
*
|
||||
* Tests the full page lifecycle:
|
||||
* Loading state → App loaded → Header (ApiServer) + Content (Doc) rendered
|
||||
*
|
||||
* Uses real DevelopMain, ApiServer, and Doc components with minimal mocks.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DevelopMain from '@/app/components/develop'
|
||||
import { AppModeEnum, Theme } from '@/types/app'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushUI() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
let storeAppDetail: unknown
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
return selector({ appDetail: storeAppDetail })
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: Theme.light }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/i18n-config/language')>()
|
||||
return {
|
||||
...actual,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((val: number) => `Time:${val}`),
|
||||
formatDate: vi.fn((val: string) => `Date:${val}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }),
|
||||
useInvalidateAppApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
|
||||
useInvalidateDatasetApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('DevelopMain page flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storeAppDetail = undefined
|
||||
})
|
||||
|
||||
it('should show loading indicator when appDetail is not available', () => {
|
||||
storeAppDetail = undefined
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
// No content should be visible
|
||||
expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render full page when appDetail is loaded', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// ApiServer section should be visible
|
||||
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
|
||||
// Loading should NOT be visible
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Doc component with correct app mode template', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Chat App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
const { container } = render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Doc renders an article element with prose classes
|
||||
const article = container.querySelector('article')
|
||||
expect(article).toBeInTheDocument()
|
||||
expect(article?.className).toContain('prose')
|
||||
})
|
||||
|
||||
it('should transition from loading to content when appDetail becomes available', () => {
|
||||
// Start with no data
|
||||
storeAppDetail = undefined
|
||||
const { rerender } = render(<DevelopMain appId="app-1" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
// Simulate store update
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'My App',
|
||||
api_base_url: 'https://api.example.com/v1',
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
}
|
||||
rerender(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Content should now be visible
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open API key modal from the page', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Click API Key button in the header
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// SecretKeyModal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render correctly for different app modes', () => {
|
||||
const modes = [
|
||||
AppModeEnum.CHAT,
|
||||
AppModeEnum.COMPLETION,
|
||||
AppModeEnum.ADVANCED_CHAT,
|
||||
AppModeEnum.WORKFLOW,
|
||||
]
|
||||
|
||||
for (const mode of modes) {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: `${mode} App`,
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode,
|
||||
}
|
||||
|
||||
const { container, unmount } = render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// ApiServer should always be present
|
||||
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
|
||||
|
||||
// Doc should render an article
|
||||
expect(container.querySelector('article')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('should have correct page layout structure', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Main container: flex column with full height
|
||||
const mainDiv = screen.getByTestId('develop-main')
|
||||
expect(mainDiv.className).toContain('flex')
|
||||
expect(mainDiv.className).toContain('flex-col')
|
||||
expect(mainDiv.className).toContain('h-full')
|
||||
|
||||
// Header section with border
|
||||
const header = mainDiv.querySelector('.border-b')
|
||||
expect(header).toBeInTheDocument()
|
||||
|
||||
// Content section with overflow scroll
|
||||
const content = mainDiv.querySelector('.overflow-auto')
|
||||
expect(content).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,260 +0,0 @@
|
||||
/**
|
||||
* Integration test: Installed App Flow
|
||||
*
|
||||
* Tests the end-to-end user flow of installed apps: sidebar navigation,
|
||||
* mode-based routing (Chat / Completion / Workflow), and lifecycle
|
||||
* operations (pin/unpin, delete).
|
||||
*/
|
||||
import type { Mock } from 'vitest'
|
||||
import type { InstalledApp as InstalledAppModel } from '@/models/explore'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import InstalledApp from '@/app/components/explore/installed-app'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(),
|
||||
createContext: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledAppAccessModeByAppId: vi.fn(),
|
||||
useGetInstalledAppParams: vi.fn(),
|
||||
useGetInstalledAppMeta: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation', () => ({
|
||||
default: ({ isWorkflow }: { isWorkflow?: boolean }) => (
|
||||
<div data-testid="text-generation-app">
|
||||
Text Generation
|
||||
{isWorkflow && ' (Workflow)'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat-with-history', () => ({
|
||||
default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => (
|
||||
<div data-testid="chat-with-history">
|
||||
Chat -
|
||||
{' '}
|
||||
{installedAppInfo?.app.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Installed App Flow', () => {
|
||||
const mockUpdateAppInfo = vi.fn()
|
||||
const mockUpdateWebAppAccessMode = vi.fn()
|
||||
const mockUpdateAppParams = vi.fn()
|
||||
const mockUpdateWebAppMeta = vi.fn()
|
||||
const mockUpdateUserCanAccessApp = vi.fn()
|
||||
|
||||
const createInstalledApp = (mode: AppModeEnum = AppModeEnum.CHAT): InstalledAppModel => ({
|
||||
id: 'installed-app-1',
|
||||
app: {
|
||||
id: 'real-app-id',
|
||||
name: 'Integration Test App',
|
||||
mode,
|
||||
icon_type: 'emoji',
|
||||
icon: '🧪',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: '',
|
||||
description: 'Test app for integration',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
uninstallable: true,
|
||||
is_pinned: false,
|
||||
})
|
||||
|
||||
const mockAppParams = {
|
||||
user_input_form: [],
|
||||
file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
|
||||
system_parameters: {},
|
||||
}
|
||||
|
||||
type MockOverrides = {
|
||||
context?: { installedApps?: InstalledAppModel[], isFetchingInstalledApps?: boolean }
|
||||
accessMode?: { isFetching?: boolean, data?: unknown, error?: unknown }
|
||||
params?: { isFetching?: boolean, data?: unknown, error?: unknown }
|
||||
meta?: { isFetching?: boolean, data?: unknown, error?: unknown }
|
||||
userAccess?: { data?: unknown, error?: unknown }
|
||||
}
|
||||
|
||||
const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => {
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: app ? [app] : [],
|
||||
isFetchingInstalledApps: false,
|
||||
...overrides.context,
|
||||
})
|
||||
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => {
|
||||
return selector({
|
||||
updateAppInfo: mockUpdateAppInfo,
|
||||
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
|
||||
updateAppParams: mockUpdateAppParams,
|
||||
updateWebAppMeta: mockUpdateWebAppMeta,
|
||||
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
|
||||
})
|
||||
})
|
||||
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: { accessMode: AccessMode.PUBLIC },
|
||||
error: null,
|
||||
...overrides.accessMode,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockAppParams,
|
||||
error: null,
|
||||
...overrides.params,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: { tool_icons: {} },
|
||||
error: null,
|
||||
...overrides.meta,
|
||||
})
|
||||
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: true },
|
||||
error: null,
|
||||
...overrides.userAccess,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Mode-Based Routing', () => {
|
||||
it.each([
|
||||
[AppModeEnum.CHAT, 'chat-with-history'],
|
||||
[AppModeEnum.ADVANCED_CHAT, 'chat-with-history'],
|
||||
[AppModeEnum.AGENT_CHAT, 'chat-with-history'],
|
||||
])('should render ChatWithHistory for %s mode', (mode, testId) => {
|
||||
const app = createInstalledApp(mode)
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Integration Test App/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp for COMPLETION mode', () => {
|
||||
const app = createInstalledApp(AppModeEnum.COMPLETION)
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
|
||||
expect(screen.getByText('Text Generation')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
|
||||
const app = createInstalledApp(AppModeEnum.WORKFLOW)
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Loading Flow', () => {
|
||||
it('should show loading spinner when params are being fetched', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app, { params: { isFetching: true, data: null } })
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content when all data is available', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling Flow', () => {
|
||||
it('should show error state when API fails', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app, { params: { data: null, error: new Error('Network error') } })
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByText(/Network error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 404 when app is not found', () => {
|
||||
setupDefaultMocks(undefined, {
|
||||
accessMode: { data: null },
|
||||
params: { data: null },
|
||||
meta: { data: null },
|
||||
userAccess: { data: null },
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent" />)
|
||||
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 403 when user has no permission', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app, { userAccess: { data: { result: false } } })
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Synchronization', () => {
|
||||
it('should update all stores when app data is loaded', async () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
app_id: 'installed-app-1',
|
||||
site: expect.objectContaining({
|
||||
title: 'Integration Test App',
|
||||
icon: '🧪',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
|
||||
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith({ tool_icons: {} })
|
||||
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
|
||||
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,225 +0,0 @@
|
||||
import type { IExplore } from '@/context/explore-context'
|
||||
/**
|
||||
* Integration test: Sidebar Lifecycle Flow
|
||||
*
|
||||
* Tests the sidebar interactions for installed apps lifecycle:
|
||||
* navigation, pin/unpin ordering, delete confirmation, and
|
||||
* fold/unfold behavior.
|
||||
*/
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import SideBar from '@/app/components/explore/sidebar'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockMediaType: string = MediaType.pc
|
||||
const mockSegments = ['apps']
|
||||
const mockPush = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUninstall = vi.fn()
|
||||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegments: () => mockSegments,
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => mockMediaType,
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isFetching: false,
|
||||
data: { installed_apps: mockInstalledApps },
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: mockUninstall,
|
||||
}),
|
||||
useUpdateAppPinStatus: () => ({
|
||||
mutateAsync: mockUpdatePinStatus,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
uninstallable: overrides.uninstallable ?? false,
|
||||
is_pinned: overrides.is_pinned ?? false,
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-basic-id',
|
||||
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
|
||||
icon_type: overrides.app?.icon_type ?? 'emoji',
|
||||
icon: overrides.app?.icon ?? '🤖',
|
||||
icon_background: overrides.app?.icon_background ?? '#fff',
|
||||
icon_url: overrides.app?.icon_url ?? '',
|
||||
name: overrides.app?.name ?? 'App One',
|
||||
description: overrides.app?.description ?? 'desc',
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
},
|
||||
})
|
||||
|
||||
const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps,
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
})
|
||||
|
||||
const renderSidebar = (installedApps: InstalledApp[] = []) => {
|
||||
return render(
|
||||
<ExploreContext.Provider value={createContextValue(installedApps)}>
|
||||
<SideBar controlUpdateInstalledApps={0} />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar Lifecycle Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMediaType = MediaType.pc
|
||||
mockInstalledApps = []
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
})
|
||||
|
||||
describe('Pin / Unpin / Delete Flow', () => {
|
||||
it('should complete pin → unpin cycle for an app', async () => {
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
|
||||
// Step 1: Start with an unpinned app and pin it
|
||||
const unpinnedApp = createInstalledApp({ is_pinned: false })
|
||||
mockInstalledApps = [unpinnedApp]
|
||||
const { unmount } = renderSidebar(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true })
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
|
||||
// Step 2: Simulate refetch returning pinned state, then unpin
|
||||
unmount()
|
||||
vi.clearAllMocks()
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
|
||||
const pinnedApp = createInstalledApp({ is_pinned: true })
|
||||
mockInstalledApps = [pinnedApp]
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false })
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should complete the delete flow with confirmation', async () => {
|
||||
const app = createInstalledApp()
|
||||
mockInstalledApps = [app]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
// Step 1: Open operation menu and click delete
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Step 2: Confirm dialog appears
|
||||
expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument()
|
||||
|
||||
// Step 3: Confirm deletion
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
// Step 4: Uninstall API called and success toast shown
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).toHaveBeenCalledWith('app-1')
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
message: 'common.api.remove',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should cancel deletion when user clicks cancel', async () => {
|
||||
const app = createInstalledApp()
|
||||
mockInstalledApps = [app]
|
||||
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
// Open delete flow
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Cancel the deletion
|
||||
fireEvent.click(await screen.findByText('common.operation.cancel'))
|
||||
|
||||
// Uninstall should not be called
|
||||
expect(mockUninstall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multi-App Ordering', () => {
|
||||
it('should display pinned apps before unpinned apps with divider', () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({ id: 'pinned-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }),
|
||||
createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }),
|
||||
]
|
||||
|
||||
const { container } = renderSidebar(mockInstalledApps)
|
||||
|
||||
// Both apps are rendered
|
||||
const pinnedApp = screen.getByText('Pinned App')
|
||||
const regularApp = screen.getByText('Regular App')
|
||||
expect(pinnedApp).toBeInTheDocument()
|
||||
expect(regularApp).toBeInTheDocument()
|
||||
|
||||
// Pinned app appears before unpinned app in the DOM
|
||||
const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')!
|
||||
const regularItem = regularApp.closest('[class*="rounded-lg"]')!
|
||||
expect(pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
|
||||
// Divider is rendered between pinned and unpinned sections
|
||||
const divider = container.querySelector('[class*="bg-divider-regular"]')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show NoApps component when no apps are installed on desktop', () => {
|
||||
mockMediaType = MediaType.pc
|
||||
renderSidebar([])
|
||||
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide NoApps on mobile', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderSidebar([])
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -49,14 +49,14 @@ describe('Slash Command Dual-Mode System', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => {
|
||||
;(slashCommandRegistry as any).findCommand = vi.fn((name: string) => {
|
||||
if (name === 'docs')
|
||||
return mockDirectCommand
|
||||
if (name === 'theme')
|
||||
return mockSubmenuCommand
|
||||
return undefined
|
||||
return null
|
||||
})
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
;(slashCommandRegistry as any).getAllCommands = vi.fn(() => [
|
||||
mockDirectCommand,
|
||||
mockSubmenuCommand,
|
||||
])
|
||||
@ -147,7 +147,7 @@ describe('Slash Command Dual-Mode System', () => {
|
||||
unregister: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mocked(slashCommandRegistry.findCommand).mockReturnValue(commandWithoutMode)
|
||||
;(slashCommandRegistry as any).findCommand = vi.fn(() => commandWithoutMode)
|
||||
|
||||
const handler = slashCommandRegistry.findCommand('test')
|
||||
// Default behavior should be submenu when mode is not specified
|
||||
|
||||
@ -1,271 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Plugin Authentication Flow
|
||||
*
|
||||
* Tests the integration between PluginAuth, usePluginAuth hook,
|
||||
* Authorize/Authorized components, and credential management.
|
||||
* Verifies the complete auth flow from checking authorization status
|
||||
* to rendering the correct UI state.
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AuthCategory, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'plugin.auth.setUpTip': 'Set up your credentials',
|
||||
'plugin.auth.authorized': 'Authorized',
|
||||
'plugin.auth.apiKey': 'API Key',
|
||||
'plugin.auth.oauth': 'OAuth',
|
||||
}
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
const mockUsePluginAuth = vi.fn()
|
||||
vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({
|
||||
usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({
|
||||
default: ({ pluginPayload, canOAuth, canApiKey }: {
|
||||
pluginPayload: { provider: string }
|
||||
canOAuth: boolean
|
||||
canApiKey: boolean
|
||||
}) => (
|
||||
<div data-testid="authorize-component">
|
||||
<span data-testid="auth-provider">{pluginPayload.provider}</span>
|
||||
{canOAuth && <span data-testid="auth-oauth">OAuth available</span>}
|
||||
{canApiKey && <span data-testid="auth-apikey">API Key available</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({
|
||||
default: ({ pluginPayload, credentials }: {
|
||||
pluginPayload: { provider: string }
|
||||
credentials: Array<{ id: string, name: string }>
|
||||
}) => (
|
||||
<div data-testid="authorized-component">
|
||||
<span data-testid="auth-provider">{pluginPayload.provider}</span>
|
||||
<span data-testid="auth-credential-count">
|
||||
{credentials.length}
|
||||
{' '}
|
||||
credentials
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: PluginAuth } = await import('@/app/components/plugins/plugin-auth/plugin-auth')
|
||||
|
||||
describe('Plugin Authentication Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const basePayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
describe('Unauthorized State', () => {
|
||||
it('renders Authorize component when not authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('authorize-component')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows OAuth option when plugin supports it', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: true,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies className to wrapper when not authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorized State', () => {
|
||||
it('renders Authorized component when authorized and no children', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [
|
||||
{ id: 'cred-1', name: 'My API Key', is_default: true },
|
||||
],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('authorized-component')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('1 credentials')
|
||||
})
|
||||
|
||||
it('renders children instead of Authorized when authorized and children provided', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<PluginAuth pluginPayload={basePayload}>
|
||||
<div data-testid="custom-children">Custom authorized view</div>
|
||||
</PluginAuth>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('custom-children')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not apply className when authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).not.toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth Category Integration', () => {
|
||||
it('passes correct provider to usePluginAuth for tool category', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const toolPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'google-search-provider',
|
||||
}
|
||||
|
||||
render(<PluginAuth pluginPayload={toolPayload} />)
|
||||
|
||||
expect(mockUsePluginAuth).toHaveBeenCalledWith(toolPayload, true)
|
||||
expect(screen.getByTestId('auth-provider')).toHaveTextContent('google-search-provider')
|
||||
})
|
||||
|
||||
it('passes correct provider to usePluginAuth for datasource category', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: true,
|
||||
canApiKey: false,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const dsPayload = {
|
||||
category: AuthCategory.datasource,
|
||||
provider: 'notion-datasource',
|
||||
}
|
||||
|
||||
render(<PluginAuth pluginPayload={dsPayload} />)
|
||||
|
||||
expect(mockUsePluginAuth).toHaveBeenCalledWith(dsPayload, true)
|
||||
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('auth-apikey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Credentials', () => {
|
||||
it('shows credential count when multiple credentials exist', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: true,
|
||||
canApiKey: true,
|
||||
credentials: [
|
||||
{ id: 'cred-1', name: 'API Key 1', is_default: true },
|
||||
{ id: 'cred-2', name: 'API Key 2', is_default: false },
|
||||
{ id: 'cred-3', name: 'OAuth Token', is_default: false, credential_type: CredentialTypeEnum.OAUTH2 },
|
||||
],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('3 credentials')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,224 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Plugin Card Rendering Pipeline
|
||||
*
|
||||
* Tests the integration between Card, Icon, Title, Description,
|
||||
* OrgInfo, CornerMark, and CardMoreInfo components. Verifies that
|
||||
* plugin data flows correctly through the card rendering pipeline.
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
renderI18nObject: (obj: Record<string, string>, locale: string) => obj[locale] || obj.en_US || '',
|
||||
}))
|
||||
|
||||
vi.mock('@/types/app', () => ({
|
||||
Theme: { dark: 'dark', light: 'light' },
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useCategories: () => ({
|
||||
categoriesMap: {
|
||||
tool: { label: 'Tool' },
|
||||
model: { label: 'Model' },
|
||||
extension: { label: 'Extension' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/partner', () => ({
|
||||
default: () => <span data-testid="partner-badge">Partner</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
|
||||
default: () => <span data-testid="verified-badge">Verified</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src, installed, installFailed }: { src: string | object, installed?: boolean, installFailed?: boolean }) => (
|
||||
<div data-testid="card-icon" data-installed={installed} data-install-failed={installFailed}>
|
||||
{typeof src === 'string' ? src : 'emoji-icon'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({
|
||||
default: ({ text }: { text: string }) => (
|
||||
<div data-testid="corner-mark">{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
default: ({ text, descriptionLineRows }: { text: string, descriptionLineRows?: number }) => (
|
||||
<div data-testid="description" data-rows={descriptionLineRows}>{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
|
||||
<div data-testid="org-info">
|
||||
{orgName}
|
||||
/
|
||||
{packageName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/placeholder', () => ({
|
||||
default: ({ text }: { text: string }) => (
|
||||
<div data-testid="placeholder">{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/title', () => ({
|
||||
default: ({ title }: { title: string }) => (
|
||||
<div data-testid="title">{title}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: Card } = await import('@/app/components/plugins/card/index')
|
||||
type CardPayload = Parameters<typeof Card>[0]['payload']
|
||||
|
||||
describe('Plugin Card Rendering Integration', () => {
|
||||
beforeEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const makePayload = (overrides = {}) => ({
|
||||
category: 'tool',
|
||||
type: 'plugin',
|
||||
name: 'google-search',
|
||||
org: 'langgenius',
|
||||
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
|
||||
brief: { en_US: 'Search the web using Google', zh_Hans: '使用Google搜索网页' },
|
||||
icon: 'https://example.com/icon.png',
|
||||
verified: true,
|
||||
badges: [] as string[],
|
||||
...overrides,
|
||||
}) as CardPayload
|
||||
|
||||
it('renders a complete plugin card with all subcomponents', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Google Search')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search')
|
||||
expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google')
|
||||
})
|
||||
|
||||
it('shows corner mark with category label when not hidden', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('corner-mark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides corner mark when hideCornerMark is true', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} hideCornerMark />)
|
||||
|
||||
expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows installed status on icon', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} installed />)
|
||||
|
||||
const icon = screen.getByTestId('card-icon')
|
||||
expect(icon).toHaveAttribute('data-installed', 'true')
|
||||
})
|
||||
|
||||
it('shows install failed status on icon', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} installFailed />)
|
||||
|
||||
const icon = screen.getByTestId('card-icon')
|
||||
expect(icon).toHaveAttribute('data-install-failed', 'true')
|
||||
})
|
||||
|
||||
it('renders verified badge when plugin is verified', () => {
|
||||
const payload = makePayload({ verified: true })
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders partner badge when plugin has partner badge', () => {
|
||||
const payload = makePayload({ badges: ['partner'] })
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders footer content when provided', () => {
|
||||
const payload = makePayload()
|
||||
render(
|
||||
<Card
|
||||
payload={payload}
|
||||
footer={<div data-testid="custom-footer">Custom footer</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders titleLeft content when provided', () => {
|
||||
const payload = makePayload()
|
||||
render(
|
||||
<Card
|
||||
payload={payload}
|
||||
titleLeft={<span data-testid="title-left-content">New</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('title-left-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses dark icon when theme is dark and icon_dark is provided', () => {
|
||||
vi.doMock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'dark' }),
|
||||
}))
|
||||
|
||||
const payload = makePayload({
|
||||
icon: 'https://example.com/icon-light.png',
|
||||
icon_dark: 'https://example.com/icon-dark.png',
|
||||
})
|
||||
|
||||
render(<Card payload={payload} />)
|
||||
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading placeholder when isLoading is true', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} isLoading loadingFileName="uploading.difypkg" />)
|
||||
|
||||
expect(screen.getByTestId('placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders description with custom line rows', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} descriptionLineRows={3} />)
|
||||
|
||||
const description = screen.getByTestId('description')
|
||||
expect(description).toHaveAttribute('data-rows', '3')
|
||||
})
|
||||
})
|
||||
@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Plugin Data Utilities
|
||||
*
|
||||
* Tests the integration between plugin utility functions, including
|
||||
* tag/category validation, form schema transformation, and
|
||||
* credential data processing. Verifies that these utilities work
|
||||
* correctly together in processing plugin metadata.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { transformFormSchemasSecretInput } from '@/app/components/plugins/plugin-auth/utils'
|
||||
import { getValidCategoryKeys, getValidTagKeys } from '@/app/components/plugins/utils'
|
||||
|
||||
type TagInput = Parameters<typeof getValidTagKeys>[0]
|
||||
|
||||
describe('Plugin Data Utilities Integration', () => {
|
||||
describe('Tag and Category Validation Pipeline', () => {
|
||||
it('validates tags and categories in a metadata processing flow', () => {
|
||||
const pluginMetadata = {
|
||||
tags: ['search', 'productivity', 'invalid-tag', 'media-generate'],
|
||||
category: 'tool',
|
||||
}
|
||||
|
||||
const validTags = getValidTagKeys(pluginMetadata.tags as TagInput)
|
||||
expect(validTags.length).toBeGreaterThan(0)
|
||||
expect(validTags.length).toBeLessThanOrEqual(pluginMetadata.tags.length)
|
||||
|
||||
const validCategory = getValidCategoryKeys(pluginMetadata.category)
|
||||
expect(validCategory).toBeDefined()
|
||||
})
|
||||
|
||||
it('handles completely invalid metadata gracefully', () => {
|
||||
const invalidMetadata = {
|
||||
tags: ['nonexistent-1', 'nonexistent-2'],
|
||||
category: 'nonexistent-category',
|
||||
}
|
||||
|
||||
const validTags = getValidTagKeys(invalidMetadata.tags as TagInput)
|
||||
expect(validTags).toHaveLength(0)
|
||||
|
||||
const validCategory = getValidCategoryKeys(invalidMetadata.category)
|
||||
expect(validCategory).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles undefined and empty inputs', () => {
|
||||
expect(getValidTagKeys([] as TagInput)).toHaveLength(0)
|
||||
expect(getValidCategoryKeys(undefined)).toBeUndefined()
|
||||
expect(getValidCategoryKeys('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Credential Secret Masking Pipeline', () => {
|
||||
it('masks secrets when displaying credential form data', () => {
|
||||
const credentialValues = {
|
||||
api_key: 'sk-abc123456789',
|
||||
api_endpoint: 'https://api.example.com',
|
||||
secret_token: 'secret-token-value',
|
||||
description: 'My credential set',
|
||||
}
|
||||
|
||||
const secretFields = ['api_key', 'secret_token']
|
||||
|
||||
const displayValues = transformFormSchemasSecretInput(secretFields, credentialValues)
|
||||
|
||||
expect(displayValues.api_key).toBe('[__HIDDEN__]')
|
||||
expect(displayValues.secret_token).toBe('[__HIDDEN__]')
|
||||
expect(displayValues.api_endpoint).toBe('https://api.example.com')
|
||||
expect(displayValues.description).toBe('My credential set')
|
||||
})
|
||||
|
||||
it('preserves original values when no secret fields', () => {
|
||||
const values = {
|
||||
name: 'test',
|
||||
endpoint: 'https://api.example.com',
|
||||
}
|
||||
|
||||
const result = transformFormSchemasSecretInput([], values)
|
||||
expect(result).toEqual(values)
|
||||
})
|
||||
|
||||
it('handles falsy secret values without masking', () => {
|
||||
const values = {
|
||||
api_key: '',
|
||||
secret: null as unknown as string,
|
||||
other: 'visible',
|
||||
}
|
||||
|
||||
const result = transformFormSchemasSecretInput(['api_key', 'secret'], values)
|
||||
expect(result.api_key).toBe('')
|
||||
expect(result.secret).toBeNull()
|
||||
expect(result.other).toBe('visible')
|
||||
})
|
||||
|
||||
it('does not mutate the original values object', () => {
|
||||
const original = {
|
||||
api_key: 'my-secret-key',
|
||||
name: 'test',
|
||||
}
|
||||
const originalCopy = { ...original }
|
||||
|
||||
transformFormSchemasSecretInput(['api_key'], original)
|
||||
|
||||
expect(original).toEqual(originalCopy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Combined Plugin Metadata Validation', () => {
|
||||
it('processes a complete plugin entry with tags and credentials', () => {
|
||||
const pluginEntry = {
|
||||
name: 'test-plugin',
|
||||
category: 'tool',
|
||||
tags: ['search', 'invalid-tag'],
|
||||
credentials: {
|
||||
api_key: 'sk-test-key-123',
|
||||
base_url: 'https://api.test.com',
|
||||
},
|
||||
secretFields: ['api_key'],
|
||||
}
|
||||
|
||||
const validCategory = getValidCategoryKeys(pluginEntry.category)
|
||||
expect(validCategory).toBe('tool')
|
||||
|
||||
const validTags = getValidTagKeys(pluginEntry.tags as TagInput)
|
||||
expect(validTags).toContain('search')
|
||||
|
||||
const displayCredentials = transformFormSchemasSecretInput(
|
||||
pluginEntry.secretFields,
|
||||
pluginEntry.credentials,
|
||||
)
|
||||
expect(displayCredentials.api_key).toBe('[__HIDDEN__]')
|
||||
expect(displayCredentials.base_url).toBe('https://api.test.com')
|
||||
|
||||
expect(pluginEntry.credentials.api_key).toBe('sk-test-key-123')
|
||||
})
|
||||
|
||||
it('handles multiple plugins in batch processing', () => {
|
||||
const plugins = [
|
||||
{ tags: ['search', 'productivity'], category: 'tool' },
|
||||
{ tags: ['image', 'design'], category: 'model' },
|
||||
{ tags: ['invalid'], category: 'extension' },
|
||||
]
|
||||
|
||||
const results = plugins.map(p => ({
|
||||
validTags: getValidTagKeys(p.tags as TagInput),
|
||||
validCategory: getValidCategoryKeys(p.category),
|
||||
}))
|
||||
|
||||
expect(results[0].validTags.length).toBeGreaterThan(0)
|
||||
expect(results[0].validCategory).toBe('tool')
|
||||
|
||||
expect(results[1].validTags).toContain('image')
|
||||
expect(results[1].validTags).toContain('design')
|
||||
expect(results[1].validCategory).toBe('model')
|
||||
|
||||
expect(results[2].validTags).toHaveLength(0)
|
||||
expect(results[2].validCategory).toBe('extension')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Plugin Installation Flow
|
||||
*
|
||||
* Tests the integration between GitHub release fetching, version comparison,
|
||||
* upload handling, and task status polling. Verifies the complete plugin
|
||||
* installation pipeline from source discovery to completion.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
GITHUB_ACCESS_TOKEN: '',
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (...args: unknown[]) => mockToastNotify(...args) },
|
||||
}))
|
||||
|
||||
const mockUploadGitHub = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
|
||||
checkTaskStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/semver', () => ({
|
||||
compareVersion: (a: string, b: string) => {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const [aMajor, aMinor = 0, aPatch = 0] = parse(a)
|
||||
const [bMajor, bMinor = 0, bPatch = 0] = parse(b)
|
||||
if (aMajor !== bMajor)
|
||||
return aMajor > bMajor ? 1 : -1
|
||||
if (aMinor !== bMinor)
|
||||
return aMinor > bMinor ? 1 : -1
|
||||
if (aPatch !== bPatch)
|
||||
return aPatch > bPatch ? 1 : -1
|
||||
return 0
|
||||
},
|
||||
getLatestVersion: (versions: string[]) => {
|
||||
return versions.sort((a, b) => {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const [aMaj, aMin = 0, aPat = 0] = parse(a)
|
||||
const [bMaj, bMin = 0, bPat = 0] = parse(b)
|
||||
if (aMaj !== bMaj)
|
||||
return bMaj - aMaj
|
||||
if (aMin !== bMin)
|
||||
return bMin - aMin
|
||||
return bPat - aPat
|
||||
})[0]
|
||||
},
|
||||
}))
|
||||
|
||||
const { useGitHubReleases, useGitHubUpload } = await import(
|
||||
'@/app/components/plugins/install-plugin/hooks',
|
||||
)
|
||||
|
||||
describe('Plugin Installation Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
globalThis.fetch = vi.fn()
|
||||
})
|
||||
|
||||
describe('GitHub Release Discovery → Version Check → Upload Pipeline', () => {
|
||||
it('fetches releases, checks for updates, and uploads the new version', async () => {
|
||||
const mockReleases = [
|
||||
{
|
||||
tag_name: 'v2.0.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v2.difypkg', name: 'plugin-v2.difypkg' }],
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.5.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v1.5.difypkg', name: 'plugin-v1.5.difypkg' }],
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
|
||||
},
|
||||
]
|
||||
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockReleases),
|
||||
})
|
||||
|
||||
mockUploadGitHub.mockResolvedValue({
|
||||
manifest: { name: 'test-plugin', version: '2.0.0' },
|
||||
unique_identifier: 'test-plugin:2.0.0',
|
||||
})
|
||||
|
||||
const { fetchReleases, checkForUpdates } = useGitHubReleases()
|
||||
|
||||
const releases = await fetchReleases('test-org', 'test-repo')
|
||||
expect(releases).toHaveLength(3)
|
||||
expect(releases[0].tag_name).toBe('v2.0.0')
|
||||
|
||||
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(true)
|
||||
expect(toastProps.message).toContain('v2.0.0')
|
||||
|
||||
const { handleUpload } = useGitHubUpload()
|
||||
const onSuccess = vi.fn()
|
||||
const result = await handleUpload(
|
||||
'https://github.com/test-org/test-repo',
|
||||
'v2.0.0',
|
||||
'plugin-v2.difypkg',
|
||||
onSuccess,
|
||||
)
|
||||
|
||||
expect(mockUploadGitHub).toHaveBeenCalledWith(
|
||||
'https://github.com/test-org/test-repo',
|
||||
'v2.0.0',
|
||||
'plugin-v2.difypkg',
|
||||
)
|
||||
expect(onSuccess).toHaveBeenCalledWith({
|
||||
manifest: { name: 'test-plugin', version: '2.0.0' },
|
||||
unique_identifier: 'test-plugin:2.0.0',
|
||||
})
|
||||
expect(result).toEqual({
|
||||
manifest: { name: 'test-plugin', version: '2.0.0' },
|
||||
unique_identifier: 'test-plugin:2.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles no new version available', async () => {
|
||||
const mockReleases = [
|
||||
{
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
|
||||
},
|
||||
]
|
||||
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockReleases),
|
||||
})
|
||||
|
||||
const { fetchReleases, checkForUpdates } = useGitHubReleases()
|
||||
|
||||
const releases = await fetchReleases('test-org', 'test-repo')
|
||||
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
|
||||
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('info')
|
||||
expect(toastProps.message).toBe('No new version available')
|
||||
})
|
||||
|
||||
it('handles empty releases', async () => {
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
|
||||
const { fetchReleases, checkForUpdates } = useGitHubReleases()
|
||||
|
||||
const releases = await fetchReleases('test-org', 'test-repo')
|
||||
expect(releases).toHaveLength(0)
|
||||
|
||||
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('error')
|
||||
expect(toastProps.message).toBe('Input releases is empty')
|
||||
})
|
||||
|
||||
it('handles fetch failure gracefully', async () => {
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
const { fetchReleases } = useGitHubReleases()
|
||||
const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo')
|
||||
|
||||
expect(releases).toEqual([])
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('handles upload failure gracefully', async () => {
|
||||
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
const { handleUpload } = useGitHubUpload()
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
await expect(
|
||||
handleUpload('https://github.com/test/repo', 'v1.0.0', 'plugin.difypkg', onSuccess),
|
||||
).rejects.toThrow('Upload failed')
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error', message: 'Error uploading package' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Task Status Polling Integration', () => {
|
||||
it('polls until plugin installation succeeds', async () => {
|
||||
const mockCheckTaskStatus = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
task: {
|
||||
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'running' }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
task: {
|
||||
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'success' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
|
||||
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
|
||||
|
||||
await vi.doMock('@/utils', () => ({
|
||||
sleep: () => Promise.resolve(),
|
||||
}))
|
||||
|
||||
const { default: checkTaskStatus } = await import(
|
||||
'@/app/components/plugins/install-plugin/base/check-task-status',
|
||||
)
|
||||
|
||||
const checker = checkTaskStatus()
|
||||
const result = await checker.check({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test:1.0.0',
|
||||
})
|
||||
|
||||
expect(result.status).toBe('success')
|
||||
})
|
||||
|
||||
it('returns failure when plugin not found in task', async () => {
|
||||
const mockCheckTaskStatus = vi.fn().mockResolvedValue({
|
||||
task: {
|
||||
plugins: [{ plugin_unique_identifier: 'other:1.0.0', status: 'success' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
|
||||
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
|
||||
|
||||
const { default: checkTaskStatus } = await import(
|
||||
'@/app/components/plugins/install-plugin/base/check-task-status',
|
||||
)
|
||||
|
||||
const checker = checkTaskStatus()
|
||||
const result = await checker.check({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test:1.0.0',
|
||||
})
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toBe('Plugin package not found')
|
||||
})
|
||||
|
||||
it('stops polling when stop() is called', async () => {
|
||||
const { default: checkTaskStatus } = await import(
|
||||
'@/app/components/plugins/install-plugin/base/check-task-status',
|
||||
)
|
||||
|
||||
const checker = checkTaskStatus()
|
||||
checker.stop()
|
||||
|
||||
const result = await checker.check({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test:1.0.0',
|
||||
})
|
||||
|
||||
expect(result.status).toBe('success')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,97 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
|
||||
import { InstallationScope } from '@/types/feature'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Plugin Marketplace to Install Flow', () => {
|
||||
describe('install permission validation pipeline', () => {
|
||||
const systemFeaturesAll = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
const systemFeaturesMarketplaceOnly = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
const systemFeaturesOfficialOnly = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
|
||||
it('should allow marketplace plugin when all sources allowed', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow github plugin when all sources allowed', () => {
|
||||
const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should block github plugin when marketplace only', () => {
|
||||
const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
|
||||
expect(result.canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow marketplace plugin when marketplace only', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'partner' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow official plugin when official only', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should block community plugin when official only', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'community' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
|
||||
expect(result.canInstall).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin source classification', () => {
|
||||
it('should correctly classify plugin install sources', () => {
|
||||
const sources = ['marketplace', 'github', 'package'] as const
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
const results = sources.map(source => ({
|
||||
source,
|
||||
canInstall: pluginInstallLimit(
|
||||
{ from: source, verification: { authorized_category: 'langgenius' } } as never,
|
||||
features as never,
|
||||
).canInstall,
|
||||
}))
|
||||
|
||||
expect(results.find(r => r.source === 'marketplace')?.canInstall).toBe(true)
|
||||
expect(results.find(r => r.source === 'github')?.canInstall).toBe(false)
|
||||
expect(results.find(r => r.source === 'package')?.canInstall).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,120 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useStore } from '@/app/components/plugins/plugin-page/filter-management/store'
|
||||
|
||||
describe('Plugin Page Filter Management Integration', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
act(() => {
|
||||
result.current.setTagList([])
|
||||
result.current.setCategoryList([])
|
||||
result.current.setShowTagManagementModal(false)
|
||||
result.current.setShowCategoryManagementModal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tag and category filter lifecycle', () => {
|
||||
it('should manage full tag lifecycle: add -> update -> clear', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
const initialTags = [
|
||||
{ name: 'search', label: { en_US: 'Search' } },
|
||||
{ name: 'productivity', label: { en_US: 'Productivity' } },
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList(initialTags as never[])
|
||||
})
|
||||
expect(result.current.tagList).toHaveLength(2)
|
||||
|
||||
const updatedTags = [
|
||||
...initialTags,
|
||||
{ name: 'image', label: { en_US: 'Image' } },
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList(updatedTags as never[])
|
||||
})
|
||||
expect(result.current.tagList).toHaveLength(3)
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList([])
|
||||
})
|
||||
expect(result.current.tagList).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should manage full category lifecycle: add -> update -> clear', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
const categories = [
|
||||
{ name: 'tool', label: { en_US: 'Tool' } },
|
||||
{ name: 'model', label: { en_US: 'Model' } },
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.setCategoryList(categories as never[])
|
||||
})
|
||||
expect(result.current.categoryList).toHaveLength(2)
|
||||
|
||||
act(() => {
|
||||
result.current.setCategoryList([])
|
||||
})
|
||||
expect(result.current.categoryList).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal state management', () => {
|
||||
it('should manage tag management modal independently', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(true)
|
||||
})
|
||||
expect(result.current.showTagManagementModal).toBe(true)
|
||||
expect(result.current.showCategoryManagementModal).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(false)
|
||||
})
|
||||
expect(result.current.showTagManagementModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should manage category management modal independently', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowCategoryManagementModal(true)
|
||||
})
|
||||
expect(result.current.showCategoryManagementModal).toBe(true)
|
||||
expect(result.current.showTagManagementModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should support both modals open simultaneously', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(true)
|
||||
result.current.setShowCategoryManagementModal(true)
|
||||
})
|
||||
|
||||
expect(result.current.showTagManagementModal).toBe(true)
|
||||
expect(result.current.showCategoryManagementModal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state persistence across renders', () => {
|
||||
it('should maintain filter state when re-rendered', () => {
|
||||
const { result, rerender } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList([{ name: 'search' }] as never[])
|
||||
result.current.setCategoryList([{ name: 'tool' }] as never[])
|
||||
})
|
||||
|
||||
rerender()
|
||||
|
||||
expect(result.current.tagList).toHaveLength(1)
|
||||
expect(result.current.categoryList).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,210 +0,0 @@
|
||||
/**
|
||||
* Integration test: Chunk preview formatting pipeline
|
||||
*
|
||||
* Tests the formatPreviewChunks utility across all chunking modes
|
||||
* (text, parentChild, QA) with real data structures.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 3,
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
ChunkingMode: {
|
||||
text: 'text',
|
||||
parentChild: 'parent-child',
|
||||
qa: 'qa',
|
||||
},
|
||||
}))
|
||||
|
||||
const { formatPreviewChunks } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils',
|
||||
)
|
||||
|
||||
describe('Chunk Preview Formatting', () => {
|
||||
describe('general text chunks', () => {
|
||||
it('should format text chunks correctly', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'text',
|
||||
preview: [
|
||||
{ content: 'Chunk 1 content', summary: 'Summary 1' },
|
||||
{ content: 'Chunk 2 content' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs)
|
||||
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
const chunks = result as Array<{ content: string, summary?: string }>
|
||||
expect(chunks).toHaveLength(2)
|
||||
expect(chunks[0].content).toBe('Chunk 1 content')
|
||||
expect(chunks[0].summary).toBe('Summary 1')
|
||||
expect(chunks[1].content).toBe('Chunk 2 content')
|
||||
})
|
||||
|
||||
it('should limit chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'text',
|
||||
preview: Array.from({ length: 10 }, (_, i) => ({
|
||||
content: `Chunk ${i + 1}`,
|
||||
})),
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs)
|
||||
const chunks = result as Array<{ content: string }>
|
||||
|
||||
expect(chunks).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('parent-child chunks — paragraph mode', () => {
|
||||
it('should format paragraph parent-child chunks', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'paragraph',
|
||||
preview: [
|
||||
{
|
||||
content: 'Parent paragraph',
|
||||
child_chunks: ['Child 1', 'Child 2'],
|
||||
summary: 'Parent summary',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: Array<{
|
||||
parent_content: string
|
||||
parent_summary?: string
|
||||
child_contents: string[]
|
||||
parent_mode: string
|
||||
}>
|
||||
parent_mode: string
|
||||
}
|
||||
|
||||
expect(result.parent_mode).toBe('paragraph')
|
||||
expect(result.parent_child_chunks).toHaveLength(1)
|
||||
expect(result.parent_child_chunks[0].parent_content).toBe('Parent paragraph')
|
||||
expect(result.parent_child_chunks[0].parent_summary).toBe('Parent summary')
|
||||
expect(result.parent_child_chunks[0].child_contents).toEqual(['Child 1', 'Child 2'])
|
||||
})
|
||||
|
||||
it('should limit parent chunks in paragraph mode', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'paragraph',
|
||||
preview: Array.from({ length: 10 }, (_, i) => ({
|
||||
content: `Parent ${i + 1}`,
|
||||
child_chunks: [`Child of ${i + 1}`],
|
||||
})),
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: unknown[]
|
||||
}
|
||||
|
||||
expect(result.parent_child_chunks).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('parent-child chunks — full-doc mode', () => {
|
||||
it('should format full-doc parent-child chunks', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'full-doc',
|
||||
preview: [
|
||||
{
|
||||
content: 'Full document content',
|
||||
child_chunks: ['Section 1', 'Section 2', 'Section 3'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: Array<{
|
||||
parent_content: string
|
||||
child_contents: string[]
|
||||
parent_mode: string
|
||||
}>
|
||||
}
|
||||
|
||||
expect(result.parent_child_chunks).toHaveLength(1)
|
||||
expect(result.parent_child_chunks[0].parent_content).toBe('Full document content')
|
||||
expect(result.parent_child_chunks[0].parent_mode).toBe('full-doc')
|
||||
})
|
||||
|
||||
it('should limit child chunks in full-doc mode', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'full-doc',
|
||||
preview: [
|
||||
{
|
||||
content: 'Document',
|
||||
child_chunks: Array.from({ length: 20 }, (_, i) => `Section ${i + 1}`),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: Array<{ child_contents: string[] }>
|
||||
}
|
||||
|
||||
expect(result.parent_child_chunks[0].child_contents).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('QA chunks', () => {
|
||||
it('should format QA chunks correctly', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'qa',
|
||||
qa_preview: [
|
||||
{ question: 'What is AI?', answer: 'Artificial Intelligence is...' },
|
||||
{ question: 'What is ML?', answer: 'Machine Learning is...' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
qa_chunks: Array<{ question: string, answer: string }>
|
||||
}
|
||||
|
||||
expect(result.qa_chunks).toHaveLength(2)
|
||||
expect(result.qa_chunks[0].question).toBe('What is AI?')
|
||||
expect(result.qa_chunks[0].answer).toBe('Artificial Intelligence is...')
|
||||
})
|
||||
|
||||
it('should limit QA chunks', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'qa',
|
||||
qa_preview: Array.from({ length: 10 }, (_, i) => ({
|
||||
question: `Q${i + 1}`,
|
||||
answer: `A${i + 1}`,
|
||||
})),
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
qa_chunks: unknown[]
|
||||
}
|
||||
|
||||
expect(result.qa_chunks).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return undefined for null outputs', () => {
|
||||
expect(formatPreviewChunks(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for undefined outputs', () => {
|
||||
expect(formatPreviewChunks(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for unknown chunk_structure', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'unknown-type',
|
||||
preview: [],
|
||||
}
|
||||
|
||||
expect(formatPreviewChunks(outputs)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Integration test: DSL export/import flow
|
||||
*
|
||||
* Validates DSL export logic (sync draft → check secrets → download)
|
||||
* and DSL import modal state management.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined)
|
||||
const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' })
|
||||
const mockNotify = vi.fn()
|
||||
const mockEventEmitter = { emit: vi.fn() }
|
||||
const mockDownloadBlob = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
pipelineId: 'pipeline-abc',
|
||||
knowledgeName: 'My Pipeline',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: mockEventEmitter,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({
|
||||
mutateAsync: mockExportPipelineConfig,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DSL Export/Import Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Export Flow', () => {
|
||||
it('should sync draft then export then download', async () => {
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-abc',
|
||||
include: false,
|
||||
})
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileName: 'My Pipeline.pipeline',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should export with include flag when specified', async () => {
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL(true)
|
||||
})
|
||||
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-abc',
|
||||
include: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify on export error', async () => {
|
||||
mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed'))
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Export Check Flow', () => {
|
||||
it('should export directly when no secret environment variables', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
|
||||
environment_variables: [
|
||||
{ value_type: 'string', key: 'API_URL', value: 'https://api.example.com' },
|
||||
],
|
||||
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
// Should proceed to export directly (no secret vars)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
|
||||
environment_variables: [
|
||||
{ value_type: 'secret', key: 'API_KEY', value: '***' },
|
||||
],
|
||||
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'DSL_EXPORT_CHECK',
|
||||
payload: expect.objectContaining({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ value_type: 'secret' }),
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should notify on export check error', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed'))
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,278 +0,0 @@
|
||||
/**
|
||||
* Integration test: Input field CRUD complete flow
|
||||
*
|
||||
* Validates the full lifecycle of input fields:
|
||||
* creation, editing, renaming, removal, and data conversion round-trip.
|
||||
*/
|
||||
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
|
||||
type: 'text-input',
|
||||
label: '',
|
||||
variable: '',
|
||||
max_length: 48,
|
||||
default_value: undefined,
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Input Field CRUD Flow', () => {
|
||||
describe('Create → Edit → Convert Round-trip', () => {
|
||||
it('should create a text field and roundtrip through form data', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
// Create new field from template (no data passed)
|
||||
const newFormData = convertToInputFieldFormData()
|
||||
expect(newFormData.type).toBe('text-input')
|
||||
expect(newFormData.variable).toBe('')
|
||||
expect(newFormData.label).toBe('')
|
||||
expect(newFormData.required).toBe(true)
|
||||
|
||||
// Simulate user editing form data
|
||||
const editedFormData: FormData = {
|
||||
...newFormData,
|
||||
variable: 'user_name',
|
||||
label: 'User Name',
|
||||
maxLength: 100,
|
||||
default: 'John',
|
||||
tooltips: 'Enter your name',
|
||||
placeholder: 'Type here...',
|
||||
allowedTypesAndExtensions: {},
|
||||
}
|
||||
|
||||
// Convert back to InputVar
|
||||
const inputVar = convertFormDataToINputField(editedFormData)
|
||||
|
||||
expect(inputVar.variable).toBe('user_name')
|
||||
expect(inputVar.label).toBe('User Name')
|
||||
expect(inputVar.max_length).toBe(100)
|
||||
expect(inputVar.default_value).toBe('John')
|
||||
expect(inputVar.tooltips).toBe('Enter your name')
|
||||
expect(inputVar.placeholder).toBe('Type here...')
|
||||
expect(inputVar.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle file field with upload settings', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const fileInputVar: InputVar = {
|
||||
type: PipelineInputVarType.singleFile,
|
||||
label: 'Upload Document',
|
||||
variable: 'doc_file',
|
||||
max_length: 1,
|
||||
default_value: undefined,
|
||||
required: true,
|
||||
tooltips: 'Upload a PDF',
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: ['.pdf', '.docx'],
|
||||
}
|
||||
|
||||
// Convert to form data
|
||||
const formData = convertToInputFieldFormData(fileInputVar)
|
||||
expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
|
||||
expect(formData.allowedTypesAndExtensions).toEqual({
|
||||
allowedFileTypes: [SupportUploadFileTypes.document],
|
||||
allowedFileExtensions: ['.pdf', '.docx'],
|
||||
})
|
||||
|
||||
// Round-trip back
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
|
||||
expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document])
|
||||
expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx'])
|
||||
})
|
||||
|
||||
it('should handle select field with options', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const selectVar: InputVar = {
|
||||
type: PipelineInputVarType.select,
|
||||
label: 'Priority',
|
||||
variable: 'priority',
|
||||
max_length: 0,
|
||||
default_value: 'medium',
|
||||
required: false,
|
||||
tooltips: 'Select priority level',
|
||||
options: ['low', 'medium', 'high'],
|
||||
placeholder: 'Choose...',
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(selectVar)
|
||||
expect(formData.options).toEqual(['low', 'medium', 'high'])
|
||||
expect(formData.default).toBe('medium')
|
||||
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
expect(restored.options).toEqual(['low', 'medium', 'high'])
|
||||
expect(restored.default_value).toBe('medium')
|
||||
})
|
||||
|
||||
it('should handle number field with unit', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const numberVar: InputVar = {
|
||||
type: PipelineInputVarType.number,
|
||||
label: 'Max Tokens',
|
||||
variable: 'max_tokens',
|
||||
max_length: 0,
|
||||
default_value: '1024',
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: 'tokens',
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(numberVar)
|
||||
expect(formData.unit).toBe('tokens')
|
||||
expect(formData.default).toBe('1024')
|
||||
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
expect(restored.unit).toBe('tokens')
|
||||
expect(restored.default_value).toBe('1024')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Omit optional fields', () => {
|
||||
it('should not include tooltips when undefined', async () => {
|
||||
const { convertToInputFieldFormData } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const inputVar: InputVar = {
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Test',
|
||||
variable: 'test',
|
||||
max_length: 48,
|
||||
default_value: undefined,
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
// Optional fields should not be present
|
||||
expect('tooltips' in formData).toBe(false)
|
||||
expect('placeholder' in formData).toBe(false)
|
||||
expect('unit' in formData).toBe(false)
|
||||
expect('default' in formData).toBe(false)
|
||||
})
|
||||
|
||||
it('should include optional fields when explicitly set to empty string', async () => {
|
||||
const { convertToInputFieldFormData } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const inputVar: InputVar = {
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Test',
|
||||
variable: 'test',
|
||||
max_length: 48,
|
||||
default_value: '',
|
||||
required: true,
|
||||
tooltips: '',
|
||||
options: [],
|
||||
placeholder: '',
|
||||
unit: '',
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.default).toBe('')
|
||||
expect(formData.tooltips).toBe('')
|
||||
expect(formData.placeholder).toBe('')
|
||||
expect(formData.unit).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple fields workflow', () => {
|
||||
it('should process multiple fields independently', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const fields: InputVar[] = [
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
max_length: 48,
|
||||
default_value: 'Alice',
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
},
|
||||
{
|
||||
type: PipelineInputVarType.number,
|
||||
label: 'Count',
|
||||
variable: 'count',
|
||||
max_length: 0,
|
||||
default_value: '10',
|
||||
required: false,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: 'items',
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const formDataList = fields.map(f => convertToInputFieldFormData(f))
|
||||
const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd))
|
||||
|
||||
expect(restoredFields).toHaveLength(2)
|
||||
expect(restoredFields[0].variable).toBe('name')
|
||||
expect(restoredFields[0].default_value).toBe('Alice')
|
||||
expect(restoredFields[1].variable).toBe('count')
|
||||
expect(restoredFields[1].default_value).toBe('10')
|
||||
expect(restoredFields[1].unit).toBe('items')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,199 +0,0 @@
|
||||
/**
|
||||
* Integration test: Input field editor data conversion flow
|
||||
*
|
||||
* Tests the full pipeline: InputVar -> FormData -> InputVar roundtrip
|
||||
* and schema validation for various input types.
|
||||
*/
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
|
||||
// Mock the config module for VAR_ITEM_TEMPLATE_IN_PIPELINE
|
||||
vi.mock('@/config', () => ({
|
||||
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
|
||||
type: 'text-input',
|
||||
label: '',
|
||||
variable: '',
|
||||
max_length: 48,
|
||||
required: false,
|
||||
options: [],
|
||||
allowed_file_upload_methods: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
},
|
||||
MAX_VAR_KEY_LENGTH: 30,
|
||||
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 10,
|
||||
}))
|
||||
|
||||
// Import real functions (not mocked)
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
describe('Input Field Editor Data Flow', () => {
|
||||
describe('convertToInputFieldFormData', () => {
|
||||
it('should convert a text input InputVar to FormData', () => {
|
||||
const inputVar: InputVar = {
|
||||
type: 'text-input',
|
||||
label: 'Name',
|
||||
variable: 'user_name',
|
||||
max_length: 100,
|
||||
required: true,
|
||||
default_value: 'John',
|
||||
tooltips: 'Enter your name',
|
||||
placeholder: 'Type here...',
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.type).toBe('text-input')
|
||||
expect(formData.label).toBe('Name')
|
||||
expect(formData.variable).toBe('user_name')
|
||||
expect(formData.maxLength).toBe(100)
|
||||
expect(formData.required).toBe(true)
|
||||
expect(formData.default).toBe('John')
|
||||
expect(formData.tooltips).toBe('Enter your name')
|
||||
expect(formData.placeholder).toBe('Type here...')
|
||||
})
|
||||
|
||||
it('should handle file input with upload settings', () => {
|
||||
const inputVar: InputVar = {
|
||||
type: 'file',
|
||||
label: 'Document',
|
||||
variable: 'doc',
|
||||
required: false,
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
allowed_file_types: ['document', 'image'],
|
||||
allowed_file_extensions: ['.pdf', '.jpg'],
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.allowedFileUploadMethods).toEqual(['local_file', 'remote_url'])
|
||||
expect(formData.allowedTypesAndExtensions).toEqual({
|
||||
allowedFileTypes: ['document', 'image'],
|
||||
allowedFileExtensions: ['.pdf', '.jpg'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should use template defaults when no data provided', () => {
|
||||
const formData = convertToInputFieldFormData(undefined)
|
||||
|
||||
expect(formData.type).toBe('text-input')
|
||||
expect(formData.maxLength).toBe(48)
|
||||
expect(formData.required).toBe(false)
|
||||
})
|
||||
|
||||
it('should omit undefined/null optional fields', () => {
|
||||
const inputVar: InputVar = {
|
||||
type: 'text-input',
|
||||
label: 'Simple',
|
||||
variable: 'simple_var',
|
||||
max_length: 50,
|
||||
required: false,
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.default).toBeUndefined()
|
||||
expect(formData.tooltips).toBeUndefined()
|
||||
expect(formData.placeholder).toBeUndefined()
|
||||
expect(formData.unit).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertFormDataToINputField', () => {
|
||||
it('should convert FormData back to InputVar', () => {
|
||||
const formData = {
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Name',
|
||||
variable: 'user_name',
|
||||
maxLength: 100,
|
||||
required: true,
|
||||
default: 'John',
|
||||
tooltips: 'Enter your name',
|
||||
options: [],
|
||||
placeholder: 'Type here...',
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: undefined,
|
||||
allowedFileExtensions: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
const inputVar = convertFormDataToINputField(formData)
|
||||
|
||||
expect(inputVar.type).toBe('text-input')
|
||||
expect(inputVar.label).toBe('Name')
|
||||
expect(inputVar.variable).toBe('user_name')
|
||||
expect(inputVar.max_length).toBe(100)
|
||||
expect(inputVar.required).toBe(true)
|
||||
expect(inputVar.default_value).toBe('John')
|
||||
expect(inputVar.tooltips).toBe('Enter your name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('roundtrip conversion', () => {
|
||||
it('should preserve text input data through roundtrip', () => {
|
||||
const original: InputVar = {
|
||||
type: 'text-input',
|
||||
label: 'Question',
|
||||
variable: 'question',
|
||||
max_length: 200,
|
||||
required: true,
|
||||
default_value: 'What is AI?',
|
||||
tooltips: 'Enter your question',
|
||||
placeholder: 'Ask something...',
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(original)
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
|
||||
expect(restored.type).toBe(original.type)
|
||||
expect(restored.label).toBe(original.label)
|
||||
expect(restored.variable).toBe(original.variable)
|
||||
expect(restored.max_length).toBe(original.max_length)
|
||||
expect(restored.required).toBe(original.required)
|
||||
expect(restored.default_value).toBe(original.default_value)
|
||||
expect(restored.tooltips).toBe(original.tooltips)
|
||||
expect(restored.placeholder).toBe(original.placeholder)
|
||||
})
|
||||
|
||||
it('should preserve number input data through roundtrip', () => {
|
||||
const original = {
|
||||
type: 'number',
|
||||
label: 'Temperature',
|
||||
variable: 'temp',
|
||||
required: false,
|
||||
default_value: '0.7',
|
||||
unit: '°C',
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(original)
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
|
||||
expect(restored.type).toBe('number')
|
||||
expect(restored.unit).toBe('°C')
|
||||
expect(restored.default_value).toBe('0.7')
|
||||
})
|
||||
|
||||
it('should preserve select options through roundtrip', () => {
|
||||
const original: InputVar = {
|
||||
type: 'select',
|
||||
label: 'Mode',
|
||||
variable: 'mode',
|
||||
required: true,
|
||||
options: ['fast', 'balanced', 'quality'],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(original)
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
|
||||
expect(restored.options).toEqual(['fast', 'balanced', 'quality'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,277 +0,0 @@
|
||||
/**
|
||||
* Integration test: Test run end-to-end flow
|
||||
*
|
||||
* Validates the data flow through test-run preparation hooks:
|
||||
* step navigation, datasource filtering, and data clearing.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mutable holder so mock data can reference BlockEnum after imports
|
||||
const mockNodesHolder = vi.hoisted(() => ({ value: [] as Record<string, unknown>[] }))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => mockNodesHolder.value,
|
||||
}))
|
||||
|
||||
mockNodesHolder.value = [
|
||||
{
|
||||
id: 'ds-1',
|
||||
data: {
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Local Files',
|
||||
datasource_type: 'upload_file',
|
||||
datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ds-2',
|
||||
data: {
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Web Crawl',
|
||||
datasource_type: 'website_crawl',
|
||||
datasource_configurations: { datasource_label: 'Crawl' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kb-1',
|
||||
data: {
|
||||
type: BlockEnum.KnowledgeBase,
|
||||
title: 'Knowledge Base',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Mock the Zustand store used by the hooks
|
||||
const mockSetDocumentsData = vi.fn()
|
||||
const mockSetSearchValue = vi.fn()
|
||||
const mockSetSelectedPagesId = vi.fn()
|
||||
const mockSetOnlineDocuments = vi.fn()
|
||||
const mockSetCurrentDocument = vi.fn()
|
||||
const mockSetStep = vi.fn()
|
||||
const mockSetCrawlResult = vi.fn()
|
||||
const mockSetWebsitePages = vi.fn()
|
||||
const mockSetPreviewIndex = vi.fn()
|
||||
const mockSetCurrentWebsite = vi.fn()
|
||||
const mockSetOnlineDriveFileList = vi.fn()
|
||||
const mockSetBucket = vi.fn()
|
||||
const mockSetPrefix = vi.fn()
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetSelectedFileIds = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
|
||||
useDataSourceStore: () => ({
|
||||
getState: () => ({
|
||||
setDocumentsData: mockSetDocumentsData,
|
||||
setSearchValue: mockSetSearchValue,
|
||||
setSelectedPagesId: mockSetSelectedPagesId,
|
||||
setOnlineDocuments: mockSetOnlineDocuments,
|
||||
setCurrentDocument: mockSetCurrentDocument,
|
||||
setStep: mockSetStep,
|
||||
setCrawlResult: mockSetCrawlResult,
|
||||
setWebsitePages: mockSetWebsitePages,
|
||||
setPreviewIndex: mockSetPreviewIndex,
|
||||
setCurrentWebsite: mockSetCurrentWebsite,
|
||||
setOnlineDriveFileList: mockSetOnlineDriveFileList,
|
||||
setBucket: mockSetBucket,
|
||||
setPrefix: mockSetPrefix,
|
||||
setKeywords: mockSetKeywords,
|
||||
setSelectedFileIds: mockSetSelectedFileIds,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
CrawlStep: {
|
||||
init: 'init',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Test Run Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Step Navigation', () => {
|
||||
it('should start at step 1 and navigate forward', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
})
|
||||
|
||||
it('should navigate back from step 2 to step 1', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
|
||||
act(() => {
|
||||
result.current.handleBackStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
})
|
||||
|
||||
it('should provide labeled steps', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.steps).toHaveLength(2)
|
||||
expect(result.current.steps[0].value).toBe('dataSource')
|
||||
expect(result.current.steps[1].value).toBe('documentProcessing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Datasource Options', () => {
|
||||
it('should filter nodes to only DataSource type', async () => {
|
||||
const { useDatasourceOptions } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
// Should only include DataSource nodes, not KnowledgeBase
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(result.current[0].value).toBe('ds-1')
|
||||
expect(result.current[1].value).toBe('ds-2')
|
||||
})
|
||||
|
||||
it('should include node data in options', async () => {
|
||||
const { useDatasourceOptions } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
expect(result.current[0].label).toBe('Local Files')
|
||||
expect(result.current[0].data.type).toBe(BlockEnum.DataSource)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Clearing Flow', () => {
|
||||
it('should clear online document data', async () => {
|
||||
const { useOnlineDocument } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useOnlineDocument())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDocumentData()
|
||||
})
|
||||
|
||||
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
|
||||
expect(mockSetSearchValue).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set))
|
||||
expect(mockSetOnlineDocuments).toHaveBeenCalledWith([])
|
||||
expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should clear website crawl data', async () => {
|
||||
const { useWebsiteCrawl } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useWebsiteCrawl())
|
||||
|
||||
act(() => {
|
||||
result.current.clearWebsiteCrawlData()
|
||||
})
|
||||
|
||||
expect(mockSetStep).toHaveBeenCalledWith('init')
|
||||
expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined)
|
||||
expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined)
|
||||
expect(mockSetWebsitePages).toHaveBeenCalledWith([])
|
||||
expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1)
|
||||
})
|
||||
|
||||
it('should clear online drive data', async () => {
|
||||
const { useOnlineDrive } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useOnlineDrive())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDriveData()
|
||||
})
|
||||
|
||||
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
|
||||
expect(mockSetBucket).toHaveBeenCalledWith('')
|
||||
expect(mockSetPrefix).toHaveBeenCalledWith([])
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedFileIds).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Flow Simulation', () => {
|
||||
it('should support complete step navigation cycle', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
// Start at step 1
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
|
||||
// Move to step 2
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
|
||||
// Go back to step 1
|
||||
act(() => {
|
||||
result.current.handleBackStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
|
||||
// Move forward again
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
})
|
||||
|
||||
it('should not regress when clearing all data sources in sequence', async () => {
|
||||
const {
|
||||
useOnlineDocument,
|
||||
useWebsiteCrawl,
|
||||
useOnlineDrive,
|
||||
} = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result: docResult } = renderHook(() => useOnlineDocument())
|
||||
const { result: crawlResult } = renderHook(() => useWebsiteCrawl())
|
||||
const { result: driveResult } = renderHook(() => useOnlineDrive())
|
||||
|
||||
// Clear all data sources
|
||||
act(() => {
|
||||
docResult.current.clearOnlineDocumentData()
|
||||
crawlResult.current.clearWebsiteCrawlData()
|
||||
driveResult.current.clearOnlineDriveData()
|
||||
})
|
||||
|
||||
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
|
||||
expect(mockSetStep).toHaveBeenCalledWith('init')
|
||||
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Integration test: RunBatch CSV upload → Run flow
|
||||
*
|
||||
* Tests the complete user journey:
|
||||
* Upload CSV → parse → enable run → click run → results finish → run again
|
||||
*/
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import RunBatch from '@/app/components/share/text-generation/run-batch'
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(() => 'pc'),
|
||||
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
|
||||
}))
|
||||
|
||||
// Capture the onParsed callback from CSVReader to simulate CSV uploads
|
||||
let capturedOnParsed: ((data: string[][]) => void) | undefined
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({
|
||||
default: ({ onParsed }: { onParsed: (data: string[][]) => void }) => {
|
||||
capturedOnParsed = onParsed
|
||||
return <div data-testid="csv-reader">CSV Reader</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({
|
||||
default: ({ vars }: { vars: { name: string }[] }) => (
|
||||
<div data-testid="csv-download">
|
||||
{vars.map(v => v.name).join(', ')}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('RunBatch – integration flow', () => {
|
||||
const vars = [{ name: 'prompt' }, { name: 'context' }]
|
||||
|
||||
beforeEach(() => {
|
||||
capturedOnParsed = undefined
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('full lifecycle: upload CSV → run → finish → run again', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<RunBatch vars={vars} onSend={onSend} isAllFinished />,
|
||||
)
|
||||
|
||||
// Phase 1 – verify child components rendered
|
||||
expect(screen.getByTestId('csv-reader')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('csv-download')).toHaveTextContent('prompt, context')
|
||||
|
||||
// Run button should be disabled before CSV is parsed
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
// Phase 2 – simulate CSV upload
|
||||
const csvData = [
|
||||
['prompt', 'context'],
|
||||
['Hello', 'World'],
|
||||
['Goodbye', 'Moon'],
|
||||
]
|
||||
await act(async () => {
|
||||
capturedOnParsed?.(csvData)
|
||||
})
|
||||
|
||||
// Run button should now be enabled
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Phase 3 – click run
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
expect(onSend).toHaveBeenCalledWith(csvData)
|
||||
|
||||
// Phase 4 – simulate results still running
|
||||
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />)
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
// Phase 5 – results finish → can run again
|
||||
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
onSend.mockClear()
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remain disabled when CSV not uploaded even if all finished', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show spinner icon when results are still running', async () => {
|
||||
const onSend = vi.fn()
|
||||
const { container } = render(
|
||||
<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />,
|
||||
)
|
||||
|
||||
// Upload CSV first
|
||||
await act(async () => {
|
||||
capturedOnParsed?.([['data']])
|
||||
})
|
||||
|
||||
// Button disabled + spinning icon
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('animate-spin')
|
||||
})
|
||||
})
|
||||
@ -1,218 +0,0 @@
|
||||
/**
|
||||
* Integration test: RunOnce form lifecycle
|
||||
*
|
||||
* Tests the complete user journey:
|
||||
* Init defaults → edit fields → submit → running state → stop
|
||||
*/
|
||||
import type { InputValueTypes } from '@/app/components/share/text-generation/types'
|
||||
import type { PromptConfig, PromptVariable } from '@/models/debug'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import RunOnce from '@/app/components/share/text-generation/run-once'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(() => 'pc'),
|
||||
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => (
|
||||
<textarea data-testid="code-editor" value={value ?? ''} onChange={e => onChange?.(e.target.value)} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => ({
|
||||
default: () => <div data-testid="vision-uploader" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: () => <div data-testid="file-uploader" />,
|
||||
}))
|
||||
|
||||
// ----- helpers -----
|
||||
|
||||
const variable = (overrides: Partial<PromptVariable>): PromptVariable => ({
|
||||
key: 'k',
|
||||
name: 'Name',
|
||||
type: 'string',
|
||||
required: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const visionOff: VisionSettings = {
|
||||
enabled: false,
|
||||
number_limits: 0,
|
||||
detail: Resolution.low,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
image_file_size_limit: 5,
|
||||
}
|
||||
|
||||
const siteInfo: SiteInfo = { title: 'Test' }
|
||||
|
||||
/**
|
||||
* Stateful wrapper that mirrors what text-generation/index.tsx does:
|
||||
* owns `inputs` state and passes an `inputsRef`.
|
||||
*/
|
||||
function Harness({
|
||||
promptConfig,
|
||||
visionConfig = visionOff,
|
||||
onSendSpy,
|
||||
runControl = null,
|
||||
}: {
|
||||
promptConfig: PromptConfig
|
||||
visionConfig?: VisionSettings
|
||||
onSendSpy: () => void
|
||||
runControl?: React.ComponentProps<typeof RunOnce>['runControl']
|
||||
}) {
|
||||
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
|
||||
const inputsRef = useRef<Record<string, InputValueTypes>>({})
|
||||
|
||||
return (
|
||||
<RunOnce
|
||||
siteInfo={siteInfo}
|
||||
promptConfig={promptConfig}
|
||||
inputs={inputs}
|
||||
inputsRef={inputsRef}
|
||||
onInputsChange={(updated) => {
|
||||
inputsRef.current = updated
|
||||
setInputs(updated)
|
||||
}}
|
||||
onSend={onSendSpy}
|
||||
visionConfig={visionConfig}
|
||||
onVisionFilesChange={vi.fn()}
|
||||
runControl={runControl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ----- tests -----
|
||||
|
||||
describe('RunOnce – integration flow', () => {
|
||||
it('full lifecycle: init → edit → submit → running → stop', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'name', name: 'Name', type: 'string', default: '' }),
|
||||
variable({ key: 'age', name: 'Age', type: 'number', default: '' }),
|
||||
variable({ key: 'bio', name: 'Bio', type: 'paragraph', default: '' }),
|
||||
],
|
||||
}
|
||||
|
||||
// Phase 1 – render, wait for initialisation
|
||||
const { rerender } = render(
|
||||
<Harness promptConfig={config} onSendSpy={onSend} />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Phase 2 – fill fields
|
||||
fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'Alice' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Age'), { target: { value: '30' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Bio'), { target: { value: 'Hello' } })
|
||||
|
||||
// Phase 3 – submit
|
||||
fireEvent.click(screen.getByTestId('run-button'))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Phase 4 – simulate "running" state
|
||||
const onStop = vi.fn()
|
||||
rerender(
|
||||
<Harness
|
||||
promptConfig={config}
|
||||
onSendSpy={onSend}
|
||||
runControl={{ onStop, isStopping: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const stopBtn = screen.getByTestId('stop-button')
|
||||
expect(stopBtn).toBeInTheDocument()
|
||||
fireEvent.click(stopBtn)
|
||||
expect(onStop).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Phase 5 – simulate "stopping" state
|
||||
rerender(
|
||||
<Harness
|
||||
promptConfig={config}
|
||||
onSendSpy={onSend}
|
||||
runControl={{ onStop, isStopping: true }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('stop-button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('clear resets all field types and allows re-submit', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'q', name: 'Question', type: 'string', default: 'Hi' }),
|
||||
variable({ key: 'flag', name: 'Flag', type: 'checkbox' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<Harness promptConfig={config} onSendSpy={onSend} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Question')).toHaveValue('Hi')
|
||||
})
|
||||
|
||||
// Clear all
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Question')).toHaveValue('')
|
||||
})
|
||||
|
||||
// Re-fill and submit
|
||||
fireEvent.change(screen.getByPlaceholderText('Question'), { target: { value: 'New' } })
|
||||
fireEvent.click(screen.getByTestId('run-button'))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mixed input types: string + select + json_object', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'txt', name: 'Text', type: 'string', default: '' }),
|
||||
variable({
|
||||
key: 'sel',
|
||||
name: 'Dropdown',
|
||||
type: 'select',
|
||||
options: ['A', 'B'],
|
||||
default: 'A',
|
||||
}),
|
||||
variable({
|
||||
key: 'json',
|
||||
name: 'JSON',
|
||||
type: 'json_object' as PromptVariable['type'],
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
render(<Harness promptConfig={config} onSendSpy={onSend} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('JSON')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Edit text & json
|
||||
fireEvent.change(screen.getByPlaceholderText('Text'), { target: { value: 'hello' } })
|
||||
fireEvent.change(screen.getByTestId('code-editor'), { target: { value: '{"a":1}' } })
|
||||
|
||||
fireEvent.click(screen.getByTestId('run-button'))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,369 +0,0 @@
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
/**
|
||||
* Integration Test: Tool Browsing & Filtering Flow
|
||||
*
|
||||
* Tests the integration between ProviderList, TabSliderNew, LabelFilter,
|
||||
* Input (search), and card rendering. Verifies that tab switching, keyword
|
||||
* filtering, and label filtering work together correctly.
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
// ---- Mocks ----
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'type.builtIn': 'Built-in',
|
||||
'type.custom': 'Custom',
|
||||
'type.workflow': 'Workflow',
|
||||
'noTools': 'No tools found',
|
||||
}
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryState: () => ['builtin', vi.fn()],
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({ enable_marketplace: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
getTagLabel: (key: string) => key,
|
||||
tags: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useCheckInstalled: () => ({ data: null }),
|
||||
useInvalidateInstalledPluginList: () => vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCollections: Collection[] = [
|
||||
{
|
||||
id: 'google-search',
|
||||
name: 'google_search',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Google Search Tool', zh_Hans: 'Google搜索工具' },
|
||||
icon: 'https://example.com/google.png',
|
||||
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: true,
|
||||
allow_delete: false,
|
||||
labels: ['search'],
|
||||
},
|
||||
{
|
||||
id: 'weather-api',
|
||||
name: 'weather_api',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Weather API Tool', zh_Hans: '天气API工具' },
|
||||
icon: 'https://example.com/weather.png',
|
||||
label: { en_US: 'Weather API', zh_Hans: '天气API' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: ['utility'],
|
||||
},
|
||||
{
|
||||
id: 'my-custom-tool',
|
||||
name: 'my_custom_tool',
|
||||
author: 'User',
|
||||
description: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
|
||||
icon: 'https://example.com/custom.png',
|
||||
label: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
|
||||
type: CollectionType.custom,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
{
|
||||
id: 'workflow-tool-1',
|
||||
name: 'workflow_tool_1',
|
||||
author: 'User',
|
||||
description: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
icon: 'https://example.com/workflow.png',
|
||||
label: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
type: CollectionType.workflow,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
]
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => ({
|
||||
data: mockCollections,
|
||||
refetch: mockRefetch,
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tab-slider-new', () => ({
|
||||
default: ({ value, onChange, options }: { value: string, onChange: (v: string) => void, options: Array<{ value: string, text: string }> }) => (
|
||||
<div data-testid="tab-slider">
|
||||
{options.map((opt: { value: string, text: string }) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
data-testid={`tab-${opt.value}`}
|
||||
data-active={value === opt.value ? 'true' : 'false'}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/input', () => ({
|
||||
default: ({ value, onChange, onClear, showLeftIcon, showClearIcon, wrapperClassName }: {
|
||||
value: string
|
||||
onChange: (e: { target: { value: string } }) => void
|
||||
onClear: () => void
|
||||
showLeftIcon?: boolean
|
||||
showClearIcon?: boolean
|
||||
wrapperClassName?: string
|
||||
}) => (
|
||||
<div data-testid="search-input-wrapper" className={wrapperClassName}>
|
||||
<input
|
||||
data-testid="search-input"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-left-icon={showLeftIcon ? 'true' : 'false'}
|
||||
data-clear-icon={showClearIcon ? 'true' : 'false'}
|
||||
/>
|
||||
{showClearIcon && value && (
|
||||
<button data-testid="clear-search" onClick={onClear}>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({ payload, className }: { payload: { brief: Record<string, string> | string, name: string }, className?: string }) => {
|
||||
const briefText = typeof payload.brief === 'object' ? payload.brief?.en_US || '' : payload.brief
|
||||
return (
|
||||
<div data-testid={`card-${payload.name}`} className={className}>
|
||||
<span>{payload.name}</span>
|
||||
<span>{briefText}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="card-more-info">{tags.join(', ')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/labels/filter', () => ({
|
||||
default: ({ value: _value, onChange }: { value: string[], onChange: (v: string[]) => void }) => (
|
||||
<div data-testid="label-filter">
|
||||
<button data-testid="filter-search" onClick={() => onChange(['search'])}>Filter: search</button>
|
||||
<button data-testid="filter-utility" onClick={() => onChange(['utility'])}>Filter: utility</button>
|
||||
<button data-testid="filter-clear" onClick={() => onChange([])}>Clear filter</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/custom-create-card', () => ({
|
||||
default: () => <div data-testid="custom-create-card">Create Custom Tool</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/detail', () => ({
|
||||
default: ({ collection, onHide }: { collection: Collection, onHide: () => void }) => (
|
||||
<div data-testid="provider-detail">
|
||||
<span data-testid="detail-name">{collection.name}</span>
|
||||
<button data-testid="detail-close" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/empty', () => ({
|
||||
default: () => <div data-testid="workflow-empty">No workflow tools</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
|
||||
default: ({ detail, onHide }: { detail: unknown, onHide: () => void }) => (
|
||||
detail ? <div data-testid="plugin-detail-panel"><button onClick={onHide}>Close</button></div> : null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="empty-state">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/marketplace', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/mcp', () => ({
|
||||
default: () => <div data-testid="mcp-list">MCP List</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/types', () => ({
|
||||
ToolTypeEnum: { BuiltIn: 'builtin', Custom: 'api', Workflow: 'workflow', MCP: 'mcp' },
|
||||
}))
|
||||
|
||||
const { default: ProviderList } = await import('@/app/components/tools/provider-list')
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Tool Browsing & Filtering Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders tab options and built-in tools by default', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-builtin')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-api')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-workflow')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-mcp')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-my_custom_tool')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-workflow_tool_1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters tools by keyword search', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const searchInput = screen.getByTestId('search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'Google' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears search keyword and shows all tools again', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const searchInput = screen.getByTestId('search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'Google' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('filters tools by label tags', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-search'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears label filter and shows all tools', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-utility'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-clear'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('combines keyword search and label filter', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-search'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const searchInput = screen.getByTestId('search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'Weather' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens provider detail when clicking a non-plugin collection card', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const card = screen.getByTestId('card-google_search')
|
||||
fireEvent.click(card.parentElement!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-name')).toHaveTextContent('google_search')
|
||||
})
|
||||
})
|
||||
|
||||
it('closes provider detail and deselects current provider', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const card = screen.getByTestId('card-google_search')
|
||||
fireEvent.click(card.parentElement!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('detail-close'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows label filter for non-MCP tabs', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('label-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows search input on all tabs', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Tool Data Processing Pipeline
|
||||
*
|
||||
* Tests the integration between tool utility functions and type conversions.
|
||||
* Verifies that data flows correctly through the processing pipeline:
|
||||
* raw API data → form schemas → form values → configured values.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils/index'
|
||||
import {
|
||||
addDefaultValue,
|
||||
generateFormValue,
|
||||
getConfiguredValue,
|
||||
getPlainValue,
|
||||
getStructureValue,
|
||||
toolCredentialToFormSchemas,
|
||||
toolParametersToFormSchemas,
|
||||
toType,
|
||||
triggerEventParametersToFormSchemas,
|
||||
} from '@/app/components/tools/utils/to-form-schema'
|
||||
|
||||
describe('Tool Data Processing Pipeline Integration', () => {
|
||||
describe('End-to-end: API schema → form schema → form value', () => {
|
||||
it('processes tool parameters through the full pipeline', () => {
|
||||
const rawParameters = [
|
||||
{
|
||||
name: 'query',
|
||||
label: { en_US: 'Search Query', zh_Hans: '搜索查询' },
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'hello',
|
||||
form: 'llm',
|
||||
human_description: { en_US: 'Enter your search query', zh_Hans: '输入搜索查询' },
|
||||
llm_description: 'The search query string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
label: { en_US: 'Result Limit', zh_Hans: '结果限制' },
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: '10',
|
||||
form: 'form',
|
||||
human_description: { en_US: 'Maximum results', zh_Hans: '最大结果数' },
|
||||
llm_description: 'Limit for results',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const formSchemas = toolParametersToFormSchemas(rawParameters as unknown as Parameters<typeof toolParametersToFormSchemas>[0])
|
||||
expect(formSchemas).toHaveLength(2)
|
||||
expect(formSchemas[0].variable).toBe('query')
|
||||
expect(formSchemas[0].required).toBe(true)
|
||||
expect(formSchemas[0].type).toBe('text-input')
|
||||
expect(formSchemas[1].variable).toBe('limit')
|
||||
expect(formSchemas[1].type).toBe('number-input')
|
||||
|
||||
const withDefaults = addDefaultValue({}, formSchemas)
|
||||
expect(withDefaults.query).toBe('hello')
|
||||
expect(withDefaults.limit).toBe('10')
|
||||
|
||||
const formValues = generateFormValue({}, formSchemas, false)
|
||||
expect(formValues).toBeDefined()
|
||||
expect(formValues.query).toBeDefined()
|
||||
expect(formValues.limit).toBeDefined()
|
||||
})
|
||||
|
||||
it('processes tool credentials through the pipeline', () => {
|
||||
const rawCredentials = [
|
||||
{
|
||||
name: 'api_key',
|
||||
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
|
||||
type: 'secret-input',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: { en_US: 'Enter API key', zh_Hans: '输入 API 密钥' },
|
||||
help: { en_US: 'Your API key', zh_Hans: '你的 API 密钥' },
|
||||
url: 'https://example.com/get-key',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const credentialSchemas = toolCredentialToFormSchemas(rawCredentials as Parameters<typeof toolCredentialToFormSchemas>[0])
|
||||
expect(credentialSchemas).toHaveLength(1)
|
||||
expect(credentialSchemas[0].variable).toBe('api_key')
|
||||
expect(credentialSchemas[0].required).toBe(true)
|
||||
expect(credentialSchemas[0].type).toBe('secret-input')
|
||||
})
|
||||
|
||||
it('processes trigger event parameters through the pipeline', () => {
|
||||
const rawParams = [
|
||||
{
|
||||
name: 'event_type',
|
||||
label: { en_US: 'Event Type', zh_Hans: '事件类型' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'push',
|
||||
form: 'form',
|
||||
description: { en_US: 'Type of event', zh_Hans: '事件类型' },
|
||||
options: [
|
||||
{ value: 'push', label: { en_US: 'Push', zh_Hans: '推送' } },
|
||||
{ value: 'pull', label: { en_US: 'Pull', zh_Hans: '拉取' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const schemas = triggerEventParametersToFormSchemas(rawParams as unknown as Parameters<typeof triggerEventParametersToFormSchemas>[0])
|
||||
expect(schemas).toHaveLength(1)
|
||||
expect(schemas[0].name).toBe('event_type')
|
||||
expect(schemas[0].type).toBe('select')
|
||||
expect(schemas[0].options).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type conversion integration', () => {
|
||||
it('converts all supported types correctly', () => {
|
||||
const typeConversions = [
|
||||
{ input: 'string', expected: 'text-input' },
|
||||
{ input: 'number', expected: 'number-input' },
|
||||
{ input: 'boolean', expected: 'checkbox' },
|
||||
{ input: 'select', expected: 'select' },
|
||||
{ input: 'secret-input', expected: 'secret-input' },
|
||||
{ input: 'file', expected: 'file' },
|
||||
{ input: 'files', expected: 'files' },
|
||||
]
|
||||
|
||||
typeConversions.forEach(({ input, expected }) => {
|
||||
expect(toType(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the original type for unrecognized types', () => {
|
||||
expect(toType('unknown-type')).toBe('unknown-type')
|
||||
expect(toType('app-selector')).toBe('app-selector')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value extraction integration', () => {
|
||||
it('wraps values with getStructureValue and extracts inner value with getPlainValue', () => {
|
||||
const plainInput = { query: 'test', limit: 10 }
|
||||
const structured = getStructureValue(plainInput)
|
||||
|
||||
expect(structured.query).toEqual({ value: 'test' })
|
||||
expect(structured.limit).toEqual({ value: 10 })
|
||||
|
||||
const objectStructured = {
|
||||
query: { value: { type: 'constant', content: 'test search' } },
|
||||
limit: { value: { type: 'constant', content: 10 } },
|
||||
}
|
||||
const extracted = getPlainValue(objectStructured)
|
||||
expect(extracted.query).toEqual({ type: 'constant', content: 'test search' })
|
||||
expect(extracted.limit).toEqual({ type: 'constant', content: 10 })
|
||||
})
|
||||
|
||||
it('handles getConfiguredValue for workflow tool configurations', () => {
|
||||
const formSchemas = [
|
||||
{ variable: 'query', type: 'text-input', default: 'default-query' },
|
||||
{ variable: 'format', type: 'select', default: 'json' },
|
||||
]
|
||||
|
||||
const configured = getConfiguredValue({}, formSchemas)
|
||||
expect(configured).toBeDefined()
|
||||
expect(configured.query).toBeDefined()
|
||||
expect(configured.format).toBeDefined()
|
||||
})
|
||||
|
||||
it('preserves existing values in getConfiguredValue', () => {
|
||||
const formSchemas = [
|
||||
{ variable: 'query', type: 'text-input', default: 'default-query' },
|
||||
]
|
||||
|
||||
const configured = getConfiguredValue({ query: 'my-existing-query' }, formSchemas)
|
||||
expect(configured.query).toBe('my-existing-query')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent utilities integration', () => {
|
||||
it('sorts agent thoughts and enriches with file infos end-to-end', () => {
|
||||
const thoughts = [
|
||||
{ id: 't3', position: 3, tool: 'search', files: ['f1'] },
|
||||
{ id: 't1', position: 1, tool: 'analyze', files: [] },
|
||||
{ id: 't2', position: 2, tool: 'summarize', files: ['f2'] },
|
||||
] as Parameters<typeof sortAgentSorts>[0]
|
||||
|
||||
const messageFiles = [
|
||||
{ id: 'f1', name: 'result.txt', type: 'document' },
|
||||
{ id: 'f2', name: 'summary.pdf', type: 'document' },
|
||||
] as Parameters<typeof addFileInfos>[1]
|
||||
|
||||
const sorted = sortAgentSorts(thoughts)
|
||||
expect(sorted[0].id).toBe('t1')
|
||||
expect(sorted[1].id).toBe('t2')
|
||||
expect(sorted[2].id).toBe('t3')
|
||||
|
||||
const enriched = addFileInfos(sorted, messageFiles)
|
||||
expect(enriched[0].message_files).toBeUndefined()
|
||||
expect(enriched[1].message_files).toHaveLength(1)
|
||||
expect(enriched[1].message_files![0].id).toBe('f2')
|
||||
expect(enriched[2].message_files).toHaveLength(1)
|
||||
expect(enriched[2].message_files![0].id).toBe('f1')
|
||||
})
|
||||
|
||||
it('handles null inputs gracefully in the pipeline', () => {
|
||||
const sortedNull = sortAgentSorts(null as never)
|
||||
expect(sortedNull).toBeNull()
|
||||
|
||||
const enrichedNull = addFileInfos(null as never, [])
|
||||
expect(enrichedNull).toBeNull()
|
||||
|
||||
// addFileInfos with empty list and null files returns the mapped (empty) list
|
||||
const enrichedEmptyList = addFileInfos([], null as never)
|
||||
expect(enrichedEmptyList).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default value application', () => {
|
||||
it('applies defaults only to empty fields, preserving user values', () => {
|
||||
const userValues = { api_key: 'user-provided-key' }
|
||||
const schemas = [
|
||||
{ variable: 'api_key', type: 'text-input', default: 'default-key', name: 'api_key' },
|
||||
{ variable: 'secret', type: 'secret-input', default: 'default-secret', name: 'secret' },
|
||||
]
|
||||
|
||||
const result = addDefaultValue(userValues, schemas)
|
||||
expect(result.api_key).toBe('user-provided-key')
|
||||
expect(result.secret).toBe('default-secret')
|
||||
})
|
||||
|
||||
it('handles boolean type conversion in defaults', () => {
|
||||
const schemas = [
|
||||
{ variable: 'enabled', type: 'boolean', default: 'true', name: 'enabled' },
|
||||
]
|
||||
|
||||
const result = addDefaultValue({ enabled: 'true' }, schemas)
|
||||
expect(result.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,548 +0,0 @@
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
/**
|
||||
* Integration Test: Tool Provider Detail Flow
|
||||
*
|
||||
* Tests the integration between ProviderDetail, ConfigCredential,
|
||||
* EditCustomToolModal, WorkflowToolModal, and service APIs.
|
||||
* Verifies that different provider types render correctly and
|
||||
* handle auth/edit/delete flows.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
const map: Record<string, string> = {
|
||||
'auth.authorized': 'Authorized',
|
||||
'auth.unauthorized': 'Set up credentials',
|
||||
'auth.setup': 'NEEDS SETUP',
|
||||
'createTool.editAction': 'Edit',
|
||||
'createTool.deleteToolConfirmTitle': 'Delete Tool',
|
||||
'createTool.deleteToolConfirmContent': 'Are you sure?',
|
||||
'createTool.toolInput.title': 'Tool Input',
|
||||
'createTool.toolInput.required': 'Required',
|
||||
'openInStudio': 'Open in Studio',
|
||||
'api.actionSuccess': 'Action succeeded',
|
||||
}
|
||||
if (key === 'detailPanel.actionNum')
|
||||
return `${opts?.num ?? 0} actions`
|
||||
if (key === 'includeToolNum')
|
||||
return `${opts?.num ?? 0} actions`
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en',
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
getLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetShowModelModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowModelModal: mockSetShowModelModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [
|
||||
{ provider: 'model-provider-1', name: 'Model Provider 1' },
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([
|
||||
{ name: 'tool-1', description: { en_US: 'Tool 1' }, parameters: [] },
|
||||
{ name: 'tool-2', description: { en_US: 'Tool 2' }, parameters: [] },
|
||||
])
|
||||
const mockFetchModelToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomCollection = vi.fn().mockResolvedValue({
|
||||
credentials: { auth_type: 'none' },
|
||||
schema: '',
|
||||
schema_type: 'openapi',
|
||||
})
|
||||
const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({
|
||||
workflow_app_id: 'app-123',
|
||||
tool: {
|
||||
parameters: [
|
||||
{ name: 'query', llm_description: 'Search query', form: 'text', required: true, type: 'string' },
|
||||
],
|
||||
labels: ['search'],
|
||||
},
|
||||
})
|
||||
const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockUpdateCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({})
|
||||
const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args),
|
||||
fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args),
|
||||
fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args),
|
||||
fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args),
|
||||
fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args),
|
||||
updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args),
|
||||
removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args),
|
||||
updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args),
|
||||
removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args),
|
||||
deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args),
|
||||
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
|
||||
fetchBuiltInToolCredential: vi.fn().mockResolvedValue({}),
|
||||
fetchBuiltInToolCredentialSchema: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllWorkflowTools: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/drawer', () => ({
|
||||
default: ({ isOpen, children, onClose }: { isOpen: boolean, children: React.ReactNode, onClose: () => void }) => (
|
||||
isOpen
|
||||
? (
|
||||
<div data-testid="drawer">
|
||||
{children}
|
||||
<button data-testid="drawer-close" onClick={onClose}>Close Drawer</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ title, isShow, onConfirm, onCancel }: {
|
||||
title: string
|
||||
content: string
|
||||
isShow: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<span>{title}</span>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
|
||||
LinkExternal02: () => <span data-testid="link-icon" />,
|
||||
Settings01: () => <span data-testid="settings-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiCloseLine: () => <span data-testid="close-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ConfigurationMethodEnum: { predefinedModel: 'predefined-model' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: ({ color }: { color: string }) => <span data-testid={`indicator-${color}`} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src }: { src: string }) => <div data-testid="card-icon" data-src={typeof src === 'string' ? src : 'emoji'} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
|
||||
<div data-testid="org-info">
|
||||
{orgName}
|
||||
{' '}
|
||||
/
|
||||
{' '}
|
||||
{packageName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/title', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
|
||||
default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void, payload: unknown }) => (
|
||||
<div data-testid="edit-custom-modal">
|
||||
<button data-testid="custom-modal-hide" onClick={onHide}>Hide</button>
|
||||
<button data-testid="custom-modal-save" onClick={() => onEdit({ name: 'updated', labels: [] })}>Save</button>
|
||||
<button data-testid="custom-modal-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
default: ({ onCancel, onSaved, onRemove }: { collection: Collection, onCancel: () => void, onSaved: (v: Record<string, unknown>) => void, onRemove: () => void }) => (
|
||||
<div data-testid="config-credential">
|
||||
<button data-testid="cred-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="cred-save" onClick={() => onSaved({ api_key: 'test-key' })}>Save</button>
|
||||
<button data-testid="cred-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-modal">
|
||||
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</button>
|
||||
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button>
|
||||
<button data-testid="wf-modal-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/tool-item', () => ({
|
||||
default: ({ tool }: { tool: { name: string } }) => (
|
||||
<div data-testid={`tool-item-${tool.name}`}>{tool.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: ProviderDetail } = await import('@/app/components/tools/provider/detail')
|
||||
|
||||
const makeCollection = (overrides: Partial<Collection> = {}): Collection => ({
|
||||
id: 'test-collection',
|
||||
name: 'test_collection',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Test collection description', zh_Hans: '测试集合描述' },
|
||||
icon: 'https://example.com/icon.png',
|
||||
label: { en_US: 'Test Collection', zh_Hans: '测试集合' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockOnHide = vi.fn()
|
||||
const mockOnRefreshData = vi.fn()
|
||||
|
||||
describe('Tool Provider Detail Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Built-in Provider', () => {
|
||||
it('renders provider detail with title, author, and description', async () => {
|
||||
const collection = makeCollection()
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Test Collection')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('Dify')
|
||||
expect(screen.getByTestId('description')).toHaveTextContent('Test collection description')
|
||||
})
|
||||
})
|
||||
|
||||
it('loads tool list from API on mount', async () => {
|
||||
const collection = makeCollection()
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test_collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tool-item-tool-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-item-tool-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Set up credentials" button when not authorized and needs auth', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Authorized" button when authorized', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Authorized')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens ConfigCredential when clicking auth button (built-in type)', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('saves credential and refreshes data', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cred-save'))
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test_collection', { api_key: 'test-key' })
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('removes credential and refreshes data', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cred-remove'))
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test_collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model Provider', () => {
|
||||
it('opens model modal when clicking auth button for model type', async () => {
|
||||
const collection = makeCollection({
|
||||
id: 'model-provider-1',
|
||||
type: CollectionType.model,
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowModelModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
currentProvider: expect.objectContaining({ provider: 'model-provider-1' }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Provider', () => {
|
||||
it('fetches custom collection details and shows edit button', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchCustomCollection).toHaveBeenCalledWith('test_collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens edit modal and saves changes', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-modal-save'))
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateCustomCollection).toHaveBeenCalled()
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows delete confirmation and removes collection', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-modal-remove'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test_collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow Provider', () => {
|
||||
it('fetches workflow tool detail and shows "Open in Studio" and "Edit" buttons', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open in Studio')).toBeInTheDocument()
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows workflow tool parameters', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('query')).toBeInTheDocument()
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
expect(screen.getByText('Search query')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes workflow tool through confirmation dialog', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('wf-modal-remove'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drawer Interaction', () => {
|
||||
it('calls onHide when closing the drawer', async () => {
|
||||
const collection = makeCollection()
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('drawer-close'))
|
||||
expect(mockOnHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -11,7 +11,7 @@ import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Item from './index'
|
||||
|
||||
vi.mock('../settings-modal', () => ({
|
||||
default: ({ onSave, onCancel, currentDataset }: { currentDataset: DataSet, onCancel: () => void, onSave: (newDataset: DataSet) => void }) => (
|
||||
default: ({ onSave, onCancel, currentDataset }: any) => (
|
||||
<div>
|
||||
<div>Mock settings modal</div>
|
||||
<button onClick={() => onSave({ ...currentDataset, name: 'Updated dataset' })}>Save changes</button>
|
||||
@ -177,7 +177,7 @@ describe('dataset-config/card-item', () => {
|
||||
expect(screen.getByRole('dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Save changes'))
|
||||
await user.click(screen.getByText('Save changes'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' }))
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
// Mock API services - import for direct manipulation
|
||||
import * as appsService from '@/service/apps'
|
||||
|
||||
import * as exploreService from '@/service/explore'
|
||||
import * as workflowService from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from '../app-card'
|
||||
|
||||
// Import component after mocks
|
||||
import AppCard from './app-card'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
@ -21,11 +24,11 @@ vi.mock('next/navigation', () => ({
|
||||
// Include createContext for components that use it (like Toast)
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('use-context-selector', () => ({
|
||||
createContext: <T,>(defaultValue: T) => React.createContext(defaultValue),
|
||||
createContext: (defaultValue: any) => React.createContext(defaultValue),
|
||||
useContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
useContextSelector: (_context: unknown, selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
useContextSelector: (_context: any, selector: any) => selector({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
@ -48,7 +51,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
// Mock global public store - allow dynamic configuration
|
||||
let mockWebappAuthEnabled = false
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => selector({
|
||||
useGlobalPublicStore: (selector: (s: any) => any) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: { enabled: mockWebappAuthEnabled },
|
||||
branding: { enabled: false },
|
||||
@ -103,11 +106,11 @@ vi.mock('@/utils/time', () => ({
|
||||
|
||||
// Mock dynamic imports
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (importFn: () => Promise<unknown>) => {
|
||||
default: (importFn: () => Promise<any>) => {
|
||||
const fnString = importFn.toString()
|
||||
|
||||
if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) {
|
||||
return function MockEditAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) {
|
||||
return function MockEditAppModal({ show, onHide, onConfirm }: any) {
|
||||
if (!show)
|
||||
return null
|
||||
return React.createElement('div', { 'data-testid': 'edit-app-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-edit-modal' }, 'Close'), React.createElement('button', {
|
||||
@ -125,7 +128,7 @@ vi.mock('next/dynamic', () => ({
|
||||
}
|
||||
}
|
||||
if (fnString.includes('duplicate-modal')) {
|
||||
return function MockDuplicateAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) {
|
||||
return function MockDuplicateAppModal({ show, onHide, onConfirm }: any) {
|
||||
if (!show)
|
||||
return null
|
||||
return React.createElement('div', { 'data-testid': 'duplicate-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-duplicate-modal' }, 'Close'), React.createElement('button', {
|
||||
@ -140,26 +143,26 @@ vi.mock('next/dynamic', () => ({
|
||||
}
|
||||
}
|
||||
if (fnString.includes('switch-app-modal')) {
|
||||
return function MockSwitchAppModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
|
||||
return function MockSwitchAppModal({ show, onClose, onSuccess }: any) {
|
||||
if (!show)
|
||||
return null
|
||||
return React.createElement('div', { 'data-testid': 'switch-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-switch-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'confirm-switch-modal' }, 'Switch'))
|
||||
}
|
||||
}
|
||||
if (fnString.includes('base/confirm')) {
|
||||
return function MockConfirm({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) {
|
||||
return function MockConfirm({ isShow, onCancel, onConfirm }: any) {
|
||||
if (!isShow)
|
||||
return null
|
||||
return React.createElement('div', { 'data-testid': 'confirm-dialog' }, React.createElement('button', { 'onClick': onCancel, 'data-testid': 'cancel-confirm' }, 'Cancel'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-confirm' }, 'Confirm'))
|
||||
}
|
||||
}
|
||||
if (fnString.includes('dsl-export-confirm-modal')) {
|
||||
return function MockDSLExportModal({ onClose, onConfirm }: { onClose?: () => void, onConfirm?: (withSecrets: boolean) => void }) {
|
||||
return function MockDSLExportModal({ onClose, onConfirm }: any) {
|
||||
return React.createElement('div', { 'data-testid': 'dsl-export-modal' }, React.createElement('button', { 'onClick': () => onClose?.(), 'data-testid': 'close-dsl-export' }, 'Close'), React.createElement('button', { 'onClick': () => onConfirm?.(true), 'data-testid': 'confirm-dsl-export' }, 'Export with secrets'), React.createElement('button', { 'onClick': () => onConfirm?.(false), 'data-testid': 'confirm-dsl-export-no-secrets' }, 'Export without secrets'))
|
||||
}
|
||||
}
|
||||
if (fnString.includes('app-access-control')) {
|
||||
return function MockAccessControl({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) {
|
||||
return function MockAccessControl({ onClose, onConfirm }: any) {
|
||||
return React.createElement('div', { 'data-testid': 'access-control-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-access-control' }, 'Close'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-access-control' }, 'Confirm'))
|
||||
}
|
||||
}
|
||||
@ -169,9 +172,7 @@ vi.mock('next/dynamic', () => ({
|
||||
|
||||
// Popover uses @headlessui/react portals - mock for controlled interaction testing
|
||||
vi.mock('@/app/components/base/popover', () => {
|
||||
type PopoverHtmlContent = React.ReactNode | ((state: { open: boolean, onClose: () => void, onClick: () => void }) => React.ReactNode)
|
||||
type MockPopoverProps = { htmlContent: PopoverHtmlContent, btnElement: React.ReactNode, btnClassName?: string | ((open: boolean) => string) }
|
||||
const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => {
|
||||
const MockPopover = ({ htmlContent, btnElement, btnClassName }: any) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : ''
|
||||
return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', {
|
||||
@ -187,13 +188,13 @@ vi.mock('@/app/components/base/popover', () => {
|
||||
|
||||
// Tooltip uses portals - minimal mock preserving popup content as title attribute
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: React.ReactNode }) => React.createElement('div', { title: popupContent }, children),
|
||||
default: ({ children, popupContent }: any) => React.createElement('div', { title: popupContent }, children),
|
||||
}))
|
||||
|
||||
// TagSelector has API dependency (service/tag) - mock for isolated testing
|
||||
vi.mock('@/app/components/base/tag-management/selector', () => ({
|
||||
default: ({ tags }: { tags?: { id: string, name: string }[] }) => {
|
||||
return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: { id: string, name: string }) => React.createElement('span', { key: tag.id }, tag.name)))
|
||||
default: ({ tags }: any) => {
|
||||
return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: any) => React.createElement('span', { key: tag.id }, tag.name)))
|
||||
},
|
||||
}))
|
||||
|
||||
@ -202,7 +203,11 @@ vi.mock('@/app/components/app/type-selector', () => ({
|
||||
AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }),
|
||||
}))
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createMockApp = (overrides: Record<string, any> = {}) => ({
|
||||
id: 'test-app-id',
|
||||
name: 'Test App',
|
||||
description: 'Test app description',
|
||||
@ -224,8 +229,16 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
api_rpm: 60,
|
||||
api_rph: 3600,
|
||||
is_demo: false,
|
||||
model_config: {} as any,
|
||||
app_model_config: {} as any,
|
||||
site: {} as any,
|
||||
api_base_url: 'https://api.example.com',
|
||||
...overrides,
|
||||
} as App)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('AppCard', () => {
|
||||
const mockApp = createMockApp()
|
||||
@ -1158,7 +1171,7 @@ describe('AppCard', () => {
|
||||
(exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('API Error'))
|
||||
|
||||
// Configure mockOpenAsyncWindow to call the callback and trigger error
|
||||
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => {
|
||||
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options: any) => {
|
||||
try {
|
||||
await callback()
|
||||
}
|
||||
@ -1200,7 +1213,7 @@ describe('AppCard', () => {
|
||||
(exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] })
|
||||
|
||||
// Configure mockOpenAsyncWindow to call the callback and trigger error
|
||||
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => {
|
||||
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options: any) => {
|
||||
try {
|
||||
await callback()
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Empty from '../empty'
|
||||
import Empty from './empty'
|
||||
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
@ -21,6 +21,7 @@ describe('Empty', () => {
|
||||
|
||||
it('should display the no apps found message', () => {
|
||||
render(<Empty />)
|
||||
// Use pattern matching for resilient text assertions
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Footer from '../footer'
|
||||
import Footer from './footer'
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
@ -15,6 +15,7 @@ describe('Footer', () => {
|
||||
|
||||
it('should display the community heading', () => {
|
||||
render(<Footer />)
|
||||
// Use pattern matching for resilient text assertions
|
||||
expect(screen.getByText('app.join')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import useAppsQueryState from '../use-apps-query-state'
|
||||
import useAppsQueryState from './use-apps-query-state'
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
@ -15,11 +23,13 @@ const renderWithAdapter = (searchParams = '') => {
|
||||
return { result, onUrlUpdate }
|
||||
}
|
||||
|
||||
// Groups scenarios for useAppsQueryState behavior.
|
||||
describe('useAppsQueryState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers the hook return shape and default values.
|
||||
describe('Initialization', () => {
|
||||
it('should expose query and setQuery when initialized', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
@ -37,6 +47,7 @@ describe('useAppsQueryState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Covers parsing of existing URL search params.
|
||||
describe('Parsing search params', () => {
|
||||
it('should parse tagIDs when URL includes tagIDs', () => {
|
||||
const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3')
|
||||
@ -67,6 +78,7 @@ describe('useAppsQueryState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Covers updates driven by setQuery.
|
||||
describe('Updating query state', () => {
|
||||
it('should update keywords when setQuery receives keywords', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
@ -114,6 +126,7 @@ describe('useAppsQueryState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Covers URL updates triggered by query changes.
|
||||
describe('URL synchronization', () => {
|
||||
it('should sync keywords to URL when keywords change', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
@ -189,6 +202,7 @@ describe('useAppsQueryState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Covers decoding and empty values.
|
||||
describe('Edge cases', () => {
|
||||
it('should treat empty tagIDs as empty list when URL param is empty', () => {
|
||||
const { result } = renderWithAdapter('?tagIDs=')
|
||||
@ -209,6 +223,7 @@ describe('useAppsQueryState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Covers multi-step updates that mimic real usage.
|
||||
describe('Integration scenarios', () => {
|
||||
it('should keep accumulated filters when updates are sequential', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
@ -1,6 +1,15 @@
|
||||
/**
|
||||
* 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 type { Mock } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useDSLDragDrop } from '../use-dsl-drag-drop'
|
||||
import { useDSLDragDrop } from './use-dsl-drag-drop'
|
||||
|
||||
describe('useDSLDragDrop', () => {
|
||||
let container: HTMLDivElement
|
||||
@ -17,6 +26,7 @@ describe('useDSLDragDrop', () => {
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
// Helper to create drag events
|
||||
const createDragEvent = (type: string, files: File[] = []) => {
|
||||
const dataTransfer = {
|
||||
types: files.length > 0 ? ['Files'] : [],
|
||||
@ -40,6 +50,7 @@ describe('useDSLDragDrop', () => {
|
||||
return event
|
||||
}
|
||||
|
||||
// Helper to create a mock file
|
||||
const createMockFile = (name: string) => {
|
||||
return new File(['content'], name, { type: 'application/x-yaml' })
|
||||
}
|
||||
@ -136,12 +147,14 @@ describe('useDSLDragDrop', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// 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,
|
||||
@ -167,12 +180,14 @@ describe('useDSLDragDrop', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// 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,
|
||||
@ -275,12 +290,14 @@ describe('useDSLDragDrop', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// 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)
|
||||
@ -392,12 +409,14 @@ describe('useDSLDragDrop', () => {
|
||||
{ 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)
|
||||
})
|
||||
@ -3,17 +3,21 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
import Apps from '../index'
|
||||
// Import after mocks
|
||||
import Apps from './index'
|
||||
|
||||
// Track mock calls
|
||||
let documentTitleCalls: string[] = []
|
||||
let educationInitCalls: number = 0
|
||||
|
||||
// Mock useDocumentTitle hook
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: (title: string) => {
|
||||
documentTitleCalls.push(title)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useEducationInit hook
|
||||
vi.mock('@/app/education-apply/hooks', () => ({
|
||||
useEducationInit: () => {
|
||||
educationInitCalls++
|
||||
@ -29,7 +33,8 @@ vi.mock('@/hooks/use-import-dsl', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../list', () => ({
|
||||
// Mock List component
|
||||
vi.mock('./list', () => ({
|
||||
default: () => {
|
||||
return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
|
||||
},
|
||||
@ -95,7 +100,10 @@ describe('Apps', () => {
|
||||
it('should render full component tree', () => {
|
||||
renderWithClient(<Apps />)
|
||||
|
||||
// Verify container exists
|
||||
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
|
||||
|
||||
// Verify hooks were called
|
||||
expect(documentTitleCalls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(educationInitCalls).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
@ -1,13 +1,12 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
import List from '../list'
|
||||
// Import after mocks
|
||||
import List from './list'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockReplace = vi.fn()
|
||||
const mockRouter = { replace: mockReplace }
|
||||
vi.mock('next/navigation', () => ({
|
||||
@ -15,6 +14,7 @@ vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(''),
|
||||
}))
|
||||
|
||||
// Mock app context
|
||||
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
|
||||
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
@ -24,6 +24,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock global public store
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
@ -32,28 +33,41 @@ vi.mock('@/context/global-public-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock custom hooks - allow dynamic query state
|
||||
const mockSetQuery = vi.fn()
|
||||
const mockQueryState = {
|
||||
tagIDs: [] as string[],
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
}
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
vi.mock('./hooks/use-apps-query-state', () => ({
|
||||
default: () => ({
|
||||
query: mockQueryState,
|
||||
setQuery: mockSetQuery,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Store callback for testing DSL file drop
|
||||
let mockOnDSLFileDropped: ((file: File) => void) | null = null
|
||||
let mockDragging = false
|
||||
vi.mock('../hooks/use-dsl-drag-drop', () => ({
|
||||
vi.mock('./hooks/use-dsl-drag-drop', () => ({
|
||||
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
|
||||
mockOnDSLFileDropped = onDSLFileDropped
|
||||
return { dragging: mockDragging }
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSetActiveTab = vi.fn()
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryState: () => ['all', mockSetActiveTab],
|
||||
parseAsString: {
|
||||
withDefault: () => ({
|
||||
withOptions: () => ({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock service hooks - use object for mutable state (vi.mock is hoisted)
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
|
||||
@ -110,20 +124,47 @@ vi.mock('@/service/use-apps', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Use real tag store - global zustand mock will auto-reset between tests
|
||||
|
||||
// Mock tag service to avoid API calls in TagFilter
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
|
||||
}))
|
||||
|
||||
// Store TagFilter onChange callback for testing
|
||||
let mockTagFilterOnChange: ((value: string[]) => void) | null = null
|
||||
vi.mock('@/app/components/base/tag-management/filter', () => ({
|
||||
default: ({ onChange }: { onChange: (value: string[]) => void }) => {
|
||||
mockTagFilterOnChange = onChange
|
||||
return React.createElement('div', { 'data-testid': 'tag-filter' }, 'common.tag.placeholder')
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock config
|
||||
vi.mock('@/config', () => ({
|
||||
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
|
||||
}))
|
||||
|
||||
// Mock pay hook
|
||||
vi.mock('@/hooks/use-pay', () => ({
|
||||
CheckModal: () => null,
|
||||
}))
|
||||
|
||||
// Mock ahooks - useMount only executes once on mount, not on fn change
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounceFn: (fn: () => void) => ({ run: fn }),
|
||||
useMount: (fn: () => void) => {
|
||||
const fnRef = React.useRef(fn)
|
||||
fnRef.current = fn
|
||||
React.useEffect(() => {
|
||||
fnRef.current()
|
||||
}, [])
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock dynamic imports
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (importFn: () => Promise<unknown>) => {
|
||||
default: (importFn: () => Promise<any>) => {
|
||||
const fnString = importFn.toString()
|
||||
|
||||
if (fnString.includes('tag-management')) {
|
||||
@ -132,7 +173,7 @@ vi.mock('next/dynamic', () => ({
|
||||
}
|
||||
}
|
||||
if (fnString.includes('create-from-dsl-modal')) {
|
||||
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
|
||||
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'))
|
||||
@ -142,34 +183,41 @@ vi.mock('next/dynamic', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../app-card', () => ({
|
||||
default: ({ app }: { app: { id: string, name: string } }) => {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
vi.mock('./app-card', () => ({
|
||||
default: ({ app }: any) => {
|
||||
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../new-app-card', () => ({
|
||||
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
|
||||
vi.mock('./new-app-card', () => ({
|
||||
default: React.forwardRef((_props: any, _ref: any) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../empty', () => ({
|
||||
vi.mock('./empty', () => ({
|
||||
default: () => {
|
||||
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../footer', () => ({
|
||||
vi.mock('./footer', () => ({
|
||||
default: () => {
|
||||
return React.createElement('footer', { 'data-testid': 'footer', 'role': 'contentinfo' }, 'Footer')
|
||||
},
|
||||
}))
|
||||
|
||||
// Store IntersectionObserver callback
|
||||
let intersectionCallback: IntersectionObserverCallback | null = null
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
|
||||
// Mock IntersectionObserver
|
||||
beforeAll(() => {
|
||||
globalThis.IntersectionObserver = class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
@ -186,21 +234,10 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
// Render helper wrapping with NuqsTestingAdapter
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const renderList = (searchParams = '') => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
return render(<List />, { wrapper })
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onUrlUpdate.mockClear()
|
||||
// Set up tag store state
|
||||
useTagStore.setState({
|
||||
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
|
||||
showTagManagementModal: false,
|
||||
@ -209,6 +246,7 @@ describe('List', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockDragging = false
|
||||
mockOnDSLFileDropped = null
|
||||
mockTagFilterOnChange = null
|
||||
mockServiceState.error = null
|
||||
mockServiceState.hasNextPage = false
|
||||
mockServiceState.isLoading = false
|
||||
@ -222,12 +260,13 @@ describe('List', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
// Tab slider renders app type tabs
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
@ -238,74 +277,71 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should render search input', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
// Input component renders a searchbox
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
// Tag filter renders with placeholder text
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
renderList()
|
||||
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', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render drop DSL hint for editors', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update URL when workflow tab is clicked', async () => {
|
||||
renderList()
|
||||
it('should call setActiveTab when tab is clicked', () => {
|
||||
render(<List />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update URL when all tab is clicked', async () => {
|
||||
renderList('?category=workflow')
|
||||
it('should call setActiveTab for all tab', () => {
|
||||
render(<List />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
// nuqs removes the default value ('all') from URL params
|
||||
expect(lastCall.searchParams.has('category')).toBe(false)
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith('all')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should render search input field', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search input change', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
@ -313,36 +349,55 @@ describe('List', () => {
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle search input interaction', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search clear button click', () => {
|
||||
// Set initial keywords to make clear button visible
|
||||
mockQueryState.keywords = 'existing search'
|
||||
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
// Find and click clear button (Input component uses .group class for clear icon container)
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton).toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
// handleKeywordsChange should be called with empty string
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Filter', () => {
|
||||
it('should render tag filter component', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter with placeholder', () => {
|
||||
render(<List />)
|
||||
|
||||
// Tag filter is rendered
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
// Checkbox component uses data-testid="checkbox-{id}"
|
||||
// CheckboxWithLabel doesn't pass testId, so id is undefined
|
||||
const checkbox = screen.getByTestId('checkbox-undefined')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
@ -354,7 +409,7 @@ describe('List', () => {
|
||||
it('should not render new app card for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
})
|
||||
@ -362,7 +417,7 @@ describe('List', () => {
|
||||
it('should not render drop DSL hint for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
|
||||
})
|
||||
@ -372,7 +427,7 @@ describe('List', () => {
|
||||
it('should redirect dataset operators to datasets page', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
|
||||
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
@ -382,7 +437,7 @@ describe('List', () => {
|
||||
it('should call refetch when refresh key is set in localStorage', () => {
|
||||
localStorage.setItem('needRefreshAppList', '1')
|
||||
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
|
||||
@ -391,30 +446,22 @@ describe('List', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
const { rerender } = render(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
rerender(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
@ -424,20 +471,14 @@ describe('List', () => {
|
||||
|
||||
describe('Dragging State', () => {
|
||||
it('should show drop hint when DSL feature is enabled for editors', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dragging state overlay when dragging', () => {
|
||||
mockDragging = true
|
||||
const { container } = renderList()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
@ -447,8 +488,8 @@ describe('List', () => {
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update URL for each app type tab click', async () => {
|
||||
renderList()
|
||||
it('should call setActiveTab for each app type', () => {
|
||||
render(<List />)
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
@ -458,26 +499,45 @@ describe('List', () => {
|
||||
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
|
||||
]
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
onUrlUpdate.mockClear()
|
||||
appTypeTexts.forEach(({ mode, text }) => {
|
||||
fireEvent.click(screen.getByText(text))
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(mode)
|
||||
}
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith(mode)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search and Filter Integration', () => {
|
||||
it('should display search input with correct attributes', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveAttribute('value', '')
|
||||
})
|
||||
|
||||
it('should have tag filter component', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('common.tag.placeholder')).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', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app names correctly', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
@ -486,27 +546,59 @@ describe('List', () => {
|
||||
|
||||
describe('Footer Visibility', () => {
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Additional Coverage Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Additional Coverage', () => {
|
||||
it('should render dragging state overlay when dragging', () => {
|
||||
mockDragging = true
|
||||
const { container } = render(<List />)
|
||||
|
||||
// Component should render successfully with dragging state
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle app mode filter in query params', () => {
|
||||
render(<List />)
|
||||
|
||||
const workflowTab = screen.getByText('app.types.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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DSL File Drop', () => {
|
||||
it('should handle DSL file drop and show modal', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
// Simulate DSL file drop via the callback
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
})
|
||||
|
||||
// Modal should be shown
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close DSL modal when onClose is called', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
// Open modal via DSL file drop
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
@ -515,14 +607,16 @@ describe('List', () => {
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close DSL modal and refetch when onSuccess is called', () => {
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
// Open modal via DSL file drop
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
@ -531,18 +625,67 @@ describe('List', () => {
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
// Click success button
|
||||
fireEvent.click(screen.getByTestId('success-dsl-modal'))
|
||||
|
||||
// Modal should be closed and refetch should be called
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Filter Change', () => {
|
||||
it('should handle tag filter value change', () => {
|
||||
vi.useFakeTimers()
|
||||
render(<List />)
|
||||
|
||||
// TagFilter component is rendered
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
|
||||
// Trigger tag filter change via captured callback
|
||||
act(() => {
|
||||
if (mockTagFilterOnChange)
|
||||
mockTagFilterOnChange(['tag-1', 'tag-2'])
|
||||
})
|
||||
|
||||
// Advance timers to trigger debounced setTagIDs
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
// setQuery should have been called with updated tagIDs
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should handle empty tag filter selection', () => {
|
||||
vi.useFakeTimers()
|
||||
render(<List />)
|
||||
|
||||
// Trigger tag filter change with empty array
|
||||
act(() => {
|
||||
if (mockTagFilterOnChange)
|
||||
mockTagFilterOnChange([])
|
||||
})
|
||||
|
||||
// Advance timers
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Infinite Scroll', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
// Simulate intersection
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
@ -557,8 +700,9 @@ describe('List', () => {
|
||||
|
||||
it('should not call fetchNextPage when not intersecting', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
// Simulate non-intersection
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
@ -574,7 +718,7 @@ describe('List', () => {
|
||||
it('should not call fetchNextPage when loading', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
mockServiceState.isLoading = true
|
||||
renderList()
|
||||
render(<List />)
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
@ -592,8 +736,11 @@ describe('List', () => {
|
||||
describe('Error State', () => {
|
||||
it('should handle error state in useEffect', () => {
|
||||
mockServiceState.error = new Error('Test error')
|
||||
const { container } = renderList()
|
||||
const { container } = render(<List />)
|
||||
|
||||
// Component should still render
|
||||
expect(container).toBeInTheDocument()
|
||||
// Disconnect should be called when there's an error (cleanup)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,10 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
import CreateAppCard from '../new-app-card'
|
||||
// Import after mocks
|
||||
import CreateAppCard from './new-app-card'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockReplace = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
@ -11,6 +13,7 @@ vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// Mock provider context
|
||||
const mockOnPlanInfoChanged = vi.fn()
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
@ -18,35 +21,37 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock next/dynamic to immediately resolve components
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (importFn: () => Promise<{ default: React.ComponentType }>) => {
|
||||
default: (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 }: Record<string, unknown>) {
|
||||
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 as () => void, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate as () => void, 'data-testid': 'to-template-modal' }, 'To Template'))
|
||||
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 }: Record<string, unknown>) {
|
||||
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 as () => void, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank as () => void, 'data-testid': 'to-blank-modal' }, 'To Blank'))
|
||||
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 }: Record<string, unknown>) {
|
||||
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 as () => void, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-dsl-modal' }, 'Success'))
|
||||
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
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
|
||||
CreateFromDSLModalTab: {
|
||||
FROM_URL: 'from-url',
|
||||
@ -63,6 +68,7 @@ describe('CreateAppCard', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
// Use pattern matching for resilient text assertions
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -239,15 +245,19 @@ describe('CreateAppCard', () => {
|
||||
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()
|
||||
@ -257,6 +267,7 @@ describe('CreateAppCard', () => {
|
||||
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()
|
||||
@ -1,260 +0,0 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { AgentLogDetailResponse } from '@/models/log'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { fetchAgentLogDetail } from '@/service/log'
|
||||
import AgentLogDetail from './detail'
|
||||
|
||||
vi.mock('@/service/log', () => ({
|
||||
fetchAgentLogDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/status', () => ({
|
||||
default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => (
|
||||
<div data-testid="status-panel" data-status={String(status)} data-time={String(time)} data-tokens={String(tokens)}>{error ? <span>{String(error)}</span> : null}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
|
||||
<div data-testid="code-editor">
|
||||
{title}
|
||||
{typeof value === 'string' ? value : JSON.stringify(value)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({ formatTime: (ts: number, fmt: string) => `${ts}-${fmt}` }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: () => <div data-testid="block-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
|
||||
ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />,
|
||||
}))
|
||||
|
||||
const createMockLog = (overrides: Partial<IChatItem> = {}): IChatItem => ({
|
||||
id: 'msg-id',
|
||||
content: 'output content',
|
||||
isAnswer: false,
|
||||
conversationId: 'conv-id',
|
||||
input: 'user input',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockResponse = (overrides: Partial<AgentLogDetailResponse> = {}): AgentLogDetailResponse => ({
|
||||
meta: {
|
||||
status: 'succeeded',
|
||||
executor: 'User',
|
||||
start_time: '2023-01-01',
|
||||
elapsed_time: 1.0,
|
||||
total_tokens: 100,
|
||||
agent_mode: 'function_call',
|
||||
iterations: 1,
|
||||
},
|
||||
iterations: [
|
||||
{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: '',
|
||||
tokens: 0,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }],
|
||||
},
|
||||
],
|
||||
files: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AgentLogDetail', () => {
|
||||
const notify = vi.fn()
|
||||
|
||||
const renderComponent = (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => {
|
||||
const defaultProps: ComponentProps<typeof AgentLogDetail> = {
|
||||
conversationID: 'conv-id',
|
||||
messageID: 'msg-id',
|
||||
log: createMockLog(),
|
||||
}
|
||||
return render(
|
||||
<ToastContext.Provider value={{ notify, close: vi.fn() } as ComponentProps<typeof ToastContext.Provider>['value']}>
|
||||
<AgentLogDetail {...defaultProps} {...props} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
const renderAndWaitForData = async (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => {
|
||||
const result = renderComponent(props)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show loading indicator while fetching data', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display result panel after data loads', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
|
||||
|
||||
await renderAndWaitForData()
|
||||
|
||||
expect(screen.getByText(/runLog.detail/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/runLog.tracing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call fetchAgentLogDetail with correct params', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
|
||||
|
||||
await renderAndWaitForData()
|
||||
|
||||
expect(fetchAgentLogDetail).toHaveBeenCalledWith({
|
||||
appID: 'app-id',
|
||||
params: {
|
||||
conversation_id: 'conv-id',
|
||||
message_id: 'msg-id',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should default to DETAIL tab when activeTab is not provided', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
|
||||
|
||||
await renderAndWaitForData()
|
||||
|
||||
const detailTab = screen.getByText(/runLog.detail/i)
|
||||
expect(detailTab.getAttribute('data-active')).toBe('true')
|
||||
})
|
||||
|
||||
it('should show TRACING tab when activeTab is TRACING', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
|
||||
|
||||
await renderAndWaitForData({ activeTab: 'TRACING' })
|
||||
|
||||
const tracingTab = screen.getByText(/runLog.tracing/i)
|
||||
expect(tracingTab.getAttribute('data-active')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should switch to TRACING tab when clicked', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
|
||||
|
||||
await renderAndWaitForData()
|
||||
|
||||
fireEvent.click(screen.getByText(/runLog.tracing/i))
|
||||
|
||||
await waitFor(() => {
|
||||
const tracingTab = screen.getByText(/runLog.tracing/i)
|
||||
expect(tracingTab.getAttribute('data-active')).toBe('true')
|
||||
})
|
||||
|
||||
const detailTab = screen.getByText(/runLog.detail/i)
|
||||
expect(detailTab.getAttribute('data-active')).toBe('false')
|
||||
})
|
||||
|
||||
it('should switch back to DETAIL tab after switching to TRACING', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
|
||||
|
||||
await renderAndWaitForData()
|
||||
|
||||
fireEvent.click(screen.getByText(/runLog.tracing/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/runLog.tracing/i).getAttribute('data-active')).toBe('true')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/runLog.detail/i))
|
||||
|
||||
await waitFor(() => {
|
||||
const detailTab = screen.getByText(/runLog.detail/i)
|
||||
expect(detailTab.getAttribute('data-active')).toBe('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should notify on API error', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockRejectedValue(new Error('API Error'))
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Error: API Error',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop loading after API error', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockRejectedValue(new Error('Network failure'))
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle response with empty iterations', async () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockResolvedValue(
|
||||
createMockResponse({ iterations: [] }),
|
||||
)
|
||||
|
||||
await renderAndWaitForData()
|
||||
})
|
||||
|
||||
it('should handle response with multiple iterations and duplicate tools', async () => {
|
||||
const response = createMockResponse({
|
||||
iterations: [
|
||||
{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: '',
|
||||
tokens: 0,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [
|
||||
{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } },
|
||||
{ tool_name: 'tool2', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 2' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: '',
|
||||
tokens: 0,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [
|
||||
{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.mocked(fetchAgentLogDetail).mockResolvedValue(response)
|
||||
|
||||
await renderAndWaitForData()
|
||||
|
||||
expect(screen.getByText(/runLog.detail/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -89,7 +89,6 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({
|
||||
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary',
|
||||
currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-text-secondary',
|
||||
)}
|
||||
data-active={currentTab === 'DETAIL'}
|
||||
onClick={() => switchTab('DETAIL')}
|
||||
>
|
||||
{t('detail', { ns: 'runLog' })}
|
||||
@ -99,7 +98,6 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({
|
||||
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary',
|
||||
currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-text-secondary',
|
||||
)}
|
||||
data-active={currentTab === 'TRACING'}
|
||||
onClick={() => switchTab('TRACING')}
|
||||
>
|
||||
{t('tracing', { ns: 'runLog' })}
|
||||
|
||||
@ -1,142 +0,0 @@
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { fetchAgentLogDetail } from '@/service/log'
|
||||
import AgentLogModal from './index'
|
||||
|
||||
vi.mock('@/service/log', () => ({
|
||||
fetchAgentLogDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/status', () => ({
|
||||
default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => (
|
||||
<div data-testid="status-panel" data-status={String(status)} data-time={String(time)} data-tokens={String(tokens)}>{error ? <span>{String(error)}</span> : null}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
|
||||
<div data-testid="code-editor">
|
||||
{title}
|
||||
{typeof value === 'string' ? value : JSON.stringify(value)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({ formatTime: (ts: number, fmt: string) => `${ts}-${fmt}` }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: () => <div data-testid="block-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
|
||||
ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />,
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useClickAway: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockLog = {
|
||||
id: 'msg-id',
|
||||
conversationId: 'conv-id',
|
||||
content: 'content',
|
||||
isAnswer: false,
|
||||
input: 'test input',
|
||||
} as IChatItem
|
||||
|
||||
const mockProps = {
|
||||
currentLogItem: mockLog,
|
||||
width: 1000,
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
describe('AgentLogModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(fetchAgentLogDetail).mockResolvedValue({
|
||||
meta: {
|
||||
status: 'succeeded',
|
||||
executor: 'User',
|
||||
start_time: '2023-01-01',
|
||||
elapsed_time: 1.0,
|
||||
total_tokens: 100,
|
||||
agent_mode: 'function_call',
|
||||
iterations: 1,
|
||||
},
|
||||
iterations: [{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: '',
|
||||
tokens: 0,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }],
|
||||
}],
|
||||
files: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null if no currentLogItem', () => {
|
||||
const { container } = render(<AgentLogModal {...mockProps} currentLogItem={undefined} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null if no conversationId', () => {
|
||||
const { container } = render(<AgentLogModal {...mockProps} currentLogItem={{ id: '1' } as unknown as IChatItem} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render correctly when log item is provided', async () => {
|
||||
render(
|
||||
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
|
||||
<AgentLogModal {...mockProps} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/runLog.detail/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onCancel when close button is clicked', () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
render(
|
||||
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
|
||||
<AgentLogModal {...mockProps} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
|
||||
const closeBtn = screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling!
|
||||
fireEvent.click(closeBtn)
|
||||
|
||||
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when clicking away', () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
let clickAwayHandler!: (event: Event) => void
|
||||
vi.mocked(useClickAway).mockImplementation((callback) => {
|
||||
clickAwayHandler = callback
|
||||
})
|
||||
|
||||
render(
|
||||
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
|
||||
<AgentLogModal {...mockProps} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
clickAwayHandler(new Event('click'))
|
||||
|
||||
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,57 +0,0 @@
|
||||
import type { AgentIteration } from '@/models/log'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Iteration from './iteration'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
|
||||
<div data-testid="code-editor">
|
||||
<div data-testid="code-editor-title">{title}</div>
|
||||
<div data-testid="code-editor-value">{JSON.stringify(value)}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: () => <div data-testid="block-icon" />,
|
||||
}))
|
||||
|
||||
const mockIterationInfo: AgentIteration = {
|
||||
created_at: '2023-01-01',
|
||||
files: [],
|
||||
thought: 'Test thought',
|
||||
tokens: 100,
|
||||
tool_calls: [
|
||||
{
|
||||
status: 'success',
|
||||
tool_name: 'test_tool',
|
||||
tool_label: { en: 'Test Tool' },
|
||||
tool_icon: null,
|
||||
},
|
||||
],
|
||||
tool_raw: {
|
||||
inputs: '{}',
|
||||
outputs: 'test output',
|
||||
},
|
||||
}
|
||||
|
||||
describe('Iteration', () => {
|
||||
it('should render final processing when isFinal is true', () => {
|
||||
render(<Iteration iterationInfo={mockIterationInfo} isFinal={true} index={1} />)
|
||||
|
||||
expect(screen.getByText(/appLog.agentLogDetail.finalProcessing/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/appLog.agentLogDetail.iteration/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render iteration index when isFinal is false', () => {
|
||||
render(<Iteration iterationInfo={mockIterationInfo} isFinal={false} index={2} />)
|
||||
|
||||
expect(screen.getByText(/APPLOG.AGENTLOGDETAIL.ITERATION 2/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/appLog.agentLogDetail.finalProcessing/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render LLM tool call and subsequent tool calls', () => {
|
||||
render(<Iteration iterationInfo={mockIterationInfo} isFinal={false} index={1} />)
|
||||
expect(screen.getByTitle('LLM')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Tool')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,85 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import ResultPanel from './result'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
|
||||
<div data-testid="code-editor">
|
||||
<div data-testid="code-editor-title">{title}</div>
|
||||
<div data-testid="code-editor-value">{JSON.stringify(value)}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/status', () => ({
|
||||
default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => (
|
||||
<div data-testid="status-panel">
|
||||
<span>{status}</span>
|
||||
<span>{time}</span>
|
||||
<span>{tokens}</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((ts, _format) => `formatted-${ts}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockProps = {
|
||||
status: 'succeeded',
|
||||
elapsed_time: 1.23456,
|
||||
total_tokens: 150,
|
||||
error: '',
|
||||
inputs: { query: 'input' },
|
||||
outputs: { answer: 'output' },
|
||||
created_by: 'User Name',
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
agentMode: 'function_call',
|
||||
tools: ['tool1', 'tool2'],
|
||||
iterations: 3,
|
||||
}
|
||||
|
||||
describe('ResultPanel', () => {
|
||||
it('should render status panel and code editors', () => {
|
||||
render(<ResultPanel {...mockProps} />)
|
||||
|
||||
expect(screen.getByTestId('status-panel')).toBeInTheDocument()
|
||||
|
||||
const editors = screen.getAllByTestId('code-editor')
|
||||
expect(editors).toHaveLength(2)
|
||||
|
||||
expect(screen.getByText('INPUT')).toBeInTheDocument()
|
||||
expect(screen.getByText('OUTPUT')).toBeInTheDocument()
|
||||
expect(screen.getByText(JSON.stringify(mockProps.inputs))).toBeInTheDocument()
|
||||
expect(screen.getByText(JSON.stringify(mockProps.outputs))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct metadata', () => {
|
||||
render(<ResultPanel {...mockProps} />)
|
||||
|
||||
expect(screen.getByText('User Name')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.235s')).toBeInTheDocument() // toFixed(3)
|
||||
expect(screen.getByText('150 Tokens')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument()
|
||||
expect(screen.getByText('tool1, tool2')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
|
||||
// Check formatted time
|
||||
expect(screen.getByText(/formatted-/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing created_by and tools', () => {
|
||||
render(<ResultPanel {...mockProps} created_by={undefined} tools={[]} />)
|
||||
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Null')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display ReACT mode correctly', () => {
|
||||
render(<ResultPanel {...mockProps} agentMode="react" />)
|
||||
expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,126 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import ToolCallItem from './tool-call'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
|
||||
<div data-testid="code-editor">
|
||||
<div data-testid="code-editor-title">{title}</div>
|
||||
<div data-testid="code-editor-value">{JSON.stringify(value)}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: ({ type }: { type: BlockEnum }) => <div data-testid="block-icon" data-type={type} />,
|
||||
}))
|
||||
|
||||
const mockToolCall = {
|
||||
status: 'success',
|
||||
error: null,
|
||||
tool_name: 'test_tool',
|
||||
tool_label: { en: 'Test Tool Label' },
|
||||
tool_icon: 'icon',
|
||||
time_cost: 1.5,
|
||||
tool_input: { query: 'hello' },
|
||||
tool_output: { result: 'world' },
|
||||
}
|
||||
|
||||
describe('ToolCallItem', () => {
|
||||
it('should render tool name correctly for LLM', () => {
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM={true} />)
|
||||
expect(screen.getByText('LLM')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('block-icon')).toHaveAttribute('data-type', BlockEnum.LLM)
|
||||
})
|
||||
|
||||
it('should render tool name from label for non-LLM', () => {
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />)
|
||||
expect(screen.getByText('Test Tool Label')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('block-icon')).toHaveAttribute('data-type', BlockEnum.Tool)
|
||||
})
|
||||
|
||||
it('should format time correctly', () => {
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />)
|
||||
expect(screen.getByText('1.500 s')).toBeInTheDocument()
|
||||
|
||||
// Test ms format
|
||||
render(<ToolCallItem toolCall={{ ...mockToolCall, time_cost: 0.5 }} isLLM={false} />)
|
||||
expect(screen.getByText('500.000 ms')).toBeInTheDocument()
|
||||
|
||||
// Test minute format
|
||||
render(<ToolCallItem toolCall={{ ...mockToolCall, time_cost: 65 }} isLLM={false} />)
|
||||
expect(screen.getByText('1 m 5.000 s')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format token count correctly', () => {
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM={true} tokens={1200} />)
|
||||
expect(screen.getByText('1.2K tokens')).toBeInTheDocument()
|
||||
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM={true} tokens={800} />)
|
||||
expect(screen.getByText('800 tokens')).toBeInTheDocument()
|
||||
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM={true} tokens={1200000} />)
|
||||
expect(screen.getByText('1.2M tokens')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle collapse/expand', () => {
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />)
|
||||
|
||||
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(/Test Tool Label/i))
|
||||
expect(screen.getAllByTestId('code-editor')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should display error message when status is error', () => {
|
||||
const errorToolCall = {
|
||||
...mockToolCall,
|
||||
status: 'error',
|
||||
error: 'Something went wrong',
|
||||
}
|
||||
render(<ToolCallItem toolCall={errorToolCall} isLLM={false} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/Test Tool Label/i))
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display LLM specific fields when expanded', () => {
|
||||
render(
|
||||
<ToolCallItem
|
||||
toolCall={mockToolCall}
|
||||
isLLM={true}
|
||||
observation="test observation"
|
||||
finalAnswer="test final answer"
|
||||
isFinal={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('LLM'))
|
||||
|
||||
const titles = screen.getAllByTestId('code-editor-title')
|
||||
const titleTexts = titles.map(t => t.textContent)
|
||||
|
||||
expect(titleTexts).toContain('INPUT')
|
||||
expect(titleTexts).toContain('OUTPUT')
|
||||
expect(titleTexts).toContain('OBSERVATION')
|
||||
expect(titleTexts).toContain('FINAL ANSWER')
|
||||
})
|
||||
|
||||
it('should display THOUGHT instead of FINAL ANSWER when isFinal is false', () => {
|
||||
render(
|
||||
<ToolCallItem
|
||||
toolCall={mockToolCall}
|
||||
isLLM={true}
|
||||
observation="test observation"
|
||||
finalAnswer="test thought"
|
||||
isFinal={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('LLM'))
|
||||
expect(screen.getByText('THOUGHT')).toBeInTheDocument()
|
||||
expect(screen.queryByText('FINAL ANSWER')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,50 +0,0 @@
|
||||
import type { AgentIteration } from '@/models/log'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TracingPanel from './tracing'
|
||||
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: () => <div data-testid="block-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
|
||||
ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
|
||||
<div data-testid="code-editor">
|
||||
{title}
|
||||
{typeof value === 'string' ? value : JSON.stringify(value)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createIteration = (thought: string, tokens: number): AgentIteration => ({
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought,
|
||||
tokens,
|
||||
tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }],
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
})
|
||||
|
||||
const mockList: AgentIteration[] = [
|
||||
createIteration('Thought 1', 10),
|
||||
createIteration('Thought 2', 20),
|
||||
createIteration('Thought 3', 30),
|
||||
]
|
||||
|
||||
describe('TracingPanel', () => {
|
||||
it('should render all iterations in the list', () => {
|
||||
render(<TracingPanel list={mockList} />)
|
||||
|
||||
expect(screen.getByText(/finalProcessing/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/ITERATION/i).length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render empty list correctly', () => {
|
||||
const { container } = render(<TracingPanel list={[]} />)
|
||||
expect(container.querySelector('.bg-background-section')?.children.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@ -1,34 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import AnswerIcon from '.'
|
||||
|
||||
describe('AnswerIcon', () => {
|
||||
it('renders default emoji when no icon or image is provided', () => {
|
||||
const { container } = render(<AnswerIcon />)
|
||||
const emojiElement = container.querySelector('em-emoji')
|
||||
expect(emojiElement).toBeInTheDocument()
|
||||
expect(emojiElement).toHaveAttribute('id', '🤖')
|
||||
})
|
||||
|
||||
it('renders with custom emoji when icon is provided', () => {
|
||||
const { container } = render(<AnswerIcon icon="smile" />)
|
||||
const emojiElement = container.querySelector('em-emoji')
|
||||
expect(emojiElement).toBeInTheDocument()
|
||||
expect(emojiElement).toHaveAttribute('id', 'smile')
|
||||
})
|
||||
it('renders image when iconType is image and imageUrl is provided', () => {
|
||||
render(<AnswerIcon iconType="image" imageUrl="test-image.jpg" />)
|
||||
const imgElement = screen.getByAltText('answer icon')
|
||||
expect(imgElement).toBeInTheDocument()
|
||||
expect(imgElement).toHaveAttribute('src', 'test-image.jpg')
|
||||
})
|
||||
|
||||
it('applies custom background color', () => {
|
||||
const { container } = render(<AnswerIcon background="#FF5500" />)
|
||||
expect(container.firstChild).toHaveStyle('background: #FF5500')
|
||||
})
|
||||
|
||||
it('uses default background color when no background is provided for non-image icons', () => {
|
||||
const { container } = render(<AnswerIcon />)
|
||||
expect(container.firstChild).toHaveStyle('background: #D5F5F6')
|
||||
})
|
||||
})
|
||||
@ -162,10 +162,8 @@ describe('useEmbeddedChatbot', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1')
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
|
||||
expect(result.current.conversationList).toEqual(listData.data)
|
||||
})
|
||||
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
|
||||
expect(result.current.conversationList).toEqual(listData.data)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,195 +0,0 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CheckboxList from '.'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('checkbox list component', () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
{ label: 'Apple', value: 'apple' },
|
||||
]
|
||||
|
||||
it('renders with title, description and options', () => {
|
||||
render(
|
||||
<CheckboxList
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
options={options}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument()
|
||||
options.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('filters options by label', async () => {
|
||||
render(<CheckboxList options={options} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'app')
|
||||
|
||||
expect(screen.getByText('Apple')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Option 2')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Option 3')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders select-all checkbox', () => {
|
||||
render(<CheckboxList options={options} showSelectAll />)
|
||||
const checkboxes = screen.getByTestId('checkbox-selectAll')
|
||||
expect(checkboxes).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects all options when select-all is clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
showSelectAll
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectAll = screen.getByTestId('checkbox-selectAll')
|
||||
await userEvent.click(selectAll)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple'])
|
||||
})
|
||||
|
||||
it('does not select all options when select-all is clicked when disabled', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={[]}
|
||||
disabled
|
||||
showSelectAll
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectAll = screen.getByTestId('checkbox-selectAll')
|
||||
await userEvent.click(selectAll)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deselects all options when select-all is clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={['option1', 'option2', 'option3', 'apple']}
|
||||
onChange={onChange}
|
||||
showSelectAll
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectAll = screen.getByTestId('checkbox-selectAll')
|
||||
await userEvent.click(selectAll)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('selects select-all when all options are clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={['option1', 'option2', 'option3', 'apple']}
|
||||
onChange={onChange}
|
||||
showSelectAll
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectAll = screen.getByTestId('checkbox-selectAll')
|
||||
expect(selectAll.querySelector('[data-testid="check-icon-selectAll"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides select-all checkbox when searching', async () => {
|
||||
render(<CheckboxList options={options} />)
|
||||
await userEvent.type(screen.getByRole('textbox'), 'app')
|
||||
expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects options when checkbox is clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
showSelectAll={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectOption = screen.getByTestId('checkbox-option1')
|
||||
await userEvent.click(selectOption)
|
||||
expect(onChange).toHaveBeenCalledWith(['option1'])
|
||||
})
|
||||
|
||||
it('deselects options when checkbox is clicked when selected', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={['option1']}
|
||||
onChange={onChange}
|
||||
showSelectAll={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectOption = screen.getByTestId('checkbox-option1')
|
||||
await userEvent.click(selectOption)
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('does not select options when checkbox is clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
disabled
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectOption = screen.getByTestId('checkbox-option1')
|
||||
await userEvent.click(selectOption)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Reset button works', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'ban')
|
||||
await userEvent.click(screen.getByText('common.operation.resetKeywords'))
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
})
|
||||
@ -101,12 +101,12 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col gap-1', containerClassName)}>
|
||||
{label && (
|
||||
<div className="text-text-secondary system-sm-medium">
|
||||
<div className="system-sm-medium text-text-secondary">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-text-tertiary body-xs-regular">
|
||||
<div className="body-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
@ -120,14 +120,13 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
indeterminate={isIndeterminate}
|
||||
onCheck={handleSelectAll}
|
||||
disabled={disabled}
|
||||
id="selectAll"
|
||||
/>
|
||||
)}
|
||||
{!searchQuery
|
||||
? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{title && (
|
||||
<span className="truncate leading-5 text-text-secondary system-xs-semibold-uppercase">
|
||||
<span className="system-xs-semibold-uppercase truncate leading-5 text-text-secondary">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
@ -139,7 +138,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex-1 leading-6 text-text-secondary system-sm-medium-uppercase">
|
||||
<div className="system-sm-medium-uppercase flex-1 leading-6 text-text-secondary">
|
||||
{
|
||||
filteredOptions.length > 0
|
||||
? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title })
|
||||
@ -169,7 +168,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Image alt="search menu" src={SearchMenu} width={32} />
|
||||
<span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
|
||||
<span className="system-sm-regular text-text-secondary">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
|
||||
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
)
|
||||
@ -199,10 +198,9 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
handleToggleOption(option.value)
|
||||
}}
|
||||
disabled={option.disabled || disabled}
|
||||
id={option.value}
|
||||
/>
|
||||
<div
|
||||
className="flex-1 truncate text-text-secondary system-sm-medium"
|
||||
className="system-sm-medium flex-1 truncate text-text-secondary"
|
||||
title={option.label}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Confirm from '.'
|
||||
|
||||
vi.mock('react-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createPortal: (children: React.ReactNode) => children,
|
||||
}
|
||||
})
|
||||
|
||||
const onCancel = vi.fn()
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
describe('Confirm Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders confirm correctly', () => {
|
||||
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
expect(screen.getByText('test title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render on isShow false', () => {
|
||||
const { container } = render(<Confirm isShow={false} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('hides after delay when isShow changes to false', () => {
|
||||
vi.useFakeTimers()
|
||||
const { rerender } = render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
expect(screen.getByText('test title')).toBeInTheDocument()
|
||||
|
||||
rerender(<Confirm isShow={false} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
expect(screen.queryByText('test title')).not.toBeInTheDocument()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders content when provided', () => {
|
||||
render(<Confirm isShow={true} title="title" content="some description" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
expect(screen.getByText('some description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('showCancel prop works', () => {
|
||||
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} showCancel={false} />)
|
||||
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('showConfirm prop works', () => {
|
||||
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} showConfirm={false} />)
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.confirm' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom confirm and cancel text', () => {
|
||||
render(<Confirm isShow={true} title="title" confirmText="Yes" cancelText="No" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables confirm button when isDisabled is true', () => {
|
||||
render(<Confirm isShow={true} title="title" isDisabled={true} onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('clickAway is handled properly', () => {
|
||||
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
const overlay = screen.getByTestId('confirm-overlay') as HTMLElement
|
||||
expect(overlay).toBeTruthy()
|
||||
fireEvent.mouseDown(overlay)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('overlay click stops propagation', () => {
|
||||
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
const overlay = screen.getByTestId('confirm-overlay') as HTMLElement
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
|
||||
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
|
||||
overlay.dispatchEvent(clickEvent)
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close on click away when maskClosable is false', () => {
|
||||
render(<Confirm isShow={true} title="test title" maskClosable={false} onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
const overlay = screen.getByTestId('confirm-overlay') as HTMLElement
|
||||
fireEvent.mouseDown(overlay)
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('escape keyboard event works', () => {
|
||||
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Enter keyboard event works', () => {
|
||||
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -101,7 +101,6 @@ function Confirm({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
data-testid="confirm-overlay"
|
||||
>
|
||||
<div ref={dialogRef} className="relative w-full max-w-[480px] overflow-hidden">
|
||||
<div className="shadows-shadow-lg flex max-w-full flex-col items-start rounded-2xl border-[0.5px] border-solid border-components-panel-border bg-components-panel-bg">
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ContentDialog from './index'
|
||||
|
||||
describe('ContentDialog', () => {
|
||||
it('renders children when show is true', async () => {
|
||||
render(
|
||||
<ContentDialog show={true}>
|
||||
<div>Dialog body</div>
|
||||
</ContentDialog>,
|
||||
)
|
||||
|
||||
await screen.findByText('Dialog body')
|
||||
expect(screen.getByText('Dialog body')).toBeInTheDocument()
|
||||
|
||||
const backdrop = document.querySelector('.bg-app-detail-overlay-bg')
|
||||
expect(backdrop).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render children when show is false', () => {
|
||||
render(
|
||||
<ContentDialog show={false}>
|
||||
<div>Hidden content</div>
|
||||
</ContentDialog>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Hidden content')).toBeNull()
|
||||
expect(document.querySelector('.bg-app-detail-overlay-bg')).toBeNull()
|
||||
})
|
||||
|
||||
it('calls onClose when backdrop is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<ContentDialog show={true} onClose={onClose}>
|
||||
<div>Body</div>
|
||||
</ContentDialog>,
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
const backdrop = document.querySelector('.bg-app-detail-overlay-bg') as HTMLElement | null
|
||||
expect(backdrop).toBeTruthy()
|
||||
|
||||
await user.click(backdrop!)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies provided className to the content panel', () => {
|
||||
render(
|
||||
<ContentDialog show={true} className="my-panel-class">
|
||||
<div>Panel content</div>
|
||||
</ContentDialog>,
|
||||
)
|
||||
|
||||
const contentPanel = document.querySelector('.bg-app-detail-bg') as HTMLElement | null
|
||||
expect(contentPanel).toBeTruthy()
|
||||
expect(contentPanel?.className).toContain('my-panel-class')
|
||||
expect(screen.getByText('Panel content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,93 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import CopyFeedback, { CopyFeedbackNew } from '.'
|
||||
|
||||
const mockCopy = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
let mockCopied = false
|
||||
|
||||
vi.mock('foxact/use-clipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copy: mockCopy,
|
||||
reset: mockReset,
|
||||
copied: mockCopied,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CopyFeedback', () => {
|
||||
beforeEach(() => {
|
||||
mockCopied = false
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders the action button with copy icon', () => {
|
||||
render(<CopyFeedback content="test content" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the copied icon when copied is true', () => {
|
||||
mockCopied = true
|
||||
render(<CopyFeedback content="test content" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('calls copy with content when clicked', () => {
|
||||
render(<CopyFeedback content="test content" />)
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button.firstChild as Element)
|
||||
expect(mockCopy).toHaveBeenCalledWith('test content')
|
||||
})
|
||||
|
||||
it('calls reset on mouse leave', () => {
|
||||
render(<CopyFeedback content="test content" />)
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.mouseLeave(button.firstChild as Element)
|
||||
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CopyFeedbackNew', () => {
|
||||
beforeEach(() => {
|
||||
mockCopied = false
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders the component', () => {
|
||||
const { container } = render(<CopyFeedbackNew content="test content" />)
|
||||
expect(container.querySelector('.cursor-pointer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies copied CSS class when copied is true', () => {
|
||||
mockCopied = true
|
||||
const { container } = render(<CopyFeedbackNew content="test content" />)
|
||||
const feedbackIcon = container.firstChild?.firstChild as Element
|
||||
expect(feedbackIcon).toHaveClass(/_copied_.*/)
|
||||
})
|
||||
|
||||
it('does not apply copied CSS class when not copied', () => {
|
||||
const { container } = render(<CopyFeedbackNew content="test content" />)
|
||||
const feedbackIcon = container.firstChild?.firstChild as Element
|
||||
expect(feedbackIcon).not.toHaveClass(/_copied_.*/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('calls copy with content when clicked', () => {
|
||||
const { container } = render(<CopyFeedbackNew content="test content" />)
|
||||
const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement
|
||||
fireEvent.click(clickableArea)
|
||||
expect(mockCopy).toHaveBeenCalledWith('test content')
|
||||
})
|
||||
|
||||
it('calls reset on mouse leave', () => {
|
||||
const { container } = render(<CopyFeedbackNew content="test content" />)
|
||||
const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement
|
||||
fireEvent.mouseLeave(clickableArea)
|
||||
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,54 +0,0 @@
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import CopyIcon from '.'
|
||||
|
||||
const copy = vi.fn()
|
||||
const reset = vi.fn()
|
||||
let copied = false
|
||||
|
||||
vi.mock('foxact/use-clipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copy,
|
||||
reset,
|
||||
copied,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('copy icon component', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
copied = false
|
||||
})
|
||||
|
||||
it('renders normally', () => {
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows copy icon initially', () => {
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = container.querySelector('[data-icon="Copy"]')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows copy check icon when copied', () => {
|
||||
copied = true
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = container.querySelector('[data-icon="CopyCheck"]')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles copy when clicked', () => {
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = container.querySelector('[data-icon="Copy"]')
|
||||
fireEvent.click(icon as Element)
|
||||
expect(copy).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resets on mouse leave', () => {
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = container.querySelector('[data-icon="Copy"]')
|
||||
const div = icon?.parentElement as HTMLElement
|
||||
fireEvent.mouseLeave(div)
|
||||
expect(reset).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,16 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import CornerLabel from '.'
|
||||
|
||||
describe('CornerLabel', () => {
|
||||
it('renders the label correctly', () => {
|
||||
render(<CornerLabel label="Test Label" />)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom class names', () => {
|
||||
const { container } = render(<CornerLabel label="Test Label" className="custom-class" labelClassName="custom-label-class" />)
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
expect(container.querySelector('.custom-label-class')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,138 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CustomDialog from './index'
|
||||
|
||||
describe('CustomDialog Component', () => {
|
||||
const setup = () => userEvent.setup()
|
||||
|
||||
it('should render children and title when show is true', async () => {
|
||||
render(
|
||||
<CustomDialog show={true} title="Modal Title">
|
||||
<div data-testid="dialog-content">Main Content</div>
|
||||
</CustomDialog>,
|
||||
)
|
||||
|
||||
const title = await screen.findByText('Modal Title')
|
||||
const content = screen.getByTestId('dialog-content')
|
||||
|
||||
expect(title).toBeInTheDocument()
|
||||
expect(content).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render anything when show is false', async () => {
|
||||
render(
|
||||
<CustomDialog show={false} title="Hidden Title">
|
||||
<div>Content</div>
|
||||
</CustomDialog>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Hidden Title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply the correct semantic tag to title using titleAs', async () => {
|
||||
render(
|
||||
<CustomDialog show={true} title="Semantic Title" titleAs="h1">
|
||||
Content
|
||||
</CustomDialog>,
|
||||
)
|
||||
|
||||
const title = await screen.findByRole('heading', { level: 1 })
|
||||
expect(title).toHaveTextContent('Semantic Title')
|
||||
})
|
||||
|
||||
it('should render the footer only when the prop is provided', async () => {
|
||||
const { rerender } = render(
|
||||
<CustomDialog show={true}>Content</CustomDialog>,
|
||||
)
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByText('Footer Content')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<CustomDialog show={true} footer={<div data-testid="footer-node">Footer Content</div>}>
|
||||
Content
|
||||
</CustomDialog>,
|
||||
)
|
||||
|
||||
expect(await screen.findByTestId('footer-node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose when Escape key is pressed', async () => {
|
||||
const user = setup()
|
||||
const onCloseMock = vi.fn()
|
||||
|
||||
render(
|
||||
<CustomDialog show={true} onClose={onCloseMock}>
|
||||
Content
|
||||
</CustomDialog>,
|
||||
)
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
await act(async () => {
|
||||
await user.keyboard('{Escape}')
|
||||
})
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when the backdrop is clicked', async () => {
|
||||
const user = setup()
|
||||
const onCloseMock = vi.fn()
|
||||
|
||||
render(
|
||||
<CustomDialog show={true} onClose={onCloseMock}>
|
||||
Content
|
||||
</CustomDialog>,
|
||||
)
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
const backdrop = document.querySelector('.bg-background-overlay-backdrop')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await user.click(backdrop!)
|
||||
})
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply custom class names to internal elements', async () => {
|
||||
render(
|
||||
<CustomDialog
|
||||
show={true}
|
||||
title="Title"
|
||||
className="custom-panel-container"
|
||||
titleClassName="custom-title-style"
|
||||
bodyClassName="custom-body-style"
|
||||
footer="Footer"
|
||||
footerClassName="custom-footer-style"
|
||||
>
|
||||
<div data-testid="content">Content</div>
|
||||
</CustomDialog>,
|
||||
)
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
expect(document.querySelector('.custom-panel-container')).toBeInTheDocument()
|
||||
expect(document.querySelector('.custom-title-style')).toBeInTheDocument()
|
||||
expect(document.querySelector('.custom-body-style')).toBeInTheDocument()
|
||||
expect(document.querySelector('.custom-footer-style')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain accessibility attributes (aria-modal)', async () => {
|
||||
render(
|
||||
<CustomDialog show={true} title="Accessibility Test">
|
||||
<button>Focusable Item</button>
|
||||
</CustomDialog>,
|
||||
)
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
// Headless UI should automatically set aria-modal="true"
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
})
|
||||
@ -1,447 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import DrawerPlus from '.'
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => 'desktop',
|
||||
MediaType: { mobile: 'mobile', desktop: 'desktop', tablet: 'tablet' },
|
||||
}))
|
||||
|
||||
describe('DrawerPlus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should not render when isShow is false', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={false}
|
||||
onHide={() => {}}
|
||||
title="Test Drawer"
|
||||
body={<div>Content</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render when isShow is true', () => {
|
||||
const bodyContent = <div>Body Content</div>
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test Drawer"
|
||||
body={bodyContent}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Drawer')).toBeInTheDocument()
|
||||
expect(screen.getByText('Body Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer when provided', () => {
|
||||
const footerContent = <div>Footer Content</div>
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test Drawer"
|
||||
body={<div>Body</div>}
|
||||
foot={footerContent}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Footer Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render JSX element as title', () => {
|
||||
const titleElement = <h1 data-testid="custom-title">Custom Title</h1>
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title={titleElement}
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render titleDescription when provided', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test Drawer"
|
||||
titleDescription="Description text"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Description text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render titleDescription when not provided', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test Drawer"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/Description/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render JSX element as titleDescription', () => {
|
||||
const descElement = <span data-testid="custom-desc">Custom Description</span>
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
titleDescription={descElement}
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-desc')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props - Display Options', () => {
|
||||
it('should apply default maxWidthClassName', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
|
||||
const outerPanel = innerPanel?.parentElement
|
||||
expect(outerPanel?.className).toContain('!max-w-[640px]')
|
||||
})
|
||||
|
||||
it('should apply custom maxWidthClassName', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
maxWidthClassName="!max-w-[800px]"
|
||||
/>,
|
||||
)
|
||||
|
||||
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
|
||||
const outerPanel = innerPanel?.parentElement
|
||||
expect(outerPanel?.className).toContain('!max-w-[800px]')
|
||||
})
|
||||
|
||||
it('should apply custom panelClassName', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
panelClassName="custom-panel"
|
||||
/>,
|
||||
)
|
||||
|
||||
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
|
||||
const outerPanel = innerPanel?.parentElement
|
||||
expect(outerPanel?.className).toContain('custom-panel')
|
||||
})
|
||||
|
||||
it('should apply custom dialogClassName', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
dialogClassName="custom-dialog"
|
||||
/>,
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog.className).toContain('custom-dialog')
|
||||
})
|
||||
|
||||
it('should apply custom contentClassName', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
contentClassName="custom-content"
|
||||
/>,
|
||||
)
|
||||
const title = screen.getByText('Test')
|
||||
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
|
||||
const content = header?.parentElement
|
||||
expect(content?.className).toContain('custom-content')
|
||||
})
|
||||
|
||||
it('should apply custom headerClassName', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
headerClassName="custom-header"
|
||||
/>,
|
||||
)
|
||||
|
||||
const title = screen.getByText('Test')
|
||||
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
|
||||
expect(header?.className).toContain('custom-header')
|
||||
})
|
||||
|
||||
it('should apply custom height', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
height="500px"
|
||||
/>,
|
||||
)
|
||||
|
||||
const title = screen.getByText('Test')
|
||||
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
|
||||
const content = header?.parentElement
|
||||
expect(content?.getAttribute('style')).toContain('height: 500px')
|
||||
})
|
||||
|
||||
it('should use default height', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
const title = screen.getByText('Test')
|
||||
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
|
||||
const content = header?.parentElement
|
||||
expect(content?.getAttribute('style')).toContain('calc(100vh - 72px)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handlers', () => {
|
||||
it('should call onHide when close button is clicked', () => {
|
||||
const handleHide = vi.fn()
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={handleHide}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
const title = screen.getByText('Test')
|
||||
const headerRight = title.nextElementSibling // .flex items-center
|
||||
const closeDiv = headerRight?.querySelector('.cursor-pointer') as HTMLElement
|
||||
|
||||
fireEvent.click(closeDiv)
|
||||
expect(handleHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Complex Content', () => {
|
||||
it('should render complex JSX elements in body', () => {
|
||||
const complexBody = (
|
||||
<div>
|
||||
<h2>Header</h2>
|
||||
<p>Paragraph</p>
|
||||
<button>Action Button</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={complexBody}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Header')).toBeInTheDocument()
|
||||
expect(screen.getByText('Paragraph')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Action Button' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render complex footer', () => {
|
||||
const complexFooter = (
|
||||
<div className="footer-actions">
|
||||
<button>Cancel</button>
|
||||
<button>Save</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
foot={complexFooter}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty title', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title=""
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined titleDescription', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
titleDescription={undefined}
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rapid isShow toggle', () => {
|
||||
const { rerender } = render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<DrawerPlus
|
||||
isShow={false}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in title', () => {
|
||||
const specialTitle = 'Test <> & " \' | Drawer'
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title={specialTitle}
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(specialTitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty body content', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div></div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply both custom maxWidth and panel classNames', () => {
|
||||
render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
maxWidthClassName="!max-w-[500px]"
|
||||
panelClassName="custom-style"
|
||||
/>,
|
||||
)
|
||||
|
||||
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
|
||||
const outerPanel = innerPanel?.parentElement
|
||||
expect(outerPanel?.className).toContain('!max-w-[500px]')
|
||||
expect(outerPanel?.className).toContain('custom-style')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized and not re-render on parent changes', () => {
|
||||
const { rerender } = render(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
rerender(
|
||||
<DrawerPlus
|
||||
isShow={true}
|
||||
onHide={() => {}}
|
||||
title="Test"
|
||||
body={<div>Body</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(dialog).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,225 +0,0 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Dropdown from './index'
|
||||
|
||||
describe('Dropdown Component', () => {
|
||||
const mockItems = [
|
||||
{ value: 'option1', text: 'Option 1' },
|
||||
{ value: 'option2', text: 'Option 2' },
|
||||
]
|
||||
const mockSecondItems = [
|
||||
{ value: 'option3', text: 'Option 3' },
|
||||
]
|
||||
const onSelect = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders default trigger properly', () => {
|
||||
const { container } = render(
|
||||
<Dropdown items={mockItems} onSelect={onSelect} />,
|
||||
)
|
||||
const trigger = container.querySelector('button')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom trigger when provided', () => {
|
||||
render(
|
||||
<Dropdown
|
||||
items={mockItems}
|
||||
onSelect={onSelect}
|
||||
renderTrigger={open => <button data-testid="custom-trigger">{open ? 'Open' : 'Closed'}</button>}
|
||||
/>,
|
||||
)
|
||||
const trigger = screen.getByTestId('custom-trigger')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
expect(trigger).toHaveTextContent('Closed')
|
||||
})
|
||||
|
||||
it('opens dropdown menu on trigger click and shows items', async () => {
|
||||
render(
|
||||
<Dropdown items={mockItems} onSelect={onSelect} />,
|
||||
)
|
||||
const trigger = screen.getByRole('button')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
|
||||
// Dropdown items are rendered in a portal (document.body)
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect and closes dropdown when an item is clicked', async () => {
|
||||
render(
|
||||
<Dropdown items={mockItems} onSelect={onSelect} />,
|
||||
)
|
||||
const trigger = screen.getByRole('button')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
|
||||
const option1 = screen.getByText('Option 1')
|
||||
await act(async () => {
|
||||
fireEvent.click(option1)
|
||||
})
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(mockItems[0])
|
||||
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect and closes dropdown when a second item is clicked', async () => {
|
||||
render(
|
||||
<Dropdown items={mockItems} secondItems={mockSecondItems} onSelect={onSelect} />,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
|
||||
const option3 = screen.getByText('Option 3')
|
||||
await act(async () => {
|
||||
fireEvent.click(option3)
|
||||
})
|
||||
expect(onSelect).toHaveBeenCalledWith(mockSecondItems[0])
|
||||
expect(screen.queryByText('Option 3')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders second items and divider when provided', async () => {
|
||||
render(
|
||||
<Dropdown
|
||||
items={mockItems}
|
||||
secondItems={mockSecondItems}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
const trigger = screen.getByRole('button')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument()
|
||||
|
||||
// Check for divider (h-px bg-divider-regular)
|
||||
const divider = document.body.querySelector('.bg-divider-regular.h-px')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom classNames', async () => {
|
||||
const popupClass = 'custom-popup'
|
||||
const itemClass = 'custom-item'
|
||||
const secondItemClass = 'custom-second-item'
|
||||
|
||||
render(
|
||||
<Dropdown
|
||||
items={mockItems}
|
||||
secondItems={mockSecondItems}
|
||||
onSelect={onSelect}
|
||||
popupClassName={popupClass}
|
||||
itemClassName={itemClass}
|
||||
secondItemClassName={secondItemClass}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
|
||||
const popup = document.body.querySelector(`.${popupClass}`)
|
||||
expect(popup).toBeInTheDocument()
|
||||
|
||||
const items = screen.getAllByText('Option 1')
|
||||
expect(items[0]).toHaveClass(itemClass)
|
||||
|
||||
const secondItems = screen.getAllByText('Option 3')
|
||||
expect(secondItems[0]).toHaveClass(secondItemClass)
|
||||
})
|
||||
|
||||
it('applies open class to trigger when menu is open', async () => {
|
||||
render(<Dropdown items={mockItems} onSelect={onSelect} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
expect(trigger).toHaveClass('bg-divider-regular')
|
||||
})
|
||||
|
||||
it('handles JSX elements as item text', async () => {
|
||||
const itemsWithJSX = [
|
||||
{ value: 'jsx', text: <span data-testid="jsx-item">JSX Content</span> },
|
||||
]
|
||||
render(
|
||||
<Dropdown items={itemsWithJSX} onSelect={onSelect} />,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('jsx-item')).toBeInTheDocument()
|
||||
expect(screen.getByText('JSX Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render items section if items list is empty', async () => {
|
||||
render(
|
||||
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
|
||||
const p1Divs = document.body.querySelectorAll('.p-1')
|
||||
expect(p1Divs.length).toBe(1)
|
||||
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render divider if only one section is provided', async () => {
|
||||
const { rerender } = render(
|
||||
<Dropdown items={mockItems} onSelect={onSelect} />,
|
||||
)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
|
||||
)
|
||||
})
|
||||
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing if both item lists are empty', async () => {
|
||||
render(<Dropdown items={[]} secondItems={[]} onSelect={onSelect} />)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
const popup = document.body.querySelector('.bg-components-panel-bg')
|
||||
expect(popup?.children.length).toBe(0)
|
||||
})
|
||||
|
||||
it('passes triggerProps to ActionButton and applies custom className', () => {
|
||||
render(
|
||||
<Dropdown
|
||||
items={mockItems}
|
||||
onSelect={onSelect}
|
||||
triggerProps={{
|
||||
'disabled': true,
|
||||
'aria-label': 'dropdown-trigger',
|
||||
'className': 'custom-trigger-class',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const trigger = screen.getByLabelText('dropdown-trigger')
|
||||
expect(trigger).toBeDisabled()
|
||||
expect(trigger).toHaveClass('custom-trigger-class')
|
||||
})
|
||||
})
|
||||
@ -1,9 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Effect from '.'
|
||||
|
||||
describe('Effect', () => {
|
||||
it('applies custom class names', () => {
|
||||
const { container } = render(<Effect className="custom-class" />)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
@ -1,169 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import EmojiPickerInner from './Inner'
|
||||
|
||||
vi.mock('@emoji-mart/data', () => ({
|
||||
default: {
|
||||
categories: [
|
||||
{
|
||||
id: 'nature',
|
||||
emojis: ['rabbit', 'bear'],
|
||||
},
|
||||
{
|
||||
id: 'food',
|
||||
emojis: ['apple', 'orange'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('emoji-mart', () => ({
|
||||
init: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/emoji', () => ({
|
||||
searchEmoji: vi.fn().mockResolvedValue(['dog', 'cat']),
|
||||
}))
|
||||
|
||||
describe('EmojiPickerInner', () => {
|
||||
const mockOnSelect = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Define the custom element to avoid "Unknown custom element" warnings
|
||||
if (!customElements.get('em-emoji')) {
|
||||
customElements.define('em-emoji', class extends HTMLElement {
|
||||
static get observedAttributes() { return ['id'] }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders initial categories and emojis correctly', () => {
|
||||
render(<EmojiPickerInner onSelect={mockOnSelect} />)
|
||||
|
||||
expect(screen.getByText('nature')).toBeInTheDocument()
|
||||
expect(screen.getByText('food')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('calls searchEmoji and displays results when typing in search input', async () => {
|
||||
render(<EmojiPickerInner onSelect={mockOnSelect} />)
|
||||
const searchInput = screen.getByPlaceholderText('Search emojis...')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: 'anim' } })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const searchSection = screen.getByText('Search').parentElement
|
||||
expect(searchSection?.querySelectorAll('em-emoji').length).toBe(2)
|
||||
})
|
||||
|
||||
it('updates selected emoji and calls onSelect when an emoji is clicked', async () => {
|
||||
render(<EmojiPickerInner onSelect={mockOnSelect} />)
|
||||
const emojiContainers = screen.getAllByTestId(/^emoji-container-/)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(emojiContainers[0])
|
||||
})
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('rabbit', expect.any(String))
|
||||
})
|
||||
|
||||
it('toggles style colors display when clicking the chevron', async () => {
|
||||
render(<EmojiPickerInner onSelect={mockOnSelect} />)
|
||||
|
||||
expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument()
|
||||
|
||||
const toggleButton = screen.getByTestId('toggle-colors')
|
||||
expect(toggleButton).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggleButton!)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Choose Style')).toBeInTheDocument()
|
||||
const colorOptions = document.querySelectorAll('[style^="background:"]')
|
||||
expect(colorOptions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('updates background color and calls onSelect when a color is clicked', async () => {
|
||||
render(<EmojiPickerInner onSelect={mockOnSelect} />)
|
||||
|
||||
const toggleButton = screen.getByTestId('toggle-colors')
|
||||
await act(async () => {
|
||||
fireEvent.click(toggleButton!)
|
||||
})
|
||||
|
||||
const emojiContainers = screen.getAllByTestId(/^emoji-container-/)
|
||||
await act(async () => {
|
||||
fireEvent.click(emojiContainers[0])
|
||||
})
|
||||
|
||||
mockOnSelect.mockClear()
|
||||
|
||||
const colorOptions = document.querySelectorAll('[style^="background:"]')
|
||||
await act(async () => {
|
||||
fireEvent.click(colorOptions[1].parentElement!)
|
||||
})
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC')
|
||||
})
|
||||
|
||||
it('updates selected emoji when clicking a search result', async () => {
|
||||
render(<EmojiPickerInner onSelect={mockOnSelect} />)
|
||||
const searchInput = screen.getByPlaceholderText('Search emojis...')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: 'anim' } })
|
||||
})
|
||||
|
||||
await screen.findByText('Search')
|
||||
|
||||
const searchEmojis = screen.getAllByTestId(/^emoji-search-result-/)
|
||||
await act(async () => {
|
||||
fireEvent.click(searchEmojis![0])
|
||||
})
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('dog', expect.any(String))
|
||||
})
|
||||
|
||||
it('toggles style colors display back and forth', async () => {
|
||||
render(<EmojiPickerInner onSelect={mockOnSelect} />)
|
||||
|
||||
const toggleButton = screen.getByTestId('toggle-colors')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggleButton!)
|
||||
})
|
||||
expect(screen.getByText('Choose Style')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('toggle-colors')!) // It should be the other icon now
|
||||
})
|
||||
expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears search results when input is cleared', async () => {
|
||||
render(<EmojiPickerInner onSelect={mockOnSelect} />)
|
||||
const searchInput = screen.getByPlaceholderText('Search emojis...')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: 'anim' } })
|
||||
})
|
||||
|
||||
await screen.findByText('Search')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: '' } })
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Search')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -3,6 +3,8 @@ import type { EmojiMartData } from '@emoji-mart/data'
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import data from '@emoji-mart/data'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { init } from 'emoji-mart'
|
||||
@ -95,7 +97,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||
{isSearching && (
|
||||
<>
|
||||
<div key="category-search" className="flex flex-col">
|
||||
<p className="mb-1 text-text-primary system-xs-medium-uppercase">Search</p>
|
||||
<p className="system-xs-medium-uppercase mb-1 text-text-primary">Search</p>
|
||||
<div className="grid h-full w-full grid-cols-8 gap-1">
|
||||
{searchedEmojis.map((emoji: string, index: number) => {
|
||||
return (
|
||||
@ -106,7 +108,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||
setSelectedEmoji(emoji)
|
||||
}}
|
||||
>
|
||||
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1" data-testid={`emoji-search-result-${emoji}`}>
|
||||
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
|
||||
<em-emoji id={emoji} />
|
||||
</div>
|
||||
</div>
|
||||
@ -120,7 +122,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||
{categories.map((category, index: number) => {
|
||||
return (
|
||||
<div key={`category-${index}`} className="flex flex-col">
|
||||
<p className="mb-1 text-text-primary system-xs-medium-uppercase">{category.id}</p>
|
||||
<p className="system-xs-medium-uppercase mb-1 text-text-primary">{category.id}</p>
|
||||
<div className="grid h-full w-full grid-cols-8 gap-1">
|
||||
{category.emojis.map((emoji, index: number) => {
|
||||
return (
|
||||
@ -131,7 +133,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||
setSelectedEmoji(emoji)
|
||||
}}
|
||||
>
|
||||
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1" data-testid={`emoji-container-${emoji}`}>
|
||||
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
|
||||
<em-emoji id={emoji} />
|
||||
</div>
|
||||
</div>
|
||||
@ -146,10 +148,10 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||
|
||||
{/* Color Select */}
|
||||
<div className={cn('flex items-center justify-between p-3 pb-0')}>
|
||||
<p className="mb-2 text-text-primary system-xs-medium-uppercase">Choose Style</p>
|
||||
<p className="system-xs-medium-uppercase mb-2 text-text-primary">Choose Style</p>
|
||||
{showStyleColors
|
||||
? <span className="i-heroicons-chevron-down h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" />
|
||||
: <span className="i-heroicons-chevron-up h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" />}
|
||||
? <ChevronDownIcon className="h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} />
|
||||
: <ChevronUpIcon className="h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} />}
|
||||
</div>
|
||||
{showStyleColors && (
|
||||
<div className="grid w-full grid-cols-8 gap-1 px-3">
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import EmojiPicker from './index'
|
||||
|
||||
vi.mock('@emoji-mart/data', () => ({
|
||||
default: {
|
||||
categories: [
|
||||
{
|
||||
id: 'category1',
|
||||
name: 'Category 1',
|
||||
emojis: ['emoji1', 'emoji2'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('emoji-mart', () => ({
|
||||
init: vi.fn(),
|
||||
SearchIndex: {
|
||||
search: vi.fn().mockResolvedValue([{ skins: [{ native: '🔍' }] }]),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/emoji', () => ({
|
||||
searchEmoji: vi.fn().mockResolvedValue(['🔍']),
|
||||
}))
|
||||
|
||||
describe('EmojiPicker', () => {
|
||||
const mockOnSelect = vi.fn()
|
||||
const mockOnClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders nothing when isModal is false', () => {
|
||||
const { container } = render(
|
||||
<EmojiPicker isModal={false} />,
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders modal when isModal is true', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker isModal={true} />,
|
||||
)
|
||||
})
|
||||
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/OK/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('OK button is disabled initially', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker />,
|
||||
)
|
||||
})
|
||||
const okButton = screen.getByText(/OK/i).closest('button')
|
||||
expect(okButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('applies custom className to modal wrapper', async () => {
|
||||
const customClass = 'custom-wrapper-class'
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker className={customClass} />,
|
||||
)
|
||||
})
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveClass(customClass)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('calls onSelect with selected emoji and background when OK is clicked', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker onSelect={mockOnSelect} />,
|
||||
)
|
||||
})
|
||||
|
||||
const emojiWrappers = screen.getAllByTestId(/^emoji-container-/)
|
||||
expect(emojiWrappers.length).toBeGreaterThan(0)
|
||||
await act(async () => {
|
||||
fireEvent.click(emojiWrappers[0])
|
||||
})
|
||||
|
||||
const okButton = screen.getByText(/OK/i)
|
||||
expect(okButton.closest('button')).not.toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(okButton)
|
||||
})
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(expect.any(String), expect.any(String))
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker onClose={mockOnClose} />,
|
||||
)
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByText(/Cancel/i)
|
||||
await act(async () => {
|
||||
fireEvent.click(cancelButton)
|
||||
})
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,15 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { EncryptedBottom } from '.'
|
||||
|
||||
describe('EncryptedBottom', () => {
|
||||
it('applies custom class names', () => {
|
||||
const { container } = render(<EncryptedBottom className="custom-class" />)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('passes keys', async () => {
|
||||
render(<EncryptedBottom frontTextKey="provider.encrypted.front" backTextKey="provider.encrypted.back" />)
|
||||
expect(await screen.findByText(/provider.encrypted.front/i)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/provider.encrypted.back/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,28 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import FileIcon from '.'
|
||||
|
||||
describe('File icon component', () => {
|
||||
const testCases = [
|
||||
{ type: 'csv', icon: 'Csv' },
|
||||
{ type: 'doc', icon: 'Doc' },
|
||||
{ type: 'docx', icon: 'Docx' },
|
||||
{ type: 'htm', icon: 'Html' },
|
||||
{ type: 'html', icon: 'Html' },
|
||||
{ type: 'md', icon: 'Md' },
|
||||
{ type: 'mdx', icon: 'Md' },
|
||||
{ type: 'markdown', icon: 'Md' },
|
||||
{ type: 'pdf', icon: 'Pdf' },
|
||||
{ type: 'xls', icon: 'Xlsx' },
|
||||
{ type: 'xlsx', icon: 'Xlsx' },
|
||||
{ type: 'notion', icon: 'Notion' },
|
||||
{ type: 'something-else', icon: 'Unknown' },
|
||||
{ type: 'txt', icon: 'Txt' },
|
||||
{ type: 'json', icon: 'Json' },
|
||||
]
|
||||
|
||||
it.each(testCases)('renders $icon icon for type $type', ({ type, icon }) => {
|
||||
const { container } = render(<FileIcon type={type} />)
|
||||
const iconElement = container.querySelector(`[data-icon="${icon}"]`)
|
||||
expect(iconElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,20 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ImageRender from './image-render'
|
||||
|
||||
describe('ImageRender Component', () => {
|
||||
const mockProps = {
|
||||
sourceUrl: 'https://example.com/image.jpg',
|
||||
name: 'test-image.jpg',
|
||||
}
|
||||
|
||||
describe('Render', () => {
|
||||
it('renders image with correct src and alt', () => {
|
||||
render(<ImageRender {...mockProps} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', mockProps.sourceUrl)
|
||||
expect(img).toHaveAttribute('alt', mockProps.name)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,74 +0,0 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import FileThumb from './index'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('FileThumb Component', () => {
|
||||
const mockImageFile = {
|
||||
name: 'test-image.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
extension: '.jpg',
|
||||
size: 1024,
|
||||
sourceUrl: 'https://example.com/test-image.jpg',
|
||||
}
|
||||
|
||||
const mockNonImageFile = {
|
||||
name: 'test.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
extension: '.pdf',
|
||||
size: 2048,
|
||||
sourceUrl: 'https://example.com/test.pdf',
|
||||
}
|
||||
|
||||
describe('Render', () => {
|
||||
it('renders image thumbnail correctly', () => {
|
||||
render(<FileThumb file={mockImageFile} />)
|
||||
|
||||
const img = screen.getByAltText(mockImageFile.name)
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', mockImageFile.sourceUrl)
|
||||
})
|
||||
|
||||
it('renders file type icon for non-image files', () => {
|
||||
const { container } = render(<FileThumb file={mockNonImageFile} />)
|
||||
|
||||
expect(screen.queryByAltText(mockNonImageFile.name)).not.toBeInTheDocument()
|
||||
const svgIcon = container.querySelector('svg')
|
||||
expect(svgIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wraps content inside tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<FileThumb file={mockImageFile} />)
|
||||
|
||||
const trigger = screen.getByAltText(mockImageFile.name)
|
||||
expect(trigger).toBeInTheDocument()
|
||||
|
||||
await user.hover(trigger)
|
||||
|
||||
const tooltipContent = await screen.findByText(mockImageFile.name)
|
||||
expect(tooltipContent).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('calls onClick with file when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<FileThumb file={mockImageFile} onClick={onClick} />)
|
||||
|
||||
const clickable = screen.getByAltText(mockImageFile.name).closest('div') as HTMLElement
|
||||
|
||||
fireEvent.click(clickable)
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
expect(onClick).toHaveBeenCalledWith(mockImageFile)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,11 @@
|
||||
import type {
|
||||
FileEntity,
|
||||
} from './types'
|
||||
import { isEqual } from 'es-toolkit/predicate'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
@ -55,10 +57,20 @@ export const FileContextProvider = ({
|
||||
onChange,
|
||||
}: FileProviderProps) => {
|
||||
const storeRef = useRef<FileStore | undefined>(undefined)
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createFileStore(value, onChange)
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeRef.current)
|
||||
return
|
||||
if (isEqual(value, storeRef.current.getState().files))
|
||||
return
|
||||
|
||||
storeRef.current.setState({
|
||||
files: value ? [...value] : [],
|
||||
})
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<FileContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
|
||||
@ -1,214 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import FullScreenModal from './index'
|
||||
|
||||
describe('FullScreenModal Component', () => {
|
||||
it('should not render anything when open is false', () => {
|
||||
render(
|
||||
<FullScreenModal open={false}>
|
||||
<div data-testid="modal-content">Content</div>
|
||||
</FullScreenModal>,
|
||||
)
|
||||
expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content when open is true', async () => {
|
||||
render(
|
||||
<FullScreenModal open={true}>
|
||||
<div data-testid="modal-content">Content</div>
|
||||
</FullScreenModal>,
|
||||
)
|
||||
expect(await screen.findByTestId('modal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not crash when provided with title and description props', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<FullScreenModal
|
||||
open={true}
|
||||
title="My Title"
|
||||
description="My Description"
|
||||
>
|
||||
Content
|
||||
</FullScreenModal>,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Handling', () => {
|
||||
it('should apply wrapperClassName to the dialog root', async () => {
|
||||
render(
|
||||
<FullScreenModal
|
||||
open={true}
|
||||
wrapperClassName="custom-wrapper-class"
|
||||
>
|
||||
Content
|
||||
</FullScreenModal>,
|
||||
)
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
const element = document.querySelector('.custom-wrapper-class')
|
||||
expect(element).toBeInTheDocument()
|
||||
expect(element).toHaveClass('modal-dialog')
|
||||
})
|
||||
|
||||
it('should apply className to the inner panel', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<FullScreenModal
|
||||
open={true}
|
||||
className="custom-panel-class"
|
||||
>
|
||||
Content
|
||||
</FullScreenModal>,
|
||||
)
|
||||
})
|
||||
const panel = document.querySelector('.custom-panel-class')
|
||||
expect(panel).toBeInTheDocument()
|
||||
expect(panel).toHaveClass('h-full')
|
||||
})
|
||||
|
||||
it('should handle overflowVisible prop', async () => {
|
||||
const { rerender } = await act(async () => {
|
||||
return render(
|
||||
<FullScreenModal
|
||||
open={true}
|
||||
overflowVisible={true}
|
||||
className="target-panel"
|
||||
>
|
||||
Content
|
||||
</FullScreenModal>,
|
||||
)
|
||||
})
|
||||
let panel = document.querySelector('.target-panel')
|
||||
expect(panel).toHaveClass('overflow-visible')
|
||||
expect(panel).not.toHaveClass('overflow-hidden')
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<FullScreenModal
|
||||
open={true}
|
||||
overflowVisible={false}
|
||||
className="target-panel"
|
||||
>
|
||||
Content
|
||||
</FullScreenModal>,
|
||||
)
|
||||
})
|
||||
panel = document.querySelector('.target-panel')
|
||||
expect(panel).toHaveClass('overflow-hidden')
|
||||
expect(panel).not.toHaveClass('overflow-visible')
|
||||
})
|
||||
|
||||
it('should render close button when closable is true', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<FullScreenModal open={true} closable={true}>
|
||||
Content
|
||||
</FullScreenModal>,
|
||||
)
|
||||
})
|
||||
const closeButton = document.querySelector('.bg-components-button-tertiary-bg')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render close button when closable is false', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<FullScreenModal open={true} closable={false}>
|
||||
Content
|
||||
</FullScreenModal>,
|
||||
)
|
||||
})
|
||||
const closeButton = document.querySelector('.bg-components-button-tertiary-bg')
|
||||
expect(closeButton).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should call onClose when close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<FullScreenModal open={true} closable={true} onClose={onClose}>
|
||||
Content
|
||||
</FullScreenModal>,
|
||||
)
|
||||
|
||||
const closeBtn = document.querySelector('.bg-components-button-tertiary-bg')
|
||||
expect(closeBtn).toBeInTheDocument()
|
||||
|
||||
await user.click(closeBtn!)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when clicking the backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<FullScreenModal open={true} onClose={onClose}>
|
||||
<div data-testid="inner">Content</div>
|
||||
</FullScreenModal>,
|
||||
)
|
||||
|
||||
const dialog = document.querySelector('.modal-dialog')
|
||||
if (dialog) {
|
||||
await user.click(dialog)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
}
|
||||
else {
|
||||
throw new Error('Dialog root not found')
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onClose when Escape key is pressed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<FullScreenModal open={true} onClose={onClose}>
|
||||
Content
|
||||
</FullScreenModal>,
|
||||
)
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onClose when clicking inside the content', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<FullScreenModal open={true} onClose={onClose}>
|
||||
<div className="bg-background-default-subtle">
|
||||
<button>Action</button>
|
||||
</div>
|
||||
</FullScreenModal>,
|
||||
)
|
||||
|
||||
const innerButton = screen.getByRole('button', { name: 'Action' })
|
||||
await user.click(innerButton)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
const contentPanel = document.querySelector('.bg-background-default-subtle')
|
||||
await act(async () => {
|
||||
fireEvent.click(contentPanel!)
|
||||
})
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default Props', () => {
|
||||
it('should not throw if onClose is not provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<FullScreenModal open={true} closable={true}>Content</FullScreenModal>)
|
||||
|
||||
const closeButton = document.querySelector('.bg-components-button-tertiary-bg')
|
||||
await user.click(closeButton!)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,93 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import LinkedAppsPanel from './index'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className }: { children: React.ReactNode, href: string, className: string }) => (
|
||||
<a href={href} className={className} data-testid="link-item">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('LinkedAppsPanel Component', () => {
|
||||
const mockRelatedApps = [
|
||||
{
|
||||
id: 'app-1',
|
||||
name: 'Chatbot App',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: 'emoji' as const,
|
||||
icon: '🤖',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
{
|
||||
id: 'app-2',
|
||||
name: 'Workflow App',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
icon_type: 'image' as const,
|
||||
icon: 'file-id',
|
||||
icon_background: '#E4FBCC',
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
},
|
||||
{
|
||||
id: 'app-3',
|
||||
name: '',
|
||||
mode: AppModeEnum.AGENT_CHAT,
|
||||
icon_type: 'emoji' as const,
|
||||
icon: '🕵️',
|
||||
icon_background: '#D3F8DF',
|
||||
icon_url: '',
|
||||
},
|
||||
]
|
||||
|
||||
describe('Render', () => {
|
||||
it('renders correctly with multiple apps', () => {
|
||||
render(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={false} />)
|
||||
|
||||
const items = screen.getAllByTestId('link-item')
|
||||
expect(items).toHaveLength(3)
|
||||
|
||||
expect(screen.getByText('Chatbot App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow App')).toBeInTheDocument()
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays correct app mode labels', () => {
|
||||
render(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={false} />)
|
||||
|
||||
expect(screen.getByText('Chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides app name and centers content in mobile mode', () => {
|
||||
render(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={true} />)
|
||||
|
||||
expect(screen.queryByText('Chatbot App')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Workflow App')).not.toBeInTheDocument()
|
||||
|
||||
const items = screen.getAllByTestId('link-item')
|
||||
expect(items[0]).toHaveClass('justify-center')
|
||||
})
|
||||
|
||||
it('handles empty relatedApps list gracefully', () => {
|
||||
const { container } = render(<LinkedAppsPanel relatedApps={[]} isMobile={false} />)
|
||||
const items = screen.queryAllByTestId('link-item')
|
||||
expect(items).toHaveLength(0)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('renders correct links for each app', () => {
|
||||
render(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={false} />)
|
||||
|
||||
const items = screen.getAllByTestId('link-item')
|
||||
expect(items[0]).toHaveAttribute('href', '/app/app-1/overview')
|
||||
expect(items[1]).toHaveAttribute('href', '/app/app-2/overview')
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user