mirror of
https://github.com/langgenius/dify.git
synced 2026-02-12 06:15:46 +08:00
Compare commits
5 Commits
test/billi
...
test/tool-
| Author | SHA1 | Date | |
|---|---|---|---|
| 3849e444bf | |||
| 1afc354d97 | |||
| 0e36aa9c67 | |||
| c36de51771 | |||
| b0b4cac03f |
2
api/.vscode/launch.json.example
vendored
2
api/.vscode/launch.json.example
vendored
@ -54,7 +54,7 @@
|
||||
"--loglevel",
|
||||
"DEBUG",
|
||||
"-Q",
|
||||
"dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,workflow_based_app_execution,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
"dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@ -259,20 +259,11 @@ class CeleryConfig(DatabaseConfig):
|
||||
description="Password of the Redis Sentinel master.",
|
||||
default=None,
|
||||
)
|
||||
|
||||
CELERY_SENTINEL_SOCKET_TIMEOUT: PositiveFloat | None = Field(
|
||||
description="Timeout for Redis Sentinel socket operations in seconds.",
|
||||
default=0.1,
|
||||
)
|
||||
|
||||
CELERY_TASK_ANNOTATIONS: dict[str, Any] | None = Field(
|
||||
description=(
|
||||
"Annotations for Celery tasks as a JSON mapping of task name -> options "
|
||||
"(for example, rate limits or other task-specific settings)."
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
def CELERY_RESULT_BACKEND(self) -> str | None:
|
||||
if self.CELERY_BACKEND in ("database", "rabbitmq"):
|
||||
|
||||
@ -21,7 +21,6 @@ language_timezone_mapping = {
|
||||
"th-TH": "Asia/Bangkok",
|
||||
"id-ID": "Asia/Jakarta",
|
||||
"ar-TN": "Africa/Tunis",
|
||||
"nl-NL": "Europe/Amsterdam",
|
||||
}
|
||||
|
||||
languages = list(language_timezone_mapping.keys())
|
||||
|
||||
@ -599,12 +599,7 @@ def _get_conversation(app_model, conversation_id):
|
||||
db.session.execute(
|
||||
sa.update(Conversation)
|
||||
.where(Conversation.id == conversation_id, Conversation.read_at.is_(None))
|
||||
# Keep updated_at unchanged when only marking a conversation as read.
|
||||
.values(
|
||||
read_at=naive_utc_now(),
|
||||
read_account_id=current_user.id,
|
||||
updated_at=Conversation.updated_at,
|
||||
)
|
||||
.values(read_at=naive_utc_now(), read_account_id=current_user.id)
|
||||
)
|
||||
db.session.commit()
|
||||
db.session.refresh(conversation)
|
||||
|
||||
@ -42,15 +42,7 @@ class SetupResponse(BaseModel):
|
||||
tags=["console"],
|
||||
)
|
||||
def get_setup_status_api() -> SetupStatusResponse:
|
||||
"""Get system setup status.
|
||||
|
||||
NOTE: This endpoint is unauthenticated by design.
|
||||
|
||||
During first-time bootstrap there is no admin account yet, so frontend initialization must be
|
||||
able to query setup progress before any login flow exists.
|
||||
|
||||
Only bootstrap-safe status information should be returned by this endpoint.
|
||||
"""
|
||||
"""Get system setup status."""
|
||||
if dify_config.EDITION == "SELF_HOSTED":
|
||||
setup_status = get_setup_status()
|
||||
if setup_status and not isinstance(setup_status, bool):
|
||||
@ -69,12 +61,7 @@ def get_setup_status_api() -> SetupStatusResponse:
|
||||
)
|
||||
@only_edition_self_hosted
|
||||
def setup_system(payload: SetupRequestPayload) -> SetupResponse:
|
||||
"""Initialize system setup with admin account.
|
||||
|
||||
NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
|
||||
Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
|
||||
and init-password validation rather than user session authentication.
|
||||
"""
|
||||
"""Initialize system setup with admin account."""
|
||||
if get_setup_status():
|
||||
raise AlreadySetupError()
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ def stream_topic_events(
|
||||
on_subscribe()
|
||||
while True:
|
||||
try:
|
||||
msg = sub.receive(timeout=1)
|
||||
msg = sub.receive(timeout=0.1)
|
||||
except SubscriptionClosedError:
|
||||
return
|
||||
if msg is None:
|
||||
|
||||
@ -45,8 +45,6 @@ from core.app.entities.task_entities import (
|
||||
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
|
||||
from core.file import helpers as file_helpers
|
||||
from core.file.enums import FileTransferMethod
|
||||
from core.model_manager import ModelInstance
|
||||
from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from core.model_runtime.entities.message_entities import (
|
||||
@ -58,11 +56,10 @@ from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.prompt.utils.prompt_message_util import PromptMessageUtil
|
||||
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
|
||||
from core.tools.signature import sign_tool_file
|
||||
from events.message_event import message_was_created
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.model import AppMode, Conversation, Message, MessageAgentThought, MessageFile, UploadFile
|
||||
from models.model import AppMode, Conversation, Message, MessageAgentThought
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -466,85 +463,6 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
|
||||
metadata=metadata_dict,
|
||||
)
|
||||
|
||||
def _record_files(self):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all()
|
||||
if not message_files:
|
||||
return None
|
||||
|
||||
files_list = []
|
||||
upload_file_ids = [
|
||||
mf.upload_file_id
|
||||
for mf in message_files
|
||||
if mf.transfer_method == FileTransferMethod.LOCAL_FILE and mf.upload_file_id
|
||||
]
|
||||
upload_files_map = {}
|
||||
if upload_file_ids:
|
||||
upload_files = session.scalars(select(UploadFile).where(UploadFile.id.in_(upload_file_ids))).all()
|
||||
upload_files_map = {uf.id: uf for uf in upload_files}
|
||||
|
||||
for message_file in message_files:
|
||||
upload_file = None
|
||||
if message_file.transfer_method == FileTransferMethod.LOCAL_FILE and message_file.upload_file_id:
|
||||
upload_file = upload_files_map.get(message_file.upload_file_id)
|
||||
|
||||
url = None
|
||||
filename = "file"
|
||||
mime_type = "application/octet-stream"
|
||||
size = 0
|
||||
extension = ""
|
||||
|
||||
if message_file.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
url = message_file.url
|
||||
if message_file.url:
|
||||
filename = message_file.url.split("/")[-1].split("?")[0] # Remove query params
|
||||
elif message_file.transfer_method == FileTransferMethod.LOCAL_FILE:
|
||||
if upload_file:
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(upload_file.id))
|
||||
filename = upload_file.name
|
||||
mime_type = upload_file.mime_type or "application/octet-stream"
|
||||
size = upload_file.size or 0
|
||||
extension = f".{upload_file.extension}" if upload_file.extension else ""
|
||||
elif message_file.upload_file_id:
|
||||
# Fallback: generate URL even if upload_file not found
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(message_file.upload_file_id))
|
||||
elif message_file.transfer_method == FileTransferMethod.TOOL_FILE and message_file.url:
|
||||
# For tool files, use URL directly if it's HTTP, otherwise sign it
|
||||
if message_file.url.startswith("http"):
|
||||
url = message_file.url
|
||||
filename = message_file.url.split("/")[-1].split("?")[0]
|
||||
else:
|
||||
# Extract tool file id and extension from URL
|
||||
url_parts = message_file.url.split("/")
|
||||
if url_parts:
|
||||
file_part = url_parts[-1].split("?")[0] # Remove query params first
|
||||
# Use rsplit to correctly handle filenames with multiple dots
|
||||
if "." in file_part:
|
||||
tool_file_id, ext = file_part.rsplit(".", 1)
|
||||
extension = f".{ext}"
|
||||
else:
|
||||
tool_file_id = file_part
|
||||
extension = ".bin"
|
||||
url = sign_tool_file(tool_file_id=tool_file_id, extension=extension)
|
||||
filename = file_part
|
||||
|
||||
transfer_method_value = message_file.transfer_method
|
||||
remote_url = message_file.url if message_file.transfer_method == FileTransferMethod.REMOTE_URL else ""
|
||||
file_dict = {
|
||||
"related_id": message_file.id,
|
||||
"extension": extension,
|
||||
"filename": filename,
|
||||
"size": size,
|
||||
"mime_type": mime_type,
|
||||
"transfer_method": transfer_method_value,
|
||||
"type": message_file.type,
|
||||
"url": url or "",
|
||||
"upload_file_id": message_file.upload_file_id or message_file.id,
|
||||
"remote_url": remote_url,
|
||||
}
|
||||
files_list.append(file_dict)
|
||||
return files_list or None
|
||||
|
||||
def _agent_message_to_stream_response(self, answer: str, message_id: str) -> AgentMessageStreamResponse:
|
||||
"""
|
||||
Agent message to stream response.
|
||||
|
||||
@ -64,13 +64,7 @@ class MessageCycleManager:
|
||||
|
||||
# Use SQLAlchemy 2.x style session.scalar(select(...))
|
||||
with session_factory.create_session() as session:
|
||||
message_file = session.scalar(
|
||||
select(MessageFile)
|
||||
.where(
|
||||
MessageFile.message_id == message_id,
|
||||
)
|
||||
.where(MessageFile.belongs_to == "assistant")
|
||||
)
|
||||
message_file = session.scalar(select(MessageFile).where(MessageFile.message_id == message_id))
|
||||
|
||||
if message_file:
|
||||
self._message_has_file.add(message_id)
|
||||
|
||||
@ -80,14 +80,8 @@ def init_app(app: DifyApp) -> Celery:
|
||||
worker_hijack_root_logger=False,
|
||||
timezone=pytz.timezone(dify_config.LOG_TZ or "UTC"),
|
||||
task_ignore_result=True,
|
||||
task_annotations=dify_config.CELERY_TASK_ANNOTATIONS,
|
||||
)
|
||||
|
||||
if dify_config.CELERY_BACKEND == "redis":
|
||||
celery_app.conf.update(
|
||||
result_backend_transport_options=broker_transport_options,
|
||||
)
|
||||
|
||||
# Apply SSL configuration if enabled
|
||||
ssl_options = _get_celery_ssl_options()
|
||||
if ssl_options:
|
||||
|
||||
@ -119,7 +119,7 @@ class RedisClientWrapper:
|
||||
|
||||
|
||||
redis_client: RedisClientWrapper = RedisClientWrapper()
|
||||
_pubsub_redis_client: redis.Redis | RedisCluster | None = None
|
||||
pubsub_redis_client: RedisClientWrapper = RedisClientWrapper()
|
||||
|
||||
|
||||
def _get_ssl_configuration() -> tuple[type[Union[Connection, SSLConnection]], dict[str, Any]]:
|
||||
@ -232,7 +232,7 @@ def _create_standalone_client(redis_params: dict[str, Any]) -> Union[redis.Redis
|
||||
return client
|
||||
|
||||
|
||||
def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> redis.Redis | RedisCluster:
|
||||
def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> Union[redis.Redis, RedisCluster]:
|
||||
if use_clusters:
|
||||
return RedisCluster.from_url(pubsub_url)
|
||||
return redis.Redis.from_url(pubsub_url)
|
||||
@ -256,19 +256,23 @@ def init_app(app: DifyApp):
|
||||
redis_client.initialize(client)
|
||||
app.extensions["redis"] = redis_client
|
||||
|
||||
global _pubsub_redis_client
|
||||
_pubsub_redis_client = client
|
||||
pubsub_client = client
|
||||
if dify_config.normalized_pubsub_redis_url:
|
||||
_pubsub_redis_client = _create_pubsub_client(
|
||||
pubsub_client = _create_pubsub_client(
|
||||
dify_config.normalized_pubsub_redis_url, dify_config.PUBSUB_REDIS_USE_CLUSTERS
|
||||
)
|
||||
pubsub_redis_client.initialize(pubsub_client)
|
||||
|
||||
|
||||
def get_pubsub_redis_client() -> RedisClientWrapper:
|
||||
return pubsub_redis_client
|
||||
|
||||
|
||||
def get_pubsub_broadcast_channel() -> BroadcastChannelProtocol:
|
||||
assert _pubsub_redis_client is not None, "PubSub redis Client should be initialized here."
|
||||
redis_conn = get_pubsub_redis_client()
|
||||
if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "sharded":
|
||||
return ShardedRedisBroadcastChannel(_pubsub_redis_client)
|
||||
return RedisBroadcastChannel(_pubsub_redis_client)
|
||||
return ShardedRedisBroadcastChannel(redis_conn) # pyright: ignore[reportArgumentType]
|
||||
return RedisBroadcastChannel(redis_conn) # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
|
||||
@ -152,7 +152,7 @@ class RedisSubscriptionBase(Subscription):
|
||||
"""Iterator for consuming messages from the subscription."""
|
||||
while not self._closed.is_set():
|
||||
try:
|
||||
item = self._queue.get(timeout=1)
|
||||
item = self._queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from libs.broadcast_channel.channel import Producer, Subscriber, Subscription
|
||||
from redis import Redis, RedisCluster
|
||||
from redis import Redis
|
||||
|
||||
from ._subscription import RedisSubscriptionBase
|
||||
|
||||
@ -18,7 +18,7 @@ class BroadcastChannel:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
redis_client: Redis,
|
||||
):
|
||||
self._client = redis_client
|
||||
|
||||
@ -27,7 +27,7 @@ class BroadcastChannel:
|
||||
|
||||
|
||||
class Topic:
|
||||
def __init__(self, redis_client: Redis | RedisCluster, topic: str):
|
||||
def __init__(self, redis_client: Redis, topic: str):
|
||||
self._client = redis_client
|
||||
self._topic = topic
|
||||
|
||||
|
||||
@ -70,9 +70,8 @@ class _RedisShardedSubscription(RedisSubscriptionBase):
|
||||
# Since we have already filtered at the caller's site, we can safely set
|
||||
# `ignore_subscribe_messages=False`.
|
||||
if isinstance(self._client, RedisCluster):
|
||||
# NOTE(QuantumGhost): due to an issue in upstream code, calling `get_sharded_message` without
|
||||
# specifying the `target_node` argument would use busy-looping to wait
|
||||
# for incoming message, consuming excessive CPU quota.
|
||||
# NOTE(QuantumGhost): due to an issue in upstream code, calling `get_sharded_message`
|
||||
# would use busy-looping to wait for incoming message, consuming excessive CPU quota.
|
||||
#
|
||||
# Here we specify the `target_node` to mitigate this problem.
|
||||
node = self._client.get_node_from_key(self._topic)
|
||||
@ -81,10 +80,8 @@ class _RedisShardedSubscription(RedisSubscriptionBase):
|
||||
timeout=1,
|
||||
target_node=node,
|
||||
)
|
||||
elif isinstance(self._client, Redis):
|
||||
return self._pubsub.get_sharded_message(ignore_subscribe_messages=False, timeout=1) # type: ignore[attr-defined]
|
||||
else:
|
||||
raise AssertionError("client should be either Redis or RedisCluster.")
|
||||
return self._pubsub.get_sharded_message(ignore_subscribe_messages=False, timeout=1) # type: ignore[attr-defined]
|
||||
|
||||
def _get_message_type(self) -> str:
|
||||
return "smessage"
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
"""add unique constraint to tenant_default_models
|
||||
|
||||
Revision ID: fix_tenant_default_model_unique
|
||||
Revises: 9d77545f524e
|
||||
Create Date: 2026-01-19 15:07:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _is_pg(conn):
|
||||
return conn.dialect.name == "postgresql"
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f55813ffe2c8'
|
||||
down_revision = 'c3df22613c99'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# First, remove duplicate records keeping only the most recent one per (tenant_id, model_type)
|
||||
# This is necessary before adding the unique constraint
|
||||
conn = op.get_bind()
|
||||
|
||||
# Delete duplicates: keep the record with the latest updated_at for each (tenant_id, model_type)
|
||||
# If updated_at is the same, keep the one with the largest id as tiebreaker
|
||||
if _is_pg(conn):
|
||||
# PostgreSQL: Use DISTINCT ON for efficient deduplication
|
||||
conn.execute(sa.text("""
|
||||
DELETE FROM tenant_default_models
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT ON (tenant_id, model_type) id
|
||||
FROM tenant_default_models
|
||||
ORDER BY tenant_id, model_type, updated_at DESC, id DESC
|
||||
)
|
||||
"""))
|
||||
else:
|
||||
# MySQL: Use self-join to find and delete duplicates
|
||||
# Keep the record with latest updated_at (or largest id if updated_at is equal)
|
||||
conn.execute(sa.text("""
|
||||
DELETE t1 FROM tenant_default_models t1
|
||||
INNER JOIN tenant_default_models t2
|
||||
ON t1.tenant_id = t2.tenant_id
|
||||
AND t1.model_type = t2.model_type
|
||||
AND (t1.updated_at < t2.updated_at
|
||||
OR (t1.updated_at = t2.updated_at AND t1.id < t2.id))
|
||||
"""))
|
||||
|
||||
# Now add the unique constraint
|
||||
with op.batch_alter_table('tenant_default_models', schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint('unique_tenant_default_model_type', ['tenant_id', 'model_type'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('tenant_default_models', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('unique_tenant_default_model_type', type_='unique')
|
||||
@ -227,7 +227,7 @@ class App(Base):
|
||||
with Session(db.engine) as session:
|
||||
if api_provider_ids:
|
||||
existing_api_providers = [
|
||||
str(api_provider.id)
|
||||
api_provider.id
|
||||
for api_provider in session.execute(
|
||||
text("SELECT id FROM tool_api_providers WHERE id IN :provider_ids"),
|
||||
{"provider_ids": tuple(api_provider_ids)},
|
||||
|
||||
@ -181,7 +181,6 @@ class TenantDefaultModel(TypeBase):
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="tenant_default_model_pkey"),
|
||||
sa.Index("tenant_default_model_tenant_id_provider_type_idx", "tenant_id", "provider_name", "model_type"),
|
||||
sa.UniqueConstraint("tenant_id", "model_type", name="unique_tenant_default_model_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
|
||||
@ -22,7 +22,7 @@ from libs.exception import BaseHTTPException
|
||||
from models.human_input import RecipientType
|
||||
from models.model import App, AppMode
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from tasks.app_generate.workflow_execute_task import resume_app_execution
|
||||
from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE, resume_app_execution
|
||||
|
||||
|
||||
class Form:
|
||||
@ -230,6 +230,7 @@ class HumanInputService:
|
||||
try:
|
||||
resume_app_execution.apply_async(
|
||||
kwargs={"payload": payload},
|
||||
queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE,
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Failed to enqueue resume task for workflow run %s", workflow_run_id)
|
||||
|
||||
@ -129,15 +129,15 @@ def build_workflow_event_stream(
|
||||
return
|
||||
|
||||
try:
|
||||
event = buffer_state.queue.get(timeout=1)
|
||||
event = buffer_state.queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
current_time = time.time()
|
||||
if current_time - last_msg_time > idle_timeout:
|
||||
logger.debug(
|
||||
"Idle timeout of %s seconds reached, closing workflow event stream.",
|
||||
"No workflow events received for %s seconds, keeping stream open",
|
||||
idle_timeout,
|
||||
)
|
||||
return
|
||||
last_msg_time = current_time
|
||||
if current_time - last_ping_time >= ping_interval:
|
||||
yield StreamEvent.PING.value
|
||||
last_ping_time = current_time
|
||||
@ -405,7 +405,7 @@ def _start_buffering(subscription) -> BufferState:
|
||||
dropped_count = 0
|
||||
try:
|
||||
while not buffer_state.stop_event.is_set():
|
||||
msg = subscription.receive(timeout=1)
|
||||
msg = subscription.receive(timeout=0.1)
|
||||
if msg is None:
|
||||
continue
|
||||
event = _parse_event_message(msg)
|
||||
|
||||
@ -51,7 +51,7 @@ def _patch_redis_clients_on_loaded_modules():
|
||||
continue
|
||||
if hasattr(module, "redis_client"):
|
||||
module.redis_client = redis_mock
|
||||
if hasattr(module, "_pubsub_redis_client"):
|
||||
if hasattr(module, "pubsub_redis_client"):
|
||||
module.pubsub_redis_client = redis_mock
|
||||
|
||||
|
||||
@ -72,7 +72,7 @@ def _patch_redis_clients():
|
||||
|
||||
with (
|
||||
patch.object(ext_redis, "redis_client", redis_mock),
|
||||
patch.object(ext_redis, "_pubsub_redis_client", redis_mock),
|
||||
patch.object(ext_redis, "pubsub_redis_client", redis_mock),
|
||||
):
|
||||
_patch_redis_clients_on_loaded_modules()
|
||||
yield
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from controllers.console.app.conversation import _get_conversation
|
||||
|
||||
|
||||
def test_get_conversation_mark_read_keeps_updated_at_unchanged():
|
||||
app_model = SimpleNamespace(id="app-id")
|
||||
account = SimpleNamespace(id="account-id")
|
||||
conversation = MagicMock()
|
||||
conversation.id = "conversation-id"
|
||||
|
||||
with (
|
||||
patch("controllers.console.app.conversation.current_account_with_tenant", return_value=(account, None)),
|
||||
patch("controllers.console.app.conversation.naive_utc_now", return_value=datetime(2026, 2, 9, 0, 0, 0)),
|
||||
patch("controllers.console.app.conversation.db.session") as mock_session,
|
||||
):
|
||||
mock_session.query.return_value.where.return_value.first.return_value = conversation
|
||||
|
||||
_get_conversation(app_model, "conversation-id")
|
||||
|
||||
statement = mock_session.execute.call_args[0][0]
|
||||
compiled = statement.compile()
|
||||
sql_text = str(compiled).lower()
|
||||
compact_sql_text = sql_text.replace(" ", "")
|
||||
params = compiled.params
|
||||
|
||||
assert "updated_at=current_timestamp" not in compact_sql_text
|
||||
assert "updated_at=conversations.updated_at" in compact_sql_text
|
||||
assert "read_at=:read_at" in compact_sql_text
|
||||
assert "read_account_id=:read_account_id" in compact_sql_text
|
||||
assert params["read_at"] == datetime(2026, 2, 9, 0, 0, 0)
|
||||
assert params["read_account_id"] == "account-id"
|
||||
@ -25,19 +25,15 @@ class TestMessageCycleManagerOptimization:
|
||||
task_state = Mock()
|
||||
return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state)
|
||||
|
||||
def test_get_message_event_type_with_assistant_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has assistant-generated files.
|
||||
|
||||
This ensures that AI-generated images (belongs_to='assistant') trigger the MESSAGE_FILE event,
|
||||
allowing the frontend to properly display generated image files with url field.
|
||||
"""
|
||||
def test_get_message_event_type_with_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has files."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute
|
||||
@ -48,31 +44,6 @@ class TestMessageCycleManagerOptimization:
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_with_user_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE when message only has user-uploaded files.
|
||||
|
||||
This is a regression test for the issue where user-uploaded images (belongs_to='user')
|
||||
caused the LLM text response to be incorrectly tagged with MESSAGE_FILE event,
|
||||
resulting in broken images in the chat UI. The query filters for belongs_to='assistant',
|
||||
so when only user files exist, the database query returns None, resulting in MESSAGE event type.
|
||||
"""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# When querying for assistant files with only user files present, return None
|
||||
# (simulates database query with belongs_to='assistant' filter returning no results)
|
||||
mock_session.scalar.return_value = None
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_without_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE when message has no files."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
@ -98,7 +69,7 @@ class TestMessageCycleManagerOptimization:
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute: compute event type once, then pass to message_to_stream_response
|
||||
|
||||
@ -198,15 +198,6 @@ class SubscriptionTestCase:
|
||||
description: str = ""
|
||||
|
||||
|
||||
class FakeRedisClient:
|
||||
"""Minimal fake Redis client for unit tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.publish = MagicMock()
|
||||
self.spublish = MagicMock()
|
||||
self.pubsub = MagicMock(return_value=MagicMock())
|
||||
|
||||
|
||||
class TestRedisSubscription:
|
||||
"""Test cases for the _RedisSubscription class."""
|
||||
|
||||
@ -628,13 +619,10 @@ class TestRedisSubscription:
|
||||
class TestRedisShardedSubscription:
|
||||
"""Test cases for the _RedisShardedSubscription class."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_sharded_redis_type(self, monkeypatch):
|
||||
monkeypatch.setattr("libs.broadcast_channel.redis.sharded_channel.Redis", FakeRedisClient)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(self) -> FakeRedisClient:
|
||||
return FakeRedisClient()
|
||||
def mock_redis_client(self) -> MagicMock:
|
||||
client = MagicMock()
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pubsub(self) -> MagicMock:
|
||||
@ -648,7 +636,7 @@ class TestRedisShardedSubscription:
|
||||
|
||||
@pytest.fixture
|
||||
def sharded_subscription(
|
||||
self, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient
|
||||
self, mock_pubsub: MagicMock, mock_redis_client: MagicMock
|
||||
) -> Generator[_RedisShardedSubscription, None, None]:
|
||||
"""Create a _RedisShardedSubscription instance for testing."""
|
||||
subscription = _RedisShardedSubscription(
|
||||
@ -669,7 +657,7 @@ class TestRedisShardedSubscription:
|
||||
|
||||
# ==================== Lifecycle Tests ====================
|
||||
|
||||
def test_sharded_subscription_initialization(self, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient):
|
||||
def test_sharded_subscription_initialization(self, mock_pubsub: MagicMock, mock_redis_client: MagicMock):
|
||||
"""Test that sharded subscription is properly initialized."""
|
||||
subscription = _RedisShardedSubscription(
|
||||
client=mock_redis_client,
|
||||
@ -982,7 +970,7 @@ class TestRedisShardedSubscription:
|
||||
],
|
||||
)
|
||||
def test_sharded_subscription_scenarios(
|
||||
self, test_case: SubscriptionTestCase, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient
|
||||
self, test_case: SubscriptionTestCase, mock_pubsub: MagicMock, mock_redis_client: MagicMock
|
||||
):
|
||||
"""Test various sharded subscription scenarios using table-driven approach."""
|
||||
subscription = _RedisShardedSubscription(
|
||||
@ -1070,7 +1058,7 @@ class TestRedisShardedSubscription:
|
||||
# Close should still work
|
||||
sharded_subscription.close() # Should not raise
|
||||
|
||||
def test_channel_name_variations(self, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient):
|
||||
def test_channel_name_variations(self, mock_pubsub: MagicMock, mock_redis_client: MagicMock):
|
||||
"""Test various sharded channel name formats."""
|
||||
channel_names = [
|
||||
"simple",
|
||||
@ -1132,13 +1120,10 @@ class TestRedisSubscriptionCommon:
|
||||
"""Parameterized fixture providing subscription type and class."""
|
||||
return request.param
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_sharded_redis_type(self, monkeypatch):
|
||||
monkeypatch.setattr("libs.broadcast_channel.redis.sharded_channel.Redis", FakeRedisClient)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(self) -> FakeRedisClient:
|
||||
return FakeRedisClient()
|
||||
def mock_redis_client(self) -> MagicMock:
|
||||
client = MagicMock()
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pubsub(self) -> MagicMock:
|
||||
@ -1155,7 +1140,7 @@ class TestRedisSubscriptionCommon:
|
||||
return pubsub
|
||||
|
||||
@pytest.fixture
|
||||
def subscription(self, subscription_params, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient):
|
||||
def subscription(self, subscription_params, mock_pubsub: MagicMock, mock_redis_client: MagicMock):
|
||||
"""Create a subscription instance based on parameterized type."""
|
||||
subscription_type, subscription_class = subscription_params
|
||||
topic_name = f"test-{subscription_type}-topic"
|
||||
|
||||
@ -17,6 +17,7 @@ from core.workflow.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus
|
||||
from models.human_input import RecipientType
|
||||
from services.human_input_service import Form, FormExpiredError, HumanInputService, InvalidFormDataError
|
||||
from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -87,6 +88,7 @@ def test_enqueue_resume_dispatches_task_for_workflow(mocker, mock_session_factor
|
||||
|
||||
resume_task.apply_async.assert_called_once()
|
||||
call_kwargs = resume_task.apply_async.call_args.kwargs
|
||||
assert call_kwargs["queue"] == WORKFLOW_BASED_APP_EXECUTION_QUEUE
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
@ -128,6 +130,7 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(mocker, mock_session_f
|
||||
|
||||
resume_task.apply_async.assert_called_once()
|
||||
call_kwargs = resume_task.apply_async.call_args.kwargs
|
||||
assert call_kwargs["queue"] == WORKFLOW_BASED_APP_EXECUTION_QUEUE
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
|
||||
@ -106,10 +106,10 @@ if [[ -z "${QUEUES}" ]]; then
|
||||
# Configure queues based on edition
|
||||
if [[ "${EDITION}" == "CLOUD" ]]; then
|
||||
# Cloud edition: separate queues for dataset and trigger tasks
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
else
|
||||
# Community edition (SELF_HOSTED): dataset and workflow have separate queues
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
fi
|
||||
|
||||
echo "No queues specified, using edition-based defaults: ${QUEUES}"
|
||||
|
||||
@ -62,9 +62,6 @@ LANG=C.UTF-8
|
||||
LC_ALL=C.UTF-8
|
||||
PYTHONIOENCODING=utf-8
|
||||
|
||||
# Set UV cache directory to avoid permission issues with non-existent home directory
|
||||
UV_CACHE_DIR=/tmp/.uv-cache
|
||||
|
||||
# ------------------------------
|
||||
# Server Configuration
|
||||
# ------------------------------
|
||||
@ -387,8 +384,6 @@ CELERY_USE_SENTINEL=false
|
||||
CELERY_SENTINEL_MASTER_NAME=
|
||||
CELERY_SENTINEL_PASSWORD=
|
||||
CELERY_SENTINEL_SOCKET_TIMEOUT=0.1
|
||||
# e.g. {"tasks.add": {"rate_limit": "10/s"}}
|
||||
CELERY_TASK_ANNOTATIONS=null
|
||||
|
||||
# ------------------------------
|
||||
# CORS Configuration
|
||||
|
||||
@ -16,7 +16,6 @@ x-shared-env: &shared-api-worker-env
|
||||
LANG: ${LANG:-C.UTF-8}
|
||||
LC_ALL: ${LC_ALL:-C.UTF-8}
|
||||
PYTHONIOENCODING: ${PYTHONIOENCODING:-utf-8}
|
||||
UV_CACHE_DIR: ${UV_CACHE_DIR:-/tmp/.uv-cache}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text}
|
||||
LOG_FILE: ${LOG_FILE:-/app/logs/server.log}
|
||||
@ -106,7 +105,6 @@ x-shared-env: &shared-api-worker-env
|
||||
CELERY_SENTINEL_MASTER_NAME: ${CELERY_SENTINEL_MASTER_NAME:-}
|
||||
CELERY_SENTINEL_PASSWORD: ${CELERY_SENTINEL_PASSWORD:-}
|
||||
CELERY_SENTINEL_SOCKET_TIMEOUT: ${CELERY_SENTINEL_SOCKET_TIMEOUT:-0.1}
|
||||
CELERY_TASK_ANNOTATIONS: ${CELERY_TASK_ANNOTATIONS:-null}
|
||||
WEB_API_CORS_ALLOW_ORIGINS: ${WEB_API_CORS_ALLOW_ORIGINS:-*}
|
||||
CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
|
||||
8
sdks/nodejs-client/pnpm-lock.yaml
generated
8
sdks/nodejs-client/pnpm-lock.yaml
generated
@ -10,7 +10,7 @@ importers:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.5
|
||||
version: 1.13.2
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.2
|
||||
@ -544,8 +544,8 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
axios@1.13.5:
|
||||
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
@ -1677,7 +1677,7 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.13.5:
|
||||
axios@1.13.2:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
271
web/__tests__/plugins/plugin-auth-flow.test.tsx
Normal file
271
web/__tests__/plugins/plugin-auth-flow.test.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
224
web/__tests__/plugins/plugin-card-rendering.test.tsx
Normal file
224
web/__tests__/plugins/plugin-card-rendering.test.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
159
web/__tests__/plugins/plugin-data-utilities.test.ts
Normal file
159
web/__tests__/plugins/plugin-data-utilities.test.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
269
web/__tests__/plugins/plugin-install-flow.test.ts
Normal file
269
web/__tests__/plugins/plugin-install-flow.test.ts
Normal file
@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
97
web/__tests__/plugins/plugin-marketplace-to-install.test.tsx
Normal file
97
web/__tests__/plugins/plugin-marketplace-to-install.test.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
120
web/__tests__/plugins/plugin-page-filter-management.test.tsx
Normal file
120
web/__tests__/plugins/plugin-page-filter-management.test.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
369
web/__tests__/tools/tool-browsing-and-filtering.test.tsx
Normal file
369
web/__tests__/tools/tool-browsing-and-filtering.test.tsx
Normal file
@ -0,0 +1,369 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
239
web/__tests__/tools/tool-data-processing.test.ts
Normal file
239
web/__tests__/tools/tool-data-processing.test.ts
Normal file
@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
548
web/__tests__/tools/tool-provider-detail-flow.test.tsx
Normal file
548
web/__tests__/tools/tool-provider-detail-flow.test.tsx
Normal file
@ -0,0 +1,548 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
261
web/__tests__/workflow-parallel-limit.test.tsx
Normal file
261
web/__tests__/workflow-parallel-limit.test.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
/**
|
||||
* MAX_PARALLEL_LIMIT Configuration Bug Test
|
||||
*
|
||||
* This test reproduces and verifies the fix for issue #23083:
|
||||
* MAX_PARALLEL_LIMIT environment variable does not take effect in iteration panel
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
// Mock environment variables before importing constants
|
||||
const originalEnv = process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
// Test with different environment values
|
||||
function setupEnvironment(value?: string) {
|
||||
if (value)
|
||||
process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = value
|
||||
else
|
||||
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
// Clear module cache to force re-evaluation
|
||||
vi.resetModules()
|
||||
}
|
||||
|
||||
function restoreEnvironment() {
|
||||
if (originalEnv)
|
||||
process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = originalEnv
|
||||
else
|
||||
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
vi.resetModules()
|
||||
}
|
||||
|
||||
// Mock i18next with proper implementation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
if (key.includes('MaxParallelismTitle'))
|
||||
return 'Max Parallelism'
|
||||
if (key.includes('MaxParallelismDesc'))
|
||||
return 'Maximum number of parallel executions'
|
||||
if (key.includes('parallelMode'))
|
||||
return 'Parallel Mode'
|
||||
if (key.includes('parallelPanelDesc'))
|
||||
return 'Enable parallel execution'
|
||||
if (key.includes('errorResponseMethod'))
|
||||
return 'Error Response Method'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock i18next module completely to prevent initialization issues
|
||||
vi.mock('i18next', () => ({
|
||||
use: vi.fn().mockReturnThis(),
|
||||
init: vi.fn().mockReturnThis(),
|
||||
t: vi.fn(key => key),
|
||||
isInitialized: true,
|
||||
}))
|
||||
|
||||
// Mock the useConfig hook
|
||||
vi.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
|
||||
default: () => ({
|
||||
inputs: {
|
||||
is_parallel: true,
|
||||
parallel_nums: 5,
|
||||
error_handle_mode: 'terminated',
|
||||
},
|
||||
changeParallel: vi.fn(),
|
||||
changeParallelNums: vi.fn(),
|
||||
changeErrorHandleMode: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock other components
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: function MockVarReferencePicker() {
|
||||
return <div data-testid="var-reference-picker">VarReferencePicker</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
default: function MockSplit() {
|
||||
return <div data-testid="split">Split</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
default: function MockField({ title, children }: { title: string, children: React.ReactNode }) {
|
||||
return (
|
||||
<div data-testid="field">
|
||||
<label>{title}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const getParallelControls = () => ({
|
||||
numberInput: screen.getByRole('spinbutton'),
|
||||
slider: screen.getByRole('slider'),
|
||||
})
|
||||
|
||||
describe('MAX_PARALLEL_LIMIT Configuration Bug', () => {
|
||||
const mockNodeData = {
|
||||
id: 'test-iteration-node',
|
||||
type: 'iteration' as const,
|
||||
data: {
|
||||
title: 'Test Iteration',
|
||||
desc: 'Test iteration node',
|
||||
iterator_selector: ['test'],
|
||||
output_selector: ['output'],
|
||||
is_parallel: true,
|
||||
parallel_nums: 5,
|
||||
error_handle_mode: 'terminated' as const,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnvironment()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnvironment()
|
||||
})
|
||||
|
||||
describe('Environment Variable Parsing', () => {
|
||||
it('should parse MAX_PARALLEL_LIMIT from NEXT_PUBLIC_MAX_PARALLEL_LIMIT environment variable', async () => {
|
||||
setupEnvironment('25')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(25)
|
||||
})
|
||||
|
||||
it('should fallback to default when environment variable is not set', async () => {
|
||||
setupEnvironment() // No environment variable
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle invalid environment variable values', async () => {
|
||||
setupEnvironment('invalid')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
// Should fall back to default when parsing fails
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle empty environment variable', async () => {
|
||||
setupEnvironment('')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
// Should fall back to default when empty
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
// Edge cases for boundary values
|
||||
it('should clamp MAX_PARALLEL_LIMIT to MIN when env is 0 or negative', async () => {
|
||||
setupEnvironment('0')
|
||||
let { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
|
||||
|
||||
setupEnvironment('-5')
|
||||
;({ MAX_PARALLEL_LIMIT } = await import('@/config'))
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
|
||||
})
|
||||
|
||||
it('should handle float numbers by parseInt behavior', async () => {
|
||||
setupEnvironment('12.7')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
// parseInt truncates to integer
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI Component Integration (Main Fix Verification)', () => {
|
||||
it('should render iteration panel with environment-configured max value', async () => {
|
||||
// Set environment variable to a different value
|
||||
setupEnvironment('30')
|
||||
|
||||
// Import Panel after setting environment
|
||||
const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default)
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="test-node"
|
||||
// @ts-expect-error key type mismatch
|
||||
data={mockNodeData.data}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Behavior-focused assertion: UI max should equal MAX_PARALLEL_LIMIT
|
||||
const { numberInput, slider } = getParallelControls()
|
||||
expect(numberInput).toHaveAttribute('max', String(MAX_PARALLEL_LIMIT))
|
||||
expect(slider).toHaveAttribute('aria-valuemax', String(MAX_PARALLEL_LIMIT))
|
||||
|
||||
// Verify the actual values
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(30)
|
||||
expect(numberInput.getAttribute('max')).toBe('30')
|
||||
expect(slider.getAttribute('aria-valuemax')).toBe('30')
|
||||
})
|
||||
|
||||
it('should maintain UI consistency with different environment values', async () => {
|
||||
setupEnvironment('15')
|
||||
const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default)
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="test-node"
|
||||
// @ts-expect-error key type mismatch
|
||||
data={mockNodeData.data}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Both input and slider should use the same max value from MAX_PARALLEL_LIMIT
|
||||
const { numberInput, slider } = getParallelControls()
|
||||
|
||||
expect(numberInput.getAttribute('max')).toBe(slider.getAttribute('aria-valuemax'))
|
||||
expect(numberInput.getAttribute('max')).toBe(String(MAX_PARALLEL_LIMIT))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Legacy Constant Verification (For Transition Period)', () => {
|
||||
// Marked as transition/deprecation tests
|
||||
it('should maintain MAX_ITERATION_PARALLEL_NUM for backward compatibility', async () => {
|
||||
const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
|
||||
expect(typeof MAX_ITERATION_PARALLEL_NUM).toBe('number')
|
||||
expect(MAX_ITERATION_PARALLEL_NUM).toBe(10) // Hardcoded legacy value
|
||||
})
|
||||
|
||||
it('should demonstrate MAX_PARALLEL_LIMIT vs legacy constant difference', async () => {
|
||||
setupEnvironment('50')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
|
||||
|
||||
// MAX_PARALLEL_LIMIT is configurable, MAX_ITERATION_PARALLEL_NUM is not
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(50)
|
||||
expect(MAX_ITERATION_PARALLEL_NUM).toBe(10)
|
||||
expect(MAX_PARALLEL_LIMIT).not.toBe(MAX_ITERATION_PARALLEL_NUM)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Constants Validation', () => {
|
||||
it('should validate that required constants exist and have correct types', async () => {
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
const { MIN_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
|
||||
expect(typeof MAX_PARALLEL_LIMIT).toBe('number')
|
||||
expect(typeof MIN_ITERATION_PARALLEL_NUM).toBe('number')
|
||||
expect(MAX_PARALLEL_LIMIT).toBeGreaterThanOrEqual(MIN_ITERATION_PARALLEL_NUM)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -8,7 +8,6 @@ import { UserActionButtonType } from '@/app/components/workflow/nodes/human-inpu
|
||||
import 'dayjs/locale/en'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/ja'
|
||||
import 'dayjs/locale/nl'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(relativeTime)
|
||||
@ -46,7 +45,6 @@ const localeMap: Record<string, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-Hans': 'zh-cn',
|
||||
'ja-JP': 'ja',
|
||||
'nl-NL': 'nl',
|
||||
}
|
||||
|
||||
export const getRelativeTime = (
|
||||
|
||||
@ -98,9 +98,7 @@ const VoiceParamConfig = ({
|
||||
className="h-full w-full cursor-pointer rounded-lg border-0 bg-components-input-bg-normal py-1.5 pl-3 pr-10 focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span className={cn('block truncate text-left text-text-secondary', !languageItem?.name && 'text-text-tertiary')}>
|
||||
{languageItem?.name
|
||||
? t(`voice.language.${replace(languageItem?.value ?? '', '-', '')}`, languageItem?.name, { ns: 'common' as const })
|
||||
: localLanguagePlaceholder}
|
||||
{languageItem?.name ? t(`voice.language.${replace(languageItem?.value, '-', '')}`, { ns: 'common' }) : localLanguagePlaceholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
@ -131,7 +129,7 @@ const VoiceParamConfig = ({
|
||||
<span
|
||||
className={cn('block', selected && 'font-normal')}
|
||||
>
|
||||
{t(`voice.language.${replace((item.value), '-', '')}`, item.name, { ns: 'common' as const })}
|
||||
{t(`voice.language.${replace((item.value), '-', '')}`, { ns: 'common' })}
|
||||
</span>
|
||||
{(selected || item.value === text2speech?.language) && (
|
||||
<span
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
import * as z from 'zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const InputTypeEnum = z.enum([
|
||||
'text-input',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ZodNumber, ZodSchema, ZodString } from 'zod'
|
||||
import type { BaseConfiguration } from './types'
|
||||
import * as z from 'zod'
|
||||
import { z } from 'zod'
|
||||
import { BaseFieldType } from './types'
|
||||
|
||||
export const generateZodSchema = (fields: BaseConfiguration[]) => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import * as z from 'zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
const ContactMethod = z.union([
|
||||
z.literal('email'),
|
||||
@ -22,10 +22,10 @@ export const UserSchema = z.object({
|
||||
.min(3, 'Surname must be at least 3 characters long')
|
||||
.regex(/^[A-Z]/, 'Surname must start with a capital letter'),
|
||||
isAcceptingTerms: z.boolean().refine(val => val, {
|
||||
error: 'You must accept the terms and conditions',
|
||||
message: 'You must accept the terms and conditions',
|
||||
}),
|
||||
contact: z.object({
|
||||
email: z.email('Invalid email address'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
phone: z.string().optional(),
|
||||
preferredContactMethod: ContactMethod,
|
||||
}),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ZodSchema, ZodString } from 'zod'
|
||||
import type { InputFieldConfiguration } from './types'
|
||||
import * as z from 'zod'
|
||||
import { z } from 'zod'
|
||||
import { SupportedFileTypes, TransferMethod } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/schema'
|
||||
import { InputFieldType } from './types'
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { env } from '@/env'
|
||||
import ParamItem from '.'
|
||||
|
||||
type Props = {
|
||||
@ -12,7 +11,12 @@ type Props = {
|
||||
enable: boolean
|
||||
}
|
||||
|
||||
const maxTopK = env.NEXT_PUBLIC_TOP_K_MAX_VALUE
|
||||
const maxTopK = (() => {
|
||||
const configValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-top-k-max-value') || '', 10)
|
||||
if (configValue && !isNaN(configValue))
|
||||
return configValue
|
||||
return 10
|
||||
})()
|
||||
const VALUE_LIMIT = {
|
||||
default: 2,
|
||||
step: 1,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as z from 'zod'
|
||||
import { z } from 'zod'
|
||||
import withValidation from '.'
|
||||
|
||||
describe('withValidation HOC', () => {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import * as z from 'zod'
|
||||
import { z } from 'zod'
|
||||
import withValidation from '.'
|
||||
|
||||
// Sample components to wrap with validation
|
||||
@ -65,7 +65,7 @@ const ProductCard = ({ name, price, category, inStock }: ProductCardProps) => {
|
||||
// Create validated versions
|
||||
const userSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.email('Invalid email'),
|
||||
email: z.string().email('Invalid email'),
|
||||
age: z.number().min(0).max(150),
|
||||
})
|
||||
|
||||
@ -371,7 +371,7 @@ export const ConfigurationValidation: Story = {
|
||||
)
|
||||
|
||||
const configSchema = z.object({
|
||||
apiUrl: z.url('Must be valid URL'),
|
||||
apiUrl: z.string().url('Must be valid URL'),
|
||||
timeout: z.number().min(0).max(30000),
|
||||
retries: z.number().min(0).max(5),
|
||||
debug: z.boolean(),
|
||||
@ -430,7 +430,7 @@ export const UsageDocumentation: Story = {
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-900">Usage Example</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
|
||||
{`import * as z from 'zod'
|
||||
{`import { z } from 'zod'
|
||||
import withValidation from './withValidation'
|
||||
|
||||
// Define your component
|
||||
|
||||
@ -1,141 +0,0 @@
|
||||
import { ALL_PLANS, contactSalesUrl, contractSales, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE, unAvailable } from '../config'
|
||||
import { Priority } from '../type'
|
||||
|
||||
describe('Billing Config', () => {
|
||||
describe('Constants', () => {
|
||||
it('should define NUM_INFINITE as -1', () => {
|
||||
expect(NUM_INFINITE).toBe(-1)
|
||||
})
|
||||
|
||||
it('should define contractSales string', () => {
|
||||
expect(contractSales).toBe('contractSales')
|
||||
})
|
||||
|
||||
it('should define unAvailable string', () => {
|
||||
expect(unAvailable).toBe('unAvailable')
|
||||
})
|
||||
|
||||
it('should define valid URL constants', () => {
|
||||
expect(contactSalesUrl).toMatch(/^https:\/\//)
|
||||
expect(getStartedWithCommunityUrl).toMatch(/^https:\/\//)
|
||||
expect(getWithPremiumUrl).toMatch(/^https:\/\//)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ALL_PLANS', () => {
|
||||
const requiredFields: (keyof typeof ALL_PLANS.sandbox)[] = [
|
||||
'level',
|
||||
'price',
|
||||
'modelProviders',
|
||||
'teamWorkspace',
|
||||
'teamMembers',
|
||||
'buildApps',
|
||||
'documents',
|
||||
'vectorSpace',
|
||||
'documentsUploadQuota',
|
||||
'documentsRequestQuota',
|
||||
'apiRateLimit',
|
||||
'documentProcessingPriority',
|
||||
'messageRequest',
|
||||
'triggerEvents',
|
||||
'annotatedResponse',
|
||||
'logHistory',
|
||||
]
|
||||
|
||||
it.each(['sandbox', 'professional', 'team'] as const)('should have all required fields for %s plan', (planKey) => {
|
||||
const plan = ALL_PLANS[planKey]
|
||||
for (const field of requiredFields)
|
||||
expect(plan[field]).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have ascending plan levels: sandbox < professional < team', () => {
|
||||
expect(ALL_PLANS.sandbox.level).toBeLessThan(ALL_PLANS.professional.level)
|
||||
expect(ALL_PLANS.professional.level).toBeLessThan(ALL_PLANS.team.level)
|
||||
})
|
||||
|
||||
it('should have ascending plan prices: sandbox < professional < team', () => {
|
||||
expect(ALL_PLANS.sandbox.price).toBeLessThan(ALL_PLANS.professional.price)
|
||||
expect(ALL_PLANS.professional.price).toBeLessThan(ALL_PLANS.team.price)
|
||||
})
|
||||
|
||||
it('should have sandbox as the free plan', () => {
|
||||
expect(ALL_PLANS.sandbox.price).toBe(0)
|
||||
})
|
||||
|
||||
it('should have ascending team member limits', () => {
|
||||
expect(ALL_PLANS.sandbox.teamMembers).toBeLessThan(ALL_PLANS.professional.teamMembers)
|
||||
expect(ALL_PLANS.professional.teamMembers).toBeLessThan(ALL_PLANS.team.teamMembers)
|
||||
})
|
||||
|
||||
it('should have ascending document processing priority', () => {
|
||||
expect(ALL_PLANS.sandbox.documentProcessingPriority).toBe(Priority.standard)
|
||||
expect(ALL_PLANS.professional.documentProcessingPriority).toBe(Priority.priority)
|
||||
expect(ALL_PLANS.team.documentProcessingPriority).toBe(Priority.topPriority)
|
||||
})
|
||||
|
||||
it('should have unlimited API rate limit for professional and team plans', () => {
|
||||
expect(ALL_PLANS.sandbox.apiRateLimit).not.toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.professional.apiRateLimit).toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.team.apiRateLimit).toBe(NUM_INFINITE)
|
||||
})
|
||||
|
||||
it('should have unlimited log history for professional and team plans', () => {
|
||||
expect(ALL_PLANS.professional.logHistory).toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.team.logHistory).toBe(NUM_INFINITE)
|
||||
})
|
||||
|
||||
it('should have unlimited trigger events only for team plan', () => {
|
||||
expect(ALL_PLANS.sandbox.triggerEvents).not.toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.professional.triggerEvents).not.toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.team.triggerEvents).toBe(NUM_INFINITE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultPlan', () => {
|
||||
it('should default to sandbox plan type', () => {
|
||||
expect(defaultPlan.type).toBe('sandbox')
|
||||
})
|
||||
|
||||
it('should have usage object with all required fields', () => {
|
||||
const { usage } = defaultPlan
|
||||
expect(usage).toHaveProperty('documents')
|
||||
expect(usage).toHaveProperty('vectorSpace')
|
||||
expect(usage).toHaveProperty('buildApps')
|
||||
expect(usage).toHaveProperty('teamMembers')
|
||||
expect(usage).toHaveProperty('annotatedResponse')
|
||||
expect(usage).toHaveProperty('documentsUploadQuota')
|
||||
expect(usage).toHaveProperty('apiRateLimit')
|
||||
expect(usage).toHaveProperty('triggerEvents')
|
||||
})
|
||||
|
||||
it('should have total object with all required fields', () => {
|
||||
const { total } = defaultPlan
|
||||
expect(total).toHaveProperty('documents')
|
||||
expect(total).toHaveProperty('vectorSpace')
|
||||
expect(total).toHaveProperty('buildApps')
|
||||
expect(total).toHaveProperty('teamMembers')
|
||||
expect(total).toHaveProperty('annotatedResponse')
|
||||
expect(total).toHaveProperty('documentsUploadQuota')
|
||||
expect(total).toHaveProperty('apiRateLimit')
|
||||
expect(total).toHaveProperty('triggerEvents')
|
||||
})
|
||||
|
||||
it('should use sandbox plan API rate limit and trigger events in total', () => {
|
||||
expect(defaultPlan.total.apiRateLimit).toBe(ALL_PLANS.sandbox.apiRateLimit)
|
||||
expect(defaultPlan.total.triggerEvents).toBe(ALL_PLANS.sandbox.triggerEvents)
|
||||
})
|
||||
|
||||
it('should have reset info with null values', () => {
|
||||
expect(defaultPlan.reset.apiRateLimit).toBeNull()
|
||||
expect(defaultPlan.reset.triggerEvents).toBeNull()
|
||||
})
|
||||
|
||||
it('should have usage values not exceeding totals', () => {
|
||||
expect(defaultPlan.usage.documents).toBeLessThanOrEqual(defaultPlan.total.documents)
|
||||
expect(defaultPlan.usage.vectorSpace).toBeLessThanOrEqual(defaultPlan.total.vectorSpace)
|
||||
expect(defaultPlan.usage.buildApps).toBeLessThanOrEqual(defaultPlan.total.buildApps)
|
||||
expect(defaultPlan.usage.teamMembers).toBeLessThanOrEqual(defaultPlan.total.teamMembers)
|
||||
expect(defaultPlan.usage.annotatedResponse).toBeLessThanOrEqual(defaultPlan.total.annotatedResponse)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import AnnotationFull from '../index'
|
||||
import AnnotationFull from './index'
|
||||
|
||||
vi.mock('../usage', () => ({
|
||||
vi.mock('./usage', () => ({
|
||||
default: (props: { className?: string }) => {
|
||||
return (
|
||||
<div data-testid="usage-component" data-classname={props.className ?? ''}>
|
||||
@ -11,7 +11,7 @@ vi.mock('../usage', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: (props: { loc?: string }) => {
|
||||
return (
|
||||
<button type="button" data-testid="upgrade-btn">
|
||||
@ -29,21 +29,27 @@ describe('AnnotationFull', () => {
|
||||
// Rendering marketing copy with action button
|
||||
describe('Rendering', () => {
|
||||
it('should render tips when rendered', () => {
|
||||
// Act
|
||||
render(<AnnotationFull />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render upgrade button when rendered', () => {
|
||||
// Act
|
||||
render(<AnnotationFull />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Usage component when rendered', () => {
|
||||
// Act
|
||||
render(<AnnotationFull />)
|
||||
|
||||
// Assert
|
||||
const usageComponent = screen.getByTestId('usage-component')
|
||||
expect(usageComponent).toBeInTheDocument()
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AnnotationFullModal from '../modal'
|
||||
import AnnotationFullModal from './modal'
|
||||
|
||||
vi.mock('../usage', () => ({
|
||||
vi.mock('./usage', () => ({
|
||||
default: (props: { className?: string }) => {
|
||||
return (
|
||||
<div data-testid="usage-component" data-classname={props.className ?? ''}>
|
||||
@ -12,7 +12,7 @@ vi.mock('../usage', () => ({
|
||||
}))
|
||||
|
||||
let mockUpgradeBtnProps: { loc?: string } | null = null
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: (props: { loc?: string }) => {
|
||||
mockUpgradeBtnProps = props
|
||||
return (
|
||||
@ -29,7 +29,7 @@ type ModalSnapshot = {
|
||||
className?: string
|
||||
}
|
||||
let mockModalProps: ModalSnapshot | null = null
|
||||
vi.mock('../../../base/modal', () => ({
|
||||
vi.mock('../../base/modal', () => ({
|
||||
default: ({ isShow, children, onClose, closable, className }: { isShow: boolean, children: React.ReactNode, onClose: () => void, closable?: boolean, className?: string }) => {
|
||||
mockModalProps = {
|
||||
isShow,
|
||||
@ -61,8 +61,10 @@ describe('AnnotationFullModal', () => {
|
||||
// Rendering marketing copy inside modal
|
||||
describe('Rendering', () => {
|
||||
it('should display main info when visible', () => {
|
||||
// Act
|
||||
render(<AnnotationFullModal show onHide={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('usage-component')).toHaveAttribute('data-classname', 'mt-4')
|
||||
@ -79,8 +81,10 @@ describe('AnnotationFullModal', () => {
|
||||
// Controlling modal visibility
|
||||
describe('Visibility', () => {
|
||||
it('should not render content when hidden', () => {
|
||||
// Act
|
||||
const { container } = render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(mockModalProps).toEqual(expect.objectContaining({ isShow: false }))
|
||||
})
|
||||
@ -89,11 +93,14 @@ describe('AnnotationFullModal', () => {
|
||||
// Handling close interactions
|
||||
describe('Close handling', () => {
|
||||
it('should trigger onHide when close control is clicked', () => {
|
||||
// Arrange
|
||||
const onHide = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<AnnotationFullModal show onHide={onHide} />)
|
||||
fireEvent.click(screen.getByTestId('mock-modal-close'))
|
||||
|
||||
// Assert
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Usage from '../usage'
|
||||
import Usage from './usage'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockPlan = {
|
||||
usage: {
|
||||
@ -17,25 +23,33 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}))
|
||||
|
||||
describe('Usage', () => {
|
||||
// Rendering: renders UsageInfo with correct props from context
|
||||
describe('Rendering', () => {
|
||||
it('should render usage info with data from provider context', () => {
|
||||
// Arrange & Act
|
||||
render(<Usage />)
|
||||
|
||||
expect(screen.getByText('billing.annotatedResponse.quotaTitle')).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText('annotatedResponse.quotaTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass className to UsageInfo component', () => {
|
||||
// Arrange
|
||||
const testClassName = 'mt-4'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Usage className={testClassName} />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass(testClassName)
|
||||
})
|
||||
|
||||
it('should display usage and total values from context', () => {
|
||||
// Arrange & Act
|
||||
render(<Usage />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('50')).toBeInTheDocument()
|
||||
expect(screen.getByText('100')).toBeInTheDocument()
|
||||
})
|
||||
@ -8,7 +8,7 @@ import { Plan } from '@/app/components/billing/type'
|
||||
import { mailToSupport } from '@/app/components/header/utils/util'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import AppsFull from '../index'
|
||||
import AppsFull from './index'
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
@ -120,8 +120,10 @@ describe('AppsFull', () => {
|
||||
// Rendering behavior for non-team plans.
|
||||
describe('Rendering', () => {
|
||||
it('should render the sandbox messaging and upgrade button', () => {
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.apps.fullTip1des')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
|
||||
@ -129,8 +131,10 @@ describe('AppsFull', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior for team plans and contact CTA.
|
||||
describe('Props', () => {
|
||||
it('should render team messaging and contact button for non-sandbox plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@ -145,6 +149,7 @@ describe('AppsFull', () => {
|
||||
}))
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
@ -153,6 +158,7 @@ describe('AppsFull', () => {
|
||||
})
|
||||
|
||||
it('should render upgrade button for professional plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@ -166,14 +172,17 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.apps.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render contact button for enterprise plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@ -187,8 +196,10 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com')
|
||||
@ -196,8 +207,10 @@ describe('AppsFull', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases for progress color thresholds.
|
||||
describe('Edge Cases', () => {
|
||||
it('should use the success color when usage is below 50%', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@ -211,12 +224,15 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid')
|
||||
})
|
||||
|
||||
it('should use the warning color when usage is between 50% and 80%', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@ -230,12 +246,15 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress')
|
||||
})
|
||||
|
||||
it('should use the error color when usage is 80% or higher', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@ -249,8 +268,10 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Billing from '../index'
|
||||
import Billing from './index'
|
||||
|
||||
let currentBillingUrl: string | null = 'https://billing'
|
||||
let fetching = false
|
||||
@ -33,7 +33,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../plan', () => ({
|
||||
vi.mock('../plan', () => ({
|
||||
default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />,
|
||||
}))
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { Plan } from '../../type'
|
||||
import HeaderBillingBtn from '../index'
|
||||
import { Plan } from '../type'
|
||||
import HeaderBillingBtn from './index'
|
||||
|
||||
type HeaderGlobal = typeof globalThis & {
|
||||
__mockProviderContext?: ReturnType<typeof vi.fn>
|
||||
@ -26,7 +26,7 @@ vi.mock('@/context/provider-context', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: () => <button data-testid="upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
@ -70,42 +70,6 @@ describe('HeaderBillingBtn', () => {
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders team badge for team plan with correct styling', () => {
|
||||
ensureProviderContextMock().mockReturnValueOnce({
|
||||
plan: { type: Plan.team },
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
const badge = screen.getByText('team').closest('div')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(badge).toHaveClass('bg-[#E0EAFF]')
|
||||
})
|
||||
|
||||
it('renders nothing when plan is not fetched', () => {
|
||||
ensureProviderContextMock().mockReturnValueOnce({
|
||||
plan: { type: Plan.professional },
|
||||
enableBilling: true,
|
||||
isFetchedPlan: false,
|
||||
})
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders sandbox upgrade btn with undefined onClick in display-only mode', () => {
|
||||
ensureProviderContextMock().mockReturnValueOnce({
|
||||
plan: { type: Plan.sandbox },
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
|
||||
render(<HeaderBillingBtn isDisplayOnly />)
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders plan badge and forwards clicks when not display-only', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import PartnerStack from '../index'
|
||||
import PartnerStack from './index'
|
||||
|
||||
let isCloudEdition = true
|
||||
|
||||
@ -12,7 +12,7 @@ vi.mock('@/config', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../use-ps-info', () => ({
|
||||
vi.mock('./use-ps-info', () => ({
|
||||
default: () => ({
|
||||
saveOrUpdate,
|
||||
bind,
|
||||
@ -40,23 +40,4 @@ describe('PartnerStack', () => {
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(bind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders null (no visible DOM)', () => {
|
||||
const { container } = render(<PartnerStack />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('does not call helpers again on rerender', () => {
|
||||
const { rerender } = render(<PartnerStack />)
|
||||
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(bind).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<PartnerStack />)
|
||||
|
||||
// useEffect with [] should not run again on rerender
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(bind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import usePSInfo from '../use-ps-info'
|
||||
import usePSInfo from './use-ps-info'
|
||||
|
||||
let searchParamsValues: Record<string, string | null> = {}
|
||||
const setSearchParams = (values: Record<string, string | null>) => {
|
||||
@ -193,107 +193,4 @@ describe('usePSInfo', () => {
|
||||
domain: '.dify.ai',
|
||||
})
|
||||
})
|
||||
|
||||
// Cookie parse failure: covers catch block (L14-16)
|
||||
it('should fall back to empty object when cookie contains invalid JSON', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('not-valid-json{{{')
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setSearchParams({
|
||||
ps_partner_key: 'from-url',
|
||||
ps_xid: 'click-url',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse partner stack info from cookie:',
|
||||
expect.any(SyntaxError),
|
||||
)
|
||||
// Should still pick up values from search params
|
||||
expect(result.current.psPartnerKey).toBe('from-url')
|
||||
expect(result.current.psClickId).toBe('click-url')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
// No keys at all: covers saveOrUpdate early return (L30) and bind no-op (L45 false branch)
|
||||
it('should not save or bind when neither search params nor cookie have keys', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBeUndefined()
|
||||
expect(result.current.psClickId).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call mutateAsync when keys are missing during bind', async () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
const mutate = ensureMutateAsync()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Non-400 error: covers L55 false branch (shouldRemoveCookie stays false)
|
||||
it('should not remove cookie when bind fails with non-400 error', async () => {
|
||||
const mutate = ensureMutateAsync()
|
||||
mutate.mockRejectedValueOnce({ status: 500 })
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
const { remove } = ensureCookieMocks()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Fallback to cookie values: covers L19-20 right side of || operator
|
||||
it('should use cookie values when search params are absent', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({
|
||||
partnerKey: 'cookie-partner',
|
||||
clickId: 'cookie-click',
|
||||
}))
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
||||
expect(result.current.psClickId).toBe('cookie-click')
|
||||
})
|
||||
|
||||
// Partial key missing: only partnerKey present, no clickId
|
||||
it('should not save when only one key is available', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({ ps_partner_key: 'partial-key' })
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import PlanUpgradeModal from '../index'
|
||||
import PlanUpgradeModal from './index'
|
||||
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
|
||||
@ -39,11 +39,13 @@ describe('PlanUpgradeModal', () => {
|
||||
|
||||
// Rendering and props-driven content
|
||||
it('should render modal with provided content when visible', () => {
|
||||
// Arrange
|
||||
const extraInfoText = 'Additional upgrade details'
|
||||
renderComponent({
|
||||
extraInfo: <div>{extraInfoText}</div>,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(baseProps.title)).toBeInTheDocument()
|
||||
expect(screen.getByText(baseProps.description)).toBeInTheDocument()
|
||||
expect(screen.getByText(extraInfoText)).toBeInTheDocument()
|
||||
@ -53,32 +55,40 @@ describe('PlanUpgradeModal', () => {
|
||||
|
||||
// Guard against rendering when modal is hidden
|
||||
it('should not render content when show is false', () => {
|
||||
// Act
|
||||
renderComponent({ show: false })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(baseProps.title)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(baseProps.description)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// User closes the modal from dismiss button
|
||||
it('should call onClose when dismiss button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
renderComponent({ onClose })
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByText('billing.triggerLimitModal.dismiss'))
|
||||
|
||||
// Assert
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Upgrade path uses provided callback over pricing modal
|
||||
it('should call onUpgrade and onClose when upgrade button is clicked with onUpgrade provided', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
renderComponent({ onClose, onUpgrade })
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
|
||||
|
||||
// Assert
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
@ -86,12 +96,15 @@ describe('PlanUpgradeModal', () => {
|
||||
|
||||
// Fallback upgrade path opens pricing modal when no onUpgrade is supplied
|
||||
it('should open pricing modal when upgrade button is clicked without onUpgrade', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
renderComponent({ onClose, onUpgrade: undefined })
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
|
||||
|
||||
// Assert
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Enterprise from '../enterprise'
|
||||
import Enterprise from './enterprise'
|
||||
|
||||
describe('Enterprise Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -1,11 +1,11 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import EnterpriseDirect from '../enterprise'
|
||||
import EnterpriseDirect from './enterprise'
|
||||
|
||||
import { Enterprise, Professional, Sandbox, Team } from '../index'
|
||||
import ProfessionalDirect from '../professional'
|
||||
import { Enterprise, Professional, Sandbox, Team } from './index'
|
||||
import ProfessionalDirect from './professional'
|
||||
// Import real components for comparison
|
||||
import SandboxDirect from '../sandbox'
|
||||
import TeamDirect from '../team'
|
||||
import SandboxDirect from './sandbox'
|
||||
import TeamDirect from './team'
|
||||
|
||||
describe('Billing Plan Assets - Integration Tests', () => {
|
||||
describe('Exports', () => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Professional from '../professional'
|
||||
import Professional from './professional'
|
||||
|
||||
describe('Professional Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -1,6 +1,6 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Sandbox from '../sandbox'
|
||||
import Sandbox from './sandbox'
|
||||
|
||||
describe('Sandbox Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Team from '../team'
|
||||
import Team from './team'
|
||||
|
||||
describe('Team Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -1,7 +1,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
|
||||
import { Plan, SelfHostedPlan } from '../../type'
|
||||
import PlanComp from '../index'
|
||||
import { Plan } from '../type'
|
||||
import PlanComp from './index'
|
||||
|
||||
let currentPath = '/billing'
|
||||
|
||||
@ -14,7 +14,8 @@ vi.mock('next/navigation', () => ({
|
||||
|
||||
const setShowAccountSettingModalMock = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof setShowAccountSettingModalMock }) => unknown) => selector({
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
useModalContextSelector: (selector: any) => selector({
|
||||
setShowAccountSettingModal: setShowAccountSettingModalMock,
|
||||
}),
|
||||
}))
|
||||
@ -46,10 +47,11 @@ const verifyStateModalMock = vi.fn(props => (
|
||||
</div>
|
||||
))
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: (props: { isShow: boolean, title?: string, content?: string, email?: string, showLink?: boolean, onConfirm?: () => void, onCancel?: () => void }) => verifyStateModalMock(props),
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
default: (props: any) => verifyStateModalMock(props),
|
||||
}))
|
||||
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: () => <button data-testid="plan-upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
@ -170,66 +172,6 @@ describe('PlanComp', () => {
|
||||
expect(screen.getByText('education.toVerified')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders enterprise plan without upgrade button', () => {
|
||||
providerContextMock.mockReturnValue({
|
||||
plan: { ...planMock, type: SelfHostedPlan.enterprise },
|
||||
enableEducationPlan: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
expect(screen.getByText('billing.plans.enterprise.name')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plan-upgrade-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows apiRateLimit reset info for sandbox plan', () => {
|
||||
providerContextMock.mockReturnValue({
|
||||
plan: {
|
||||
...planMock,
|
||||
type: Plan.sandbox,
|
||||
total: { ...planMock.total, apiRateLimit: 5000 },
|
||||
reset: { ...planMock.reset, apiRateLimit: null },
|
||||
},
|
||||
enableEducationPlan: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
// Sandbox plan with finite apiRateLimit and null reset uses getDaysUntilEndOfMonth()
|
||||
expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows apiRateLimit reset info when reset is a number', () => {
|
||||
providerContextMock.mockReturnValue({
|
||||
plan: {
|
||||
...planMock,
|
||||
type: Plan.professional,
|
||||
total: { ...planMock.total, apiRateLimit: 5000 },
|
||||
reset: { ...planMock.reset, apiRateLimit: 3 },
|
||||
},
|
||||
enableEducationPlan: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show education verify when enableEducationPlan is false', () => {
|
||||
providerContextMock.mockReturnValue({
|
||||
plan: planMock,
|
||||
enableEducationPlan: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
expect(screen.queryByText('education.toVerified')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles modal onConfirm and onCancel callbacks', async () => {
|
||||
mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
@ -1,81 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import {
|
||||
Cloud,
|
||||
Community,
|
||||
Enterprise,
|
||||
EnterpriseNoise,
|
||||
NoiseBottom,
|
||||
NoiseTop,
|
||||
Premium,
|
||||
PremiumNoise,
|
||||
Professional,
|
||||
Sandbox,
|
||||
SelfHosted,
|
||||
Team,
|
||||
} from '../index'
|
||||
|
||||
// Static SVG components (no props)
|
||||
describe('Static Pricing Asset Components', () => {
|
||||
const staticComponents = [
|
||||
{ name: 'Community', Component: Community },
|
||||
{ name: 'Enterprise', Component: Enterprise },
|
||||
{ name: 'EnterpriseNoise', Component: EnterpriseNoise },
|
||||
{ name: 'NoiseBottom', Component: NoiseBottom },
|
||||
{ name: 'NoiseTop', Component: NoiseTop },
|
||||
{ name: 'Premium', Component: Premium },
|
||||
{ name: 'PremiumNoise', Component: PremiumNoise },
|
||||
{ name: 'Professional', Component: Professional },
|
||||
{ name: 'Sandbox', Component: Sandbox },
|
||||
{ name: 'Team', Component: Team },
|
||||
]
|
||||
|
||||
it.each(staticComponents)('$name should render an SVG element', ({ Component }) => {
|
||||
const { container } = render(<Component />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(staticComponents)('$name should render without errors on rerender', ({ Component }) => {
|
||||
const { container, rerender } = render(<Component />)
|
||||
rerender(<Component />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interactive SVG components with isActive prop
|
||||
describe('Cloud', () => {
|
||||
it('should render an SVG element', () => {
|
||||
const { container } = render(<Cloud isActive={false} />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use primary color when inactive', () => {
|
||||
const { container } = render(<Cloud isActive={false} />)
|
||||
const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should use accent color when active', () => {
|
||||
const { container } = render(<Cloud isActive={true} />)
|
||||
const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SelfHosted', () => {
|
||||
it('should render an SVG element', () => {
|
||||
const { container } = render(<SelfHosted isActive={false} />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use primary color when inactive', () => {
|
||||
const { container } = render(<SelfHosted isActive={false} />)
|
||||
const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should use accent color when active', () => {
|
||||
const { container } = render(<SelfHosted isActive={true} />)
|
||||
const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@ -12,11 +12,13 @@ import {
|
||||
Sandbox,
|
||||
SelfHosted,
|
||||
Team,
|
||||
} from '../index'
|
||||
} from './index'
|
||||
|
||||
describe('Pricing Assets', () => {
|
||||
// Rendering: each asset should render an svg.
|
||||
describe('Rendering', () => {
|
||||
it('should render static assets without crashing', () => {
|
||||
// Arrange
|
||||
const assets = [
|
||||
<Community key="community" />,
|
||||
<Enterprise key="enterprise" />,
|
||||
@ -42,29 +44,37 @@ describe('Pricing Assets', () => {
|
||||
// Props: active state should change fill color for selectable assets.
|
||||
describe('Props', () => {
|
||||
it('should render active state for Cloud', () => {
|
||||
// Arrange
|
||||
const { container } = render(<Cloud isActive />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
|
||||
})
|
||||
|
||||
it('should render inactive state for Cloud', () => {
|
||||
// Arrange
|
||||
const { container } = render(<Cloud isActive={false} />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
|
||||
})
|
||||
|
||||
it('should render active state for SelfHosted', () => {
|
||||
// Arrange
|
||||
const { container } = render(<SelfHosted isActive />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
|
||||
})
|
||||
|
||||
it('should render inactive state for SelfHosted', () => {
|
||||
// Arrange
|
||||
const { container } = render(<SelfHosted isActive={false} />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { CategoryEnum } from '..'
|
||||
import Footer from '../footer'
|
||||
import { CategoryEnum } from '.'
|
||||
import Footer from './footer'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
|
||||
@ -16,10 +16,13 @@ describe('Footer', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render tax tips and comparison link when in cloud category', () => {
|
||||
// Arrange
|
||||
render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.CLOUD} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
|
||||
@ -27,19 +30,25 @@ describe('Footer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should hide tax tips when category is self-hosted', () => {
|
||||
// Arrange
|
||||
render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.SELF} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.plansCommon.taxTipSecond')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render link even when pricing URL is empty', () => {
|
||||
// Arrange
|
||||
render(<Footer pricingPageURL="" currentCategory={CategoryEnum.CLOUD} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', '')
|
||||
})
|
||||
})
|
||||
@ -1,39 +1,74 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Header from '../header'
|
||||
import Header from './header'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
if (mockTranslations[key])
|
||||
return mockTranslations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render title and description translations', () => {
|
||||
// Arrange
|
||||
const handleClose = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Header onClose={handleClose} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should invoke onClose when close button is clicked', () => {
|
||||
// Arrange
|
||||
const handleClose = vi.fn()
|
||||
render(<Header onClose={handleClose} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render structural elements with translation keys', () => {
|
||||
it('should render structure when translations are empty strings', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'billing.plansCommon.title.plans': '',
|
||||
'billing.plansCommon.title.description': '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<Header onClose={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('span')).toBeInTheDocument()
|
||||
expect(container.querySelector('p')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
@ -1,24 +1,17 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { UsagePlanInfo } from '../../type'
|
||||
import type { UsagePlanInfo } from '../type'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetPricingPageLanguage } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '../../type'
|
||||
import Pricing from '../index'
|
||||
import { Plan } from '../type'
|
||||
import Pricing from './index'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
let mockLanguage: string | null = 'en'
|
||||
|
||||
vi.mock('../plans/self-hosted-plan-item/list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`list-${plan}`}>
|
||||
List for
|
||||
{plan}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
|
||||
<a href={href} className={className} target={target} data-testid="pricing-link">
|
||||
@ -27,6 +20,10 @@ vi.mock('next/link', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
@ -39,6 +36,24 @@ vi.mock('@/context/i18n', () => ({
|
||||
useGetPricingPageLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { returnObjects?: boolean, ns?: string }) => {
|
||||
if (options?.returnObjects)
|
||||
return mockTranslations[key] ?? []
|
||||
if (mockTranslations[key])
|
||||
return mockTranslations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
|
||||
}
|
||||
})
|
||||
|
||||
const buildUsage = (): UsagePlanInfo => ({
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
@ -52,6 +67,7 @@ const buildUsage = (): UsagePlanInfo => ({
|
||||
describe('Pricing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
mockLanguage = 'en'
|
||||
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
@ -64,33 +80,42 @@ describe('Pricing', () => {
|
||||
;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage)
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render pricing header and localized footer link', () => {
|
||||
// Arrange
|
||||
render(<Pricing onCancel={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should allow switching categories and handle esc key', () => {
|
||||
it('should register esc key handler and allow switching categories', () => {
|
||||
// Arrange
|
||||
const handleCancel = vi.fn()
|
||||
render(<Pricing onCancel={handleCancel} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('billing.plansCommon.self'))
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
expect(handleCancel).toHaveBeenCalled()
|
||||
// Assert
|
||||
expect(useKeyPress).toHaveBeenCalledWith(['esc'], handleCancel)
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should fall back to default pricing URL when language is empty', () => {
|
||||
// Arrange
|
||||
mockLanguage = ''
|
||||
render(<Pricing onCancel={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
|
||||
})
|
||||
})
|
||||
@ -1,16 +1,36 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { CategoryEnum } from '../../index'
|
||||
import PlanSwitcher from '../index'
|
||||
import { PlanRange } from '../plan-range-switcher'
|
||||
import { CategoryEnum } from '../index'
|
||||
import PlanSwitcher from './index'
|
||||
import { PlanRange } from './plan-range-switcher'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
if (key in mockTranslations)
|
||||
return mockTranslations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlanSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render category tabs and plan range switcher for cloud', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.CLOUD}
|
||||
@ -20,14 +40,17 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.cloud')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.self')).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should call onChangeCategory when selecting a tab', () => {
|
||||
// Arrange
|
||||
const handleChangeCategory = vi.fn()
|
||||
render(
|
||||
<PlanSwitcher
|
||||
@ -38,13 +61,16 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('billing.plansCommon.self'))
|
||||
|
||||
// Assert
|
||||
expect(handleChangeCategory).toHaveBeenCalledTimes(1)
|
||||
expect(handleChangeCategory).toHaveBeenCalledWith(CategoryEnum.SELF)
|
||||
})
|
||||
|
||||
it('should hide plan range switcher when category is self-hosted', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.SELF}
|
||||
@ -54,12 +80,21 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render tabs with translation keys', () => {
|
||||
it('should render tabs when translation strings are empty', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'plansCommon.cloud': '',
|
||||
'plansCommon.self': '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.SELF}
|
||||
@ -69,10 +104,11 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const labels = container.querySelectorAll('span')
|
||||
expect(labels).toHaveLength(2)
|
||||
expect(labels[0]?.textContent).toBe('billing.plansCommon.cloud')
|
||||
expect(labels[1]?.textContent).toBe('billing.plansCommon.self')
|
||||
expect(labels[0]?.textContent).toBe('')
|
||||
expect(labels[1]?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,50 +1,86 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PlanRangeSwitcher, { PlanRange } from '../plan-range-switcher'
|
||||
import PlanRangeSwitcher, { PlanRange } from './plan-range-switcher'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
if (mockTranslations[key])
|
||||
return mockTranslations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlanRangeSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render the annual billing label', () => {
|
||||
// Arrange
|
||||
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/billing\.plansCommon\.annualBilling/)).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.annualBilling')).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should switch to yearly when toggled from monthly', () => {
|
||||
// Arrange
|
||||
const handleChange = vi.fn()
|
||||
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={handleChange} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// Assert
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith(PlanRange.yearly)
|
||||
})
|
||||
|
||||
it('should switch to monthly when toggled from yearly', () => {
|
||||
// Arrange
|
||||
const handleChange = vi.fn()
|
||||
render(<PlanRangeSwitcher value={PlanRange.yearly} onChange={handleChange} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// Assert
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith(PlanRange.monthly)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render label with translation key and params', () => {
|
||||
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
it('should render when the translation string is empty', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'billing.plansCommon.annualBilling': '',
|
||||
}
|
||||
|
||||
const label = screen.getByText(/billing\.plansCommon\.annualBilling/)
|
||||
// Act
|
||||
const { container } = render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('span')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label.textContent).toContain('percent')
|
||||
expect(label?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Tab from '../tab'
|
||||
import Tab from './tab'
|
||||
|
||||
const Icon = ({ isActive }: { isActive: boolean }) => (
|
||||
<svg data-testid="tab-icon" data-active={isActive ? 'true' : 'false'} />
|
||||
@ -11,8 +11,10 @@ describe('PlanSwitcherTab', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render label and icon', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
@ -23,13 +25,16 @@ describe('PlanSwitcherTab', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Cloud')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should call onClick with the provided value', () => {
|
||||
// Arrange
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<Tab
|
||||
@ -41,13 +46,16 @@ describe('PlanSwitcherTab', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('Self'))
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(handleClick).toHaveBeenCalledWith('self')
|
||||
})
|
||||
|
||||
it('should apply active text class when isActive is true', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
@ -58,13 +66,16 @@ describe('PlanSwitcherTab', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible')
|
||||
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render when label is empty', () => {
|
||||
// Arrange
|
||||
const { container } = render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
@ -75,6 +86,7 @@ describe('PlanSwitcherTab', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('span')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label?.textContent).toBe('')
|
||||
@ -1,12 +1,13 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { Plan } from '../../../../type'
|
||||
import Button from '../button'
|
||||
import { Plan } from '../../../type'
|
||||
import Button from './button'
|
||||
|
||||
describe('CloudPlanButton', () => {
|
||||
describe('Disabled state', () => {
|
||||
it('should disable button and hide arrow when plan is not available', () => {
|
||||
const handleGetPayUrl = vi.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<Button
|
||||
plan={Plan.team}
|
||||
@ -17,6 +18,7 @@ describe('CloudPlanButton', () => {
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /Get started/i })
|
||||
// Assert
|
||||
expect(button).toBeDisabled()
|
||||
expect(button.className).toContain('cursor-not-allowed')
|
||||
expect(handleGetPayUrl).not.toHaveBeenCalled()
|
||||
@ -26,6 +28,7 @@ describe('CloudPlanButton', () => {
|
||||
describe('Enabled state', () => {
|
||||
it('should invoke handler and render arrow when plan is available', () => {
|
||||
const handleGetPayUrl = vi.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<Button
|
||||
plan={Plan.sandbox}
|
||||
@ -36,8 +39,10 @@ describe('CloudPlanButton', () => {
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /Start now/i })
|
||||
// Act
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert
|
||||
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
@ -5,13 +5,13 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { fetchSubscriptionUrls } from '@/service/billing'
|
||||
import { consoleClient } from '@/service/client'
|
||||
import Toast from '../../../../../base/toast'
|
||||
import { ALL_PLANS } from '../../../../config'
|
||||
import { Plan } from '../../../../type'
|
||||
import { PlanRange } from '../../../plan-switcher/plan-range-switcher'
|
||||
import CloudPlanItem from '../index'
|
||||
import Toast from '../../../../base/toast'
|
||||
import { ALL_PLANS } from '../../../config'
|
||||
import { Plan } from '../../../type'
|
||||
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
|
||||
import CloudPlanItem from './index'
|
||||
|
||||
vi.mock('../../../../../base/toast', () => ({
|
||||
vi.mock('../../../../base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
@ -37,7 +37,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../assets', () => ({
|
||||
vi.mock('../../assets', () => ({
|
||||
Sandbox: () => <div>Sandbox Icon</div>,
|
||||
Professional: () => <div>Professional Icon</div>,
|
||||
Team: () => <div>Team Icon</div>,
|
||||
@ -66,6 +66,13 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
@ -75,13 +82,6 @@ beforeEach(() => {
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
describe('CloudPlanItem', () => {
|
||||
// Static content for each plan
|
||||
describe('Rendering', () => {
|
||||
@ -117,32 +117,6 @@ describe('CloudPlanItem', () => {
|
||||
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "most popular" badge for professional plan', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show "most popular" badge for non-professional plans', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('billing.plansCommon.mostPopular')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable CTA when workspace already on higher tier', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
@ -218,128 +192,5 @@ describe('CloudPlanItem', () => {
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L92-93: isFreePlan guard inside handleGetPayUrl
|
||||
it('should do nothing when clicking sandbox plan CTA that is not the current plan', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.sandbox}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
// Sandbox viewed from a higher plan is disabled, but let's verify no API calls
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
expect(mockBillingInvoices).not.toHaveBeenCalled()
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L95: yearly subscription URL ('year' parameter)
|
||||
it('should fetch yearly subscription url when planRange is yearly', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.yearly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L62-63: loading guard prevents double click
|
||||
it('should ignore second click while loading', async () => {
|
||||
// Make the first fetch hang until we resolve it
|
||||
let resolveFirst!: (v: { url: string }) => void
|
||||
mockFetchSubscriptionUrls.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveFirst = resolve }),
|
||||
)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
|
||||
|
||||
// First click starts loading
|
||||
fireEvent.click(button)
|
||||
// Second click while loading should be ignored
|
||||
fireEvent.click(button)
|
||||
|
||||
// Resolve first request
|
||||
resolveFirst({ url: 'https://first.example' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L82-83, L85-87: openAsyncWindow error path when invoices returns no url
|
||||
it('should invoke onError when billing invoices returns empty url', async () => {
|
||||
mockBillingInvoices.mockResolvedValue({ url: '' })
|
||||
const openWindow = vi.fn(async (cb: () => Promise<string>, opts: { onError?: (e: Error) => void }) => {
|
||||
try {
|
||||
await cb()
|
||||
}
|
||||
catch (e) {
|
||||
opts.onError?.(e as Error)
|
||||
}
|
||||
})
|
||||
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openWindow).toHaveBeenCalledTimes(1)
|
||||
// The onError callback should have been passed to openAsyncWindow
|
||||
const callArgs = openWindow.mock.calls[0]
|
||||
expect(callArgs[1]).toHaveProperty('onError')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers monthly price display (L139 !isYear branch for price)
|
||||
it('should display monthly pricing without discount', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const teamPlan = ALL_PLANS[Plan.team]
|
||||
expect(screen.getByText(`$${teamPlan.price}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/)).toBeInTheDocument()
|
||||
// Should NOT show crossed-out yearly price
|
||||
expect(screen.queryByText(`$${teamPlan.price * 12}`)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { Plan } from '../../../../../type'
|
||||
import List from '../index'
|
||||
import { Plan } from '../../../../type'
|
||||
import List from './index'
|
||||
|
||||
describe('CloudPlanItem/List', () => {
|
||||
it('should show sandbox specific quotas', () => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Item from '../index'
|
||||
import Item from './index'
|
||||
|
||||
describe('Item', () => {
|
||||
beforeEach(() => {
|
||||
@ -9,10 +9,13 @@ describe('Item', () => {
|
||||
// Rendering the plan item row
|
||||
describe('Rendering', () => {
|
||||
it('should render the provided label when tooltip is absent', () => {
|
||||
// Arrange
|
||||
const label = 'Monthly credits'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Item label={label} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(container.querySelector('.group')).toBeNull()
|
||||
})
|
||||
@ -21,21 +24,27 @@ describe('Item', () => {
|
||||
// Toggling the optional tooltip indicator
|
||||
describe('Tooltip behavior', () => {
|
||||
it('should render tooltip content when tooltip text is provided', () => {
|
||||
// Arrange
|
||||
const label = 'Workspace seats'
|
||||
const tooltip = 'Seats define how many teammates can join the workspace.'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Item label={label} tooltip={tooltip} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(screen.getByText(tooltip)).toBeInTheDocument()
|
||||
expect(container.querySelector('.group')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should treat an empty tooltip string as absent', () => {
|
||||
// Arrange
|
||||
const label = 'Vector storage'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Item label={label} tooltip="" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(container.querySelector('.group')).toBeNull()
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Tooltip from '../tooltip'
|
||||
import Tooltip from './tooltip'
|
||||
|
||||
describe('Tooltip', () => {
|
||||
beforeEach(() => {
|
||||
@ -9,20 +9,26 @@ describe('Tooltip', () => {
|
||||
// Rendering the info tooltip container
|
||||
describe('Rendering', () => {
|
||||
it('should render the content panel when provide with text', () => {
|
||||
// Arrange
|
||||
const content = 'Usage resets on the first day of every month.'
|
||||
|
||||
// Act
|
||||
render(<Tooltip content={content} />)
|
||||
|
||||
// Assert
|
||||
expect(() => screen.getByText(content)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon rendering', () => {
|
||||
it('should render the icon when provided with content', () => {
|
||||
// Arrange
|
||||
const content = 'Tooltips explain each plan detail.'
|
||||
|
||||
// Act
|
||||
render(<Tooltip content={content} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('tooltip-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -30,6 +36,7 @@ describe('Tooltip', () => {
|
||||
// Handling empty strings while keeping structure consistent
|
||||
describe('Edge cases', () => {
|
||||
it('should render without crashing when passed empty content', () => {
|
||||
// Arrange
|
||||
const content = ''
|
||||
|
||||
// Act and Assert
|
||||
@ -1,14 +1,14 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { UsagePlanInfo } from '../../../type'
|
||||
import type { UsagePlanInfo } from '../../type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { Plan } from '../../../type'
|
||||
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
|
||||
import cloudPlanItem from '../cloud-plan-item'
|
||||
import Plans from '../index'
|
||||
import selfHostedPlanItem from '../self-hosted-plan-item'
|
||||
import { Plan } from '../../type'
|
||||
import { PlanRange } from '../plan-switcher/plan-range-switcher'
|
||||
import cloudPlanItem from './cloud-plan-item'
|
||||
import Plans from './index'
|
||||
import selfHostedPlanItem from './self-hosted-plan-item'
|
||||
|
||||
vi.mock('../cloud-plan-item', () => ({
|
||||
vi.mock('./cloud-plan-item', () => ({
|
||||
default: vi.fn(props => (
|
||||
<div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}>
|
||||
Cloud
|
||||
@ -18,7 +18,7 @@ vi.mock('../cloud-plan-item', () => ({
|
||||
)),
|
||||
}))
|
||||
|
||||
vi.mock('../self-hosted-plan-item', () => ({
|
||||
vi.mock('./self-hosted-plan-item', () => ({
|
||||
default: vi.fn(props => (
|
||||
<div data-testid={`self-plan-${props.plan}`}>
|
||||
Self
|
||||
@ -3,8 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { SelfHostedPlan } from '../../../../type'
|
||||
import Button from '../button'
|
||||
import { SelfHostedPlan } from '../../../type'
|
||||
import Button from './button'
|
||||
|
||||
vi.mock('@/hooks/use-theme')
|
||||
|
||||
@ -2,21 +2,30 @@ import type { Mock } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import Toast from '../../../../../base/toast'
|
||||
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../../config'
|
||||
import { SelfHostedPlan } from '../../../../type'
|
||||
import SelfHostedPlanItem from '../index'
|
||||
import Toast from '../../../../base/toast'
|
||||
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
|
||||
import { SelfHostedPlan } from '../../../type'
|
||||
import SelfHostedPlanItem from './index'
|
||||
|
||||
vi.mock('../list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`list-${plan}`}>
|
||||
List for
|
||||
{plan}
|
||||
</div>
|
||||
),
|
||||
const featuresTranslations: Record<string, string[]> = {
|
||||
'billing.plans.community.features': ['community-feature-1', 'community-feature-2'],
|
||||
'billing.plans.premium.features': ['premium-feature-1'],
|
||||
'billing.plans.enterprise.features': ['enterprise-feature-1'],
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
if (options?.returnObjects)
|
||||
return featuresTranslations[`${prefix}${key}`] || []
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../../base/toast', () => ({
|
||||
vi.mock('../../../../base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
@ -26,7 +35,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../assets', () => ({
|
||||
vi.mock('../../assets', () => ({
|
||||
Community: () => <div>Community Icon</div>,
|
||||
Premium: () => <div>Premium Icon</div>,
|
||||
Enterprise: () => <div>Enterprise Icon</div>,
|
||||
@ -54,12 +63,6 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
@ -67,7 +70,14 @@ afterAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
describe('SelfHostedPlanItem', () => {
|
||||
// Copy rendering for each plan
|
||||
describe('Rendering', () => {
|
||||
it('should display community plan info', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
@ -75,7 +85,8 @@ describe('SelfHostedPlanItem', () => {
|
||||
expect(screen.getByText('billing.plans.community.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.price')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('list-community')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('community-feature-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show premium extras such as cloud provider notice', () => {
|
||||
@ -86,6 +97,7 @@ describe('SelfHostedPlanItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// CTA behavior for each plan
|
||||
describe('CTA interactions', () => {
|
||||
it('should show toast when non-manager tries to proceed', () => {
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
|
||||
@ -1,20 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { SelfHostedPlan } from '@/app/components/billing/type'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import List from '../index'
|
||||
|
||||
// Override global i18n mock to support returnObjects: true for feature arrays
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
'billing.plans.community.features': ['Feature A', 'Feature B'],
|
||||
}))
|
||||
|
||||
describe('SelfHostedPlanItem/List', () => {
|
||||
it('should render plan info', () => {
|
||||
render(<List plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.getByText('plans.community.includesTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,35 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Item from '../item'
|
||||
|
||||
describe('SelfHostedPlanItem/List/Item', () => {
|
||||
it('should display provided feature label', () => {
|
||||
const { container } = render(<Item label="Dedicated support" />)
|
||||
|
||||
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should render the check icon', () => {
|
||||
const { container } = render(<Item label="Custom branding" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveClass('size-4')
|
||||
})
|
||||
|
||||
it('should render different labels correctly', () => {
|
||||
const { rerender } = render(<Item label="Feature A" />)
|
||||
expect(screen.getByText('Feature A')).toBeInTheDocument()
|
||||
|
||||
rerender(<Item label="Feature B" />)
|
||||
expect(screen.getByText('Feature B')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Feature A')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty label', () => {
|
||||
const { container } = render(<Item label="" />)
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { SelfHostedPlan } from '@/app/components/billing/type'
|
||||
import List from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.returnObjects)
|
||||
return ['Feature A', 'Feature B']
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>,
|
||||
}))
|
||||
|
||||
describe('SelfHostedPlanItem/List', () => {
|
||||
it('should render plan info', () => {
|
||||
render(<List plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,12 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Item from './item'
|
||||
|
||||
describe('SelfHostedPlanItem/List/Item', () => {
|
||||
it('should display provided feature label', () => {
|
||||
const { container } = render(<Item label="Dedicated support" />)
|
||||
|
||||
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@ -2,8 +2,8 @@ import type { Mock } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createMockPlan } from '@/__mocks__/provider-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '../../type'
|
||||
import PriorityLabel from '../index'
|
||||
import { Plan } from '../type'
|
||||
import PriorityLabel from './index'
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
@ -20,12 +20,16 @@ describe('PriorityLabel', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: basic label output for sandbox plan.
|
||||
describe('Rendering', () => {
|
||||
it('should render the standard priority label when plan is sandbox', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -33,10 +37,13 @@ describe('PriorityLabel', () => {
|
||||
// Props: custom class name applied to the label container.
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to the label container', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel className="custom-class" />)
|
||||
|
||||
// Assert
|
||||
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
|
||||
expect(label).toHaveClass('custom-class')
|
||||
})
|
||||
@ -45,53 +52,54 @@ describe('PriorityLabel', () => {
|
||||
// Plan types: label text and icon visibility for different plans.
|
||||
describe('Plan Types', () => {
|
||||
it('should render priority label and icon when plan is professional', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.professional)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render top priority label and icon when plan is team', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.team)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render standard label without icon when plan is sandbox', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Enterprise plan tests
|
||||
describe('Enterprise Plan', () => {
|
||||
it('should render top-priority label with icon for enterprise plan', () => {
|
||||
setupPlan(Plan.enterprise)
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: tooltip content varies by priority level.
|
||||
describe('Edge Cases', () => {
|
||||
it('should show the tip text when priority is not top priority', async () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel />)
|
||||
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
|
||||
fireEvent.mouseEnter(label as HTMLElement)
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText(
|
||||
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard',
|
||||
)).toBeInTheDocument()
|
||||
@ -99,12 +107,15 @@ describe('PriorityLabel', () => {
|
||||
})
|
||||
|
||||
it('should hide the tip text when priority is top priority', async () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.enterprise)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel />)
|
||||
const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div')
|
||||
fireEvent.mouseEnter(label as HTMLElement)
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText(
|
||||
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority',
|
||||
)).toBeInTheDocument()
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ProgressBar from '../index'
|
||||
import ProgressBar from './index'
|
||||
|
||||
describe('ProgressBar', () => {
|
||||
describe('Normal Mode (determinate)', () => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import TriggerEventsLimitModal from '../index'
|
||||
import TriggerEventsLimitModal from './index'
|
||||
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUpgrade = vi.fn()
|
||||
@ -16,7 +16,8 @@ const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, descr
|
||||
))
|
||||
|
||||
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
|
||||
default: (props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => planUpgradeModalMock(props),
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
default: (props: any) => planUpgradeModalMock(props),
|
||||
}))
|
||||
|
||||
describe('TriggerEventsLimitModal', () => {
|
||||
@ -65,53 +66,4 @@ describe('TriggerEventsLimitModal', () => {
|
||||
expect(planUpgradeModalMock).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false')
|
||||
})
|
||||
|
||||
it('renders reset info when resetInDays is provided', () => {
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show
|
||||
onClose={mockOnClose}
|
||||
onUpgrade={mockOnUpgrade}
|
||||
usage={18000}
|
||||
total={20000}
|
||||
resetInDays={7}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('18000')).toBeInTheDocument()
|
||||
expect(screen.getByText('20000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes correct title and description translations', () => {
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show
|
||||
onClose={mockOnClose}
|
||||
onUpgrade={mockOnUpgrade}
|
||||
usage={0}
|
||||
total={0}
|
||||
/>,
|
||||
)
|
||||
|
||||
const modal = screen.getByTestId('plan-upgrade-modal')
|
||||
expect(modal.getAttribute('data-title')).toBe('billing.triggerLimitModal.title')
|
||||
expect(modal.getAttribute('data-description')).toBe('billing.triggerLimitModal.description')
|
||||
})
|
||||
|
||||
it('passes onClose and onUpgrade callbacks to PlanUpgradeModal', () => {
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show
|
||||
onClose={mockOnClose}
|
||||
onUpgrade={mockOnUpgrade}
|
||||
usage={0}
|
||||
total={0}
|
||||
/>,
|
||||
)
|
||||
|
||||
const passedProps = planUpgradeModalMock.mock.calls[0][0]
|
||||
expect(passedProps.onClose).toBe(mockOnClose)
|
||||
expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import UpgradeBtn from '../index'
|
||||
import UpgradeBtn from './index'
|
||||
|
||||
// ✅ Import real project components (DO NOT mock these)
|
||||
// PremiumBadge, Button, SparklesSoft are all base components
|
||||
@ -14,117 +14,146 @@ vi.mock('@/context/modal-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Typed window accessor for gtag tracking tests
|
||||
const gtagWindow = window as unknown as Record<string, Mock | undefined>
|
||||
// Mock gtag for tracking tests
|
||||
let mockGtag: Mock | undefined
|
||||
|
||||
describe('UpgradeBtn', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGtag = vi.fn()
|
||||
gtagWindow.gtag = mockGtag
|
||||
;(window as any).gtag = mockGtag
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete gtagWindow.gtag
|
||||
delete (window as any).gtag
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing with default props', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
// Assert - should render with default text
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render premium badge by default', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
// Assert - PremiumBadge renders with text content
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plain button when isPlain is true', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
// Assert - Button should be rendered with plain text
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render short text when isShort is true', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isShort />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom label when labelKey is provided', () => {
|
||||
render(<UpgradeBtn labelKey="triggerLimitModal.upgrade" />)
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey={'custom.label.key' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom label in plain button when labelKey is provided with isPlain', () => {
|
||||
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain labelKey={'custom.label.key' as any} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to premium badge', () => {
|
||||
// Arrange
|
||||
const customClass = 'custom-upgrade-btn'
|
||||
|
||||
// Act
|
||||
const { container } = render(<UpgradeBtn className={customClass} />)
|
||||
|
||||
// Assert - Check the root element has the custom class
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('should apply custom className to plain button', () => {
|
||||
// Arrange
|
||||
const customClass = 'custom-button-class'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain className={customClass} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('should apply custom style to premium badge', () => {
|
||||
// Arrange
|
||||
const customStyle = { padding: '10px' }
|
||||
|
||||
// Act
|
||||
const { container } = render(<UpgradeBtn style={customStyle} />)
|
||||
|
||||
// Assert
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveStyle(customStyle)
|
||||
})
|
||||
|
||||
it('should apply custom style to plain button', () => {
|
||||
// Arrange
|
||||
const customStyle = { margin: '5px' }
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain style={customStyle} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveStyle(customStyle)
|
||||
})
|
||||
|
||||
it('should render with size "s"', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn size="s" />)
|
||||
|
||||
// Assert - Component renders successfully with size prop
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with size "m" by default', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
// Assert - Component renders successfully
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with size "custom"', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn size="custom" />)
|
||||
|
||||
// Assert - Component renders successfully with custom size
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -132,57 +161,72 @@ describe('UpgradeBtn', () => {
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call custom onClick when provided and premium badge is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call custom onClick when provided and plain button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain onClick={handleClick} />)
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should open pricing modal when no custom onClick is provided and plain button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain />)
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should track gtag event when loc is provided and badge is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const loc = 'header-navigation'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc={loc} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(mockGtag).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc,
|
||||
@ -190,13 +234,16 @@ describe('UpgradeBtn', () => {
|
||||
})
|
||||
|
||||
it('should track gtag event when loc is provided and plain button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const loc = 'footer-section'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain loc={loc} />)
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Assert
|
||||
expect(mockGtag).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc,
|
||||
@ -204,35 +251,44 @@ describe('UpgradeBtn', () => {
|
||||
})
|
||||
|
||||
it('should not track gtag event when loc is not provided', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not track gtag event when gtag is not available', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
delete gtagWindow.gtag
|
||||
delete (window as any).gtag
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc="test-location" />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - should not throw error
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call both custom onClick and track gtag when both are provided', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
const loc = 'settings-page'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} loc={loc} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
@ -244,95 +300,121 @@ describe('UpgradeBtn', () => {
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined className', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn className={undefined} />)
|
||||
|
||||
// Assert - should render without error
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined style', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn style={undefined} />)
|
||||
|
||||
// Assert - should render without error
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onClick', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={undefined} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - should fall back to setShowPricingModal
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle undefined loc', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc={undefined} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - should not attempt to track gtag
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle undefined labelKey', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey={undefined} />)
|
||||
|
||||
// Assert - should use default label
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string className', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn className="" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string loc', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc="" />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - empty loc should not trigger gtag
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle labelKey with isShort - labelKey takes precedence', () => {
|
||||
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
|
||||
it('should handle empty string labelKey', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey={'' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
// Assert - empty labelKey is falsy, so it falls back to default label
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop Combinations
|
||||
describe('Prop Combinations', () => {
|
||||
it('should handle isPlain with isShort', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain isShort />)
|
||||
|
||||
// Assert - isShort should not affect plain button text
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle isPlain with custom labelKey', () => {
|
||||
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain labelKey={'custom.key' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
// Assert - labelKey should override plain text
|
||||
expect(screen.getByText(/custom\.key/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/billing\.upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle isShort with custom labelKey', () => {
|
||||
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
|
||||
// Act
|
||||
render(<UpgradeBtn isShort labelKey={'custom.short.key' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
// Assert - labelKey should override isShort behavior
|
||||
expect(screen.getByText(/custom\.short\.key/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle all custom props together', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
const customStyle = { margin: '10px' }
|
||||
const customClass = 'all-custom'
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<UpgradeBtn
|
||||
className={customClass}
|
||||
@ -341,16 +423,17 @@ describe('UpgradeBtn', () => {
|
||||
isShort
|
||||
onClick={handleClick}
|
||||
loc="test-loc"
|
||||
labelKey="triggerLimitModal.description"
|
||||
labelKey={'custom.all' as any}
|
||||
/>,
|
||||
)
|
||||
const badge = screen.getByText(/triggerLimitModal\.description/i)
|
||||
const badge = screen.getByText(/custom\.all/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass(customClass)
|
||||
expect(rootElement).toHaveStyle(customStyle)
|
||||
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/custom\.all/i)).toBeInTheDocument()
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc: 'test-loc',
|
||||
@ -361,9 +444,11 @@ describe('UpgradeBtn', () => {
|
||||
// Accessibility Tests
|
||||
describe('Accessibility', () => {
|
||||
it('should be keyboard accessible with plain button', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain onClick={handleClick} />)
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
@ -374,38 +459,47 @@ describe('UpgradeBtn', () => {
|
||||
// Press Enter
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be keyboard accessible with Space key', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain onClick={handleClick} />)
|
||||
|
||||
// Tab to button and press Space
|
||||
await user.tab()
|
||||
await user.keyboard(' ')
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be clickable for premium badge variant', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
|
||||
// Click badge
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should have proper button role when isPlain is true', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
// Assert - Plain button should have button role
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
@ -414,25 +508,31 @@ describe('UpgradeBtn', () => {
|
||||
// Integration Tests
|
||||
describe('Integration', () => {
|
||||
it('should work with modal context for pricing modal', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should integrate onClick with analytics tracking', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} loc="integration-test" />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - Both onClick and gtag should be called
|
||||
await waitFor(() => {
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
@ -1,67 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { defaultPlan } from '../../config'
|
||||
import AppsInfo from '../apps-info'
|
||||
|
||||
const mockProviderContext = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderContext(),
|
||||
}))
|
||||
|
||||
describe('AppsInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
usage: { ...defaultPlan.usage, buildApps: 7 },
|
||||
total: { ...defaultPlan.total, buildApps: 15 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders build apps usage information with context data', () => {
|
||||
render(<AppsInfo className="apps-info-class" />)
|
||||
|
||||
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
|
||||
expect(screen.getByText('7')).toBeInTheDocument()
|
||||
expect(screen.getByText('15')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without className', () => {
|
||||
render(<AppsInfo />)
|
||||
|
||||
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders zero usage correctly', () => {
|
||||
mockProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
usage: { ...defaultPlan.usage, buildApps: 0 },
|
||||
total: { ...defaultPlan.total, buildApps: 5 },
|
||||
},
|
||||
})
|
||||
|
||||
render(<AppsInfo />)
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders when usage equals total (at capacity)', () => {
|
||||
mockProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
usage: { ...defaultPlan.usage, buildApps: 10 },
|
||||
total: { ...defaultPlan.total, buildApps: 10 },
|
||||
},
|
||||
})
|
||||
|
||||
render(<AppsInfo />)
|
||||
|
||||
const tens = screen.getAllByText('10')
|
||||
expect(tens.length).toBe(2)
|
||||
})
|
||||
})
|
||||
35
web/app/components/billing/usage-info/apps-info.spec.tsx
Normal file
35
web/app/components/billing/usage-info/apps-info.spec.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { defaultPlan } from '../config'
|
||||
import AppsInfo from './apps-info'
|
||||
|
||||
const appsUsage = 7
|
||||
const appsTotal = 15
|
||||
|
||||
const mockPlan = {
|
||||
...defaultPlan,
|
||||
usage: {
|
||||
...defaultPlan.usage,
|
||||
buildApps: appsUsage,
|
||||
},
|
||||
total: {
|
||||
...defaultPlan.total,
|
||||
buildApps: appsTotal,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: mockPlan,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AppsInfo', () => {
|
||||
it('renders build apps usage information with context data', () => {
|
||||
render(<AppsInfo className="apps-info-class" />)
|
||||
|
||||
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
|
||||
expect(screen.getByText(`${appsUsage}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`${appsTotal}`)).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { NUM_INFINITE } from '../../config'
|
||||
import UsageInfo from '../index'
|
||||
import { NUM_INFINITE } from '../config'
|
||||
import UsageInfo from './index'
|
||||
|
||||
const TestIcon = () => <span data-testid="usage-icon" />
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { defaultPlan } from '../../config'
|
||||
import { Plan } from '../../type'
|
||||
import VectorSpaceInfo from '../vector-space-info'
|
||||
import { defaultPlan } from '../config'
|
||||
import { Plan } from '../type'
|
||||
import VectorSpaceInfo from './vector-space-info'
|
||||
|
||||
// Mock provider context with configurable plan
|
||||
let mockPlanType = Plan.sandbox
|
||||
@ -1,6 +1,6 @@
|
||||
import type { CurrentPlanInfoBackend } from '../../type'
|
||||
import { DocumentProcessingPriority, Plan } from '../../type'
|
||||
import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from '../index'
|
||||
import type { CurrentPlanInfoBackend } from '../type'
|
||||
import { DocumentProcessingPriority, Plan } from '../type'
|
||||
import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from './index'
|
||||
|
||||
describe('billing utils', () => {
|
||||
// parseVectorSpaceToMB tests
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import VectorSpaceFull from '../index'
|
||||
import VectorSpaceFull from './index'
|
||||
|
||||
type VectorProviderGlobal = typeof globalThis & {
|
||||
__vectorProviderContext?: ReturnType<typeof vi.fn>
|
||||
@ -17,12 +17,12 @@ vi.mock('@/context/provider-context', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: () => <button data-testid="vector-upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
// Mock utils to control threshold and plan limits
|
||||
vi.mock('../../utils', () => ({
|
||||
vi.mock('../utils', () => ({
|
||||
getPlanVectorSpaceLimitMB: (planType: string) => {
|
||||
// Return 5 for sandbox (threshold) and 100 for team
|
||||
if (planType === 'sandbox')
|
||||
@ -66,26 +66,4 @@ describe('VectorSpaceFull', () => {
|
||||
expect(screen.getByText('8')).toBeInTheDocument()
|
||||
expect(screen.getByText('100MB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders vector space info section', () => {
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
expect(screen.getByText('billing.usagePage.vectorSpace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with sandbox plan', () => {
|
||||
const globals = getVectorGlobal()
|
||||
globals.__vectorProviderContext?.mockReturnValue({
|
||||
plan: {
|
||||
type: 'sandbox',
|
||||
usage: { vectorSpace: 2 },
|
||||
total: { vectorSpace: 50 },
|
||||
},
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
expect(screen.getByText('billing.vectorSpace.fullTip')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('vector-upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { env } from '@/env'
|
||||
|
||||
const TextLabel: FC<PropsWithChildren> = (props) => {
|
||||
return <label className="text-xs font-semibold leading-none text-text-secondary">{props.children}</label>
|
||||
@ -47,7 +46,7 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = (props) =>
|
||||
}
|
||||
|
||||
export const MaxLengthInput: FC<InputNumberProps> = (props) => {
|
||||
const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
|
||||
const maxValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000', 10)
|
||||
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { ParentMode, PreProcessingRule, ProcessRule, Rules, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { env } from '@/env'
|
||||
import { ChunkingMode, ProcessMode } from '@/models/datasets'
|
||||
import escape from './escape'
|
||||
import unescape from './unescape'
|
||||
@ -9,7 +8,10 @@ import unescape from './unescape'
|
||||
export const DEFAULT_SEGMENT_IDENTIFIER = '\\n\\n'
|
||||
export const DEFAULT_MAXIMUM_CHUNK_LENGTH = 1024
|
||||
export const DEFAULT_OVERLAP = 50
|
||||
export const MAXIMUM_CHUNK_TOKEN_LENGTH = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
|
||||
export const MAXIMUM_CHUNK_TOKEN_LENGTH = Number.parseInt(
|
||||
globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000',
|
||||
10,
|
||||
)
|
||||
|
||||
export type ParentChildConfig = {
|
||||
chunkForContext: ParentMode
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user