Compare commits

..

17 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
0e4f5cb38c refactor: move console_exempt_prefixes to module level in app_factory.py
Co-authored-by: GareArc <52963600+GareArc@users.noreply.github.com>
2026-03-09 07:28:50 +00:00
copilot-swe-agent[bot]
c13d1872d4 Initial plan 2026-03-09 07:27:12 +00:00
GareArc
c911de6a6c fix: exempt setup flow endpoints from license check
Add /console/api/init and /console/api/login to the license exempt
list so that fresh installs can complete setup when the enterprise
license is inactive. Without these exemptions the init password
validation and post-setup auto-login are blocked, causing the setup
page to enter an infinite reload loop.
2026-03-08 23:46:26 -07:00
Xiyuan Chen
968bf10e1c Update api/services/enterprise/enterprise_service.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-08 17:35:50 -07:00
Xiyuan Chen
3d77a5ec08 Update api/services/feature_service.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-08 17:07:17 -07:00
GareArc
41af72449d fix: address PR review feedback on enterprise license enforcement
- Cache invalid license statuses with 30s TTL to prevent DoS amplification
- Return LicenseStatus enum (not raw str) from get_cached_license_status
- Flatten nested try/except into _read_cached_license_status / _fetch_and_cache_license_status helpers
- Escalate log levels from debug to warning with exc_info for cache failures
- Switch before_request license check from fail-open to fail-closed
- Remove dead raise_for_status parameter from BaseRequest.send_request
- Gate license expired_at behind is_authenticated; only expose status to unauthenticated callers (CVE-2025-63387)
- Remove redundant 'not is_console_api' guard in before_request
- Add 8 unit tests for get_cached_license_status
2026-03-08 17:00:12 -07:00
Xiyuan Chen
de72bdef71 Merge branch 'main' into fix/main-enterprise-api-error-handling 2026-03-08 16:28:01 -07:00
GareArc
f97ade7053 fix: use LicenseStatus enum instead of raw strings and tighten path prefix matching
Replace raw license status strings with LicenseStatus enum values in
app_factory.py and enterprise_service.py to prevent silent mismatches.
Use trailing-slash prefixes ('/console/api/', '/api/') to avoid false
matches on unrelated paths like /api-docs.
2026-03-05 01:17:49 -08:00
GareArc
a0dcd04546 fix: remove extra exempts 2026-03-05 01:10:23 -08:00
autofix-ci[bot]
b0138316f0 [autofix.ci] apply automated fixes 2026-03-05 09:02:35 +00:00
GareArc
099568f3da fix: expose license status to unauthenticated /system-features callers
After force-logout due to license expiry, the login page calls
/system-features without auth. The license block was gated behind
is_authenticated, so the frontend always saw status='none' instead
of the actual expiry status. Split the guard so license.status and
expired_at are always returned while workspace usage details remain
auth-gated.
2026-03-05 00:48:50 -08:00
GareArc
0623522d04 fix: exempt console bootstrap APIs from license check to prevent infinite reload loop 2026-03-04 22:13:52 -08:00
GareArc
a25d48c5bd feat: add Redis caching for enterprise license status
Cache license status for 10 minutes to reduce HTTP calls to enterprise API.
Only caches license status, not full system features.

Changes:
- Add EnterpriseService.get_cached_license_status() method
- Cache key: enterprise:license:status
- TTL: 600 seconds (10 minutes)
- Graceful degradation: falls back to API call if Redis fails

Performance improvement:
- Before: HTTP call (~50-200ms) on every API request
- After: Redis lookup (~1ms) on cached requests
- Reduces load on enterprise service by ~99%
2026-03-04 21:29:11 -08:00
GareArc
4f3a020670 feat: extend license enforcement to webapp API endpoints
Extend license middleware to also block webapp API (/api/*) when
enterprise license is expired/inactive/lost.

Changes:
- Check both /console/api and /api endpoints
- Add webapp-specific exempt paths:
  - /api/passport (webapp authentication)
  - /api/login, /api/logout, /api/oauth
  - /api/forgot-password
  - /api/system-features (webapp needs this to check license status)

This ensures both console users and webapp users are blocked when
license expires, maintaining consistent enforcement across all APIs.
2026-03-04 20:40:29 -08:00
GareArc
d2e1177478 fix: use UnauthorizedAndForceLogout to trigger frontend logout on license expiry
Change license check to raise UnauthorizedAndForceLogout exception instead
of returning generic JSON response. This ensures proper frontend handling:

Frontend behavior (service/base.ts line 588):
- Checks if code === 'unauthorized_and_force_logout'
- Executes globalThis.location.reload()
- Forces user logout and redirect to login page
- Login page displays license expiration UI (already exists)

Response format:
- HTTP 401 (not 403)
- code: "unauthorized_and_force_logout"
- Triggers frontend reload which clears auth state

This completes the license enforcement flow:
1. Backend blocks all business APIs when license expires
2. Backend returns proper error code to trigger logout
3. Frontend reloads and redirects to login
4. Login page shows license expiration message
2026-03-04 20:40:29 -08:00
GareArc
8a21fd88fd feat: add global license check middleware to block API access on expiry
Add before_request middleware that validates enterprise license status
for all /console/api endpoints when ENTERPRISE_ENABLED is true.

Behavior:
- Checks license status before each console API request
- Returns 403 with clear error message when license is expired/inactive/lost
- Exempts auth endpoints (login, oauth, forgot-password, etc.)
- Exempts /console/api/features so frontend can fetch license status
- Gracefully handles errors to avoid service disruption

This ensures all business APIs are blocked when license expires,
addressing the issue where APIs remained callable after expiry.
2026-03-04 20:40:29 -08:00
GareArc
1c1bcc67da fix: handle enterprise API errors properly to prevent KeyError crashes
When enterprise API returns 403/404, the response contains error JSON
instead of expected data structure. Code was accessing fields directly
causing KeyError → 500 Internal Server Error.

Changes:
- Add enterprise-specific error classes (EnterpriseAPIError, etc.)
- Implement centralized error validation in EnterpriseRequest.send_request()
- Extract error messages from API responses (message/error/detail fields)
- Raise domain-specific errors based on HTTP status codes
- Preserve backward compatibility with raise_for_status parameter

This prevents KeyError crashes and returns proper HTTP error codes
(403/404) instead of 500 errors.
2026-03-04 19:55:03 -08:00
81 changed files with 1597 additions and 6242 deletions

View File

@@ -1,16 +1,38 @@
import logging
import time
from flask import request
from opentelemetry.trace import get_current_span
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
from configs import dify_config
from contexts.wrapper import RecyclableContextVar
from controllers.console.error import UnauthorizedAndForceLogout
from core.logging.context import init_request_context
from dify_app import DifyApp
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import LicenseStatus
logger = logging.getLogger(__name__)
# Console bootstrap APIs exempt from license check:
# - system-features: license status for expiry UI (GlobalPublicStoreProvider)
# - setup: install/setup status check (AppInitializer)
# - init: init password validation for fresh install (InitPasswordPopup)
# - login: auto-login after setup completion (InstallForm)
# - version: version check (AppContextProvider)
# - activate/check: invitation link validation (signin page)
# Without these exemptions, the signin page triggers location.reload()
# on unauthorized_and_force_logout, causing an infinite loop.
_CONSOLE_EXEMPT_PREFIXES = (
"/console/api/system-features",
"/console/api/setup",
"/console/api/init",
"/console/api/login",
"/console/api/version",
"/console/api/activate/check",
)
# ----------------------------
# Application Factory Function
@@ -31,6 +53,39 @@ def create_flask_app_with_configs() -> DifyApp:
init_request_context()
RecyclableContextVar.increment_thread_recycles()
# Enterprise license validation for API endpoints (both console and webapp)
# When license expires, block all API access except bootstrap endpoints needed
# for the frontend to load the license expiration page without infinite reloads.
if dify_config.ENTERPRISE_ENABLED:
is_console_api = request.path.startswith("/console/api/")
is_webapp_api = request.path.startswith("/api/")
if is_console_api or is_webapp_api:
if is_console_api:
is_exempt = any(request.path.startswith(p) for p in _CONSOLE_EXEMPT_PREFIXES)
else: # webapp API
is_exempt = request.path.startswith("/api/system-features")
if not is_exempt:
try:
# Check license status (cached — see EnterpriseService for TTL details)
license_status = EnterpriseService.get_cached_license_status()
if license_status in (LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST):
raise UnauthorizedAndForceLogout(
f"Enterprise license is {license_status}. Please contact your administrator."
)
if license_status is None:
raise UnauthorizedAndForceLogout(
"Unable to verify enterprise license. Please contact your administrator."
)
except UnauthorizedAndForceLogout:
raise
except Exception:
logger.exception("Failed to check enterprise license status")
raise UnauthorizedAndForceLogout(
"Unable to verify enterprise license. Please contact your administrator."
)
# add after request hook for injecting trace headers from OpenTelemetry span context
# Only adds headers when OTEL is enabled and has valid context
@dify_app.after_request

View File

@@ -121,9 +121,3 @@ class NeedAddIdsError(BaseHTTPException):
error_code = "need_add_ids"
description = "Need to add ids."
code = 400
class VariableValidationError(BaseHTTPException):
error_code = "variable_validation_error"
description = "Variable validation failed."
code = 400

View File

@@ -11,12 +11,7 @@ from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
from controllers.console import console_ns
from controllers.console.app.error import (
ConversationCompletedError,
DraftWorkflowNotExist,
DraftWorkflowNotSync,
VariableValidationError,
)
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
from controllers.console.app.workflow_run import workflow_run_node_execution_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
@@ -37,7 +32,6 @@ from dify_graph.enums import NodeType
from dify_graph.file.models import File
from dify_graph.graph_engine.manager import GraphEngineManager
from dify_graph.model_runtime.utils.encoders import jsonable_encoder
from dify_graph.variables.exc import VariableError
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from factories import file_factory, variable_factory
@@ -308,8 +302,6 @@ class DraftWorkflowApi(Resource):
)
except WorkflowHashNotEqualError:
raise DraftWorkflowNotSync()
except VariableError as e:
raise VariableValidationError(description=str(e))
return {
"result": "success",

View File

@@ -1,6 +1,7 @@
import hashlib
import logging
from threading import Thread, Timer
import time
from threading import Thread
from typing import Union
from flask import Flask, current_app
@@ -95,9 +96,9 @@ class MessageCycleManager:
if auto_generate_conversation_name and is_first_message:
# start generate thread
# time.sleep not block other logic
thread = Timer(
1,
self._generate_conversation_name_worker,
time.sleep(1)
thread = Thread(
target=self._generate_conversation_name_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"conversation_id": conversation_id,

View File

@@ -72,18 +72,9 @@ SEGMENT_TO_VARIABLE_MAP = {
}
_MAX_VARIABLE_DESCRIPTION_LENGTH = 255
def build_conversation_variable_from_mapping(mapping: Mapping[str, Any], /) -> VariableBase:
if not mapping.get("name"):
raise VariableError("missing name")
description = mapping.get("description", "")
if len(description) > _MAX_VARIABLE_DESCRIPTION_LENGTH:
raise VariableError(
f"description of variable '{mapping['name']}' is too long"
f" (max {_MAX_VARIABLE_DESCRIPTION_LENGTH} characters)"
)
return _build_variable_from_mapping(mapping=mapping, selector=[CONVERSATION_VARIABLE_NODE_ID, mapping["name"]])

View File

@@ -1589,8 +1589,6 @@ class WorkflowDraftVariable(Base):
variable.file_id = file_id
variable._set_selector(list(variable_utils.to_selector(node_id, name)))
variable.node_execution_id = node_execution_id
variable.visible = True
variable.is_default_value = False
return variable
@classmethod

View File

@@ -6,6 +6,13 @@ from typing import Any
import httpx
from core.helper.trace_id_helper import generate_traceparent_header
from services.errors.enterprise import (
EnterpriseAPIBadRequestError,
EnterpriseAPIError,
EnterpriseAPIForbiddenError,
EnterpriseAPINotFoundError,
EnterpriseAPIUnauthorizedError,
)
logger = logging.getLogger(__name__)
@@ -41,7 +48,6 @@ class BaseRequest:
params: Mapping[str, Any] | None = None,
*,
timeout: float | httpx.Timeout | None = None,
raise_for_status: bool = False,
) -> Any:
headers = {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key}
url = f"{cls.base_url}{endpoint}"
@@ -64,10 +70,51 @@ class BaseRequest:
request_kwargs["timeout"] = timeout
response = client.request(method, url, **request_kwargs)
if raise_for_status:
response.raise_for_status()
# Validate HTTP status and raise domain-specific errors
if not response.is_success:
cls._handle_error_response(response)
return response.json()
@classmethod
def _handle_error_response(cls, response: httpx.Response) -> None:
"""
Handle non-2xx HTTP responses by raising appropriate domain errors.
Attempts to extract error message from JSON response body,
falls back to status text if parsing fails.
"""
error_message = f"Enterprise API request failed: {response.status_code} {response.reason_phrase}"
# Try to extract error message from JSON response
try:
error_data = response.json()
if isinstance(error_data, dict):
# Common error response formats:
# {"error": "...", "message": "..."}
# {"message": "..."}
# {"detail": "..."}
error_message = (
error_data.get("message") or error_data.get("error") or error_data.get("detail") or error_message
)
except Exception:
# If JSON parsing fails, use the default message
logger.debug(
"Failed to parse error response from enterprise API (status=%s)", response.status_code, exc_info=True
)
# Raise specific error based on status code
if response.status_code == 400:
raise EnterpriseAPIBadRequestError(error_message)
elif response.status_code == 401:
raise EnterpriseAPIUnauthorizedError(error_message)
elif response.status_code == 403:
raise EnterpriseAPIForbiddenError(error_message)
elif response.status_code == 404:
raise EnterpriseAPINotFoundError(error_message)
else:
raise EnterpriseAPIError(error_message, status_code=response.status_code)
class EnterpriseRequest(BaseRequest):
base_url = os.environ.get("ENTERPRISE_API_URL", "ENTERPRISE_API_URL")

View File

@@ -1,15 +1,26 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field, model_validator
from configs import dify_config
from extensions.ext_redis import redis_client
from services.enterprise.base import EnterpriseRequest
if TYPE_CHECKING:
from services.feature_service import LicenseStatus
logger = logging.getLogger(__name__)
DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS = 1.0
# License status cache configuration
LICENSE_STATUS_CACHE_KEY = "enterprise:license:status"
VALID_LICENSE_CACHE_TTL = 600 # 10 minutes — valid licenses are stable
INVALID_LICENSE_CACHE_TTL = 30 # 30 seconds — short so admin fixes are picked up quickly
class WebAppSettings(BaseModel):
@@ -52,7 +63,7 @@ class DefaultWorkspaceJoinResult(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
@model_validator(mode="after")
def _check_workspace_id_when_joined(self) -> "DefaultWorkspaceJoinResult":
def _check_workspace_id_when_joined(self) -> DefaultWorkspaceJoinResult:
if self.joined and not self.workspace_id:
raise ValueError("workspace_id must be non-empty when joined is True")
return self
@@ -115,7 +126,6 @@ class EnterpriseService:
"/default-workspace/members",
json={"account_id": account_id},
timeout=DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS,
raise_for_status=True,
)
if not isinstance(data, dict):
raise ValueError("Invalid response format from enterprise default workspace API")
@@ -223,3 +233,64 @@ class EnterpriseService:
params = {"appId": app_id}
EnterpriseRequest.send_request("DELETE", "/webapp/clean", params=params)
@classmethod
def get_cached_license_status(cls) -> LicenseStatus | None:
"""Get enterprise license status with Redis caching to reduce HTTP calls.
Caches valid statuses (active/expiring) for 10 minutes and invalid statuses
(inactive/expired/lost) for 30 seconds. The shorter TTL for invalid statuses
balances prompt license-fix detection against DoS mitigation — without
caching, every request on an expired license would hit the enterprise API.
Returns:
LicenseStatus enum value, or None if enterprise is disabled / unreachable.
"""
if not dify_config.ENTERPRISE_ENABLED:
return None
cached = cls._read_cached_license_status()
if cached is not None:
return cached
return cls._fetch_and_cache_license_status()
@classmethod
def _read_cached_license_status(cls) -> LicenseStatus | None:
"""Read license status from Redis cache, returning None on miss or failure."""
from services.feature_service import LicenseStatus
try:
raw = redis_client.get(LICENSE_STATUS_CACHE_KEY)
if raw:
value = raw.decode("utf-8") if isinstance(raw, bytes) else raw
return LicenseStatus(value)
except Exception:
logger.warning("Failed to read license status from cache", exc_info=True)
return None
@classmethod
def _fetch_and_cache_license_status(cls) -> LicenseStatus | None:
"""Fetch license status from enterprise API and cache the result."""
from services.feature_service import LicenseStatus
try:
info = cls.get_info()
license_info = info.get("License")
if not license_info:
return None
status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
ttl = (
VALID_LICENSE_CACHE_TTL
if status in (LicenseStatus.ACTIVE, LicenseStatus.EXPIRING)
else INVALID_LICENSE_CACHE_TTL
)
try:
redis_client.setex(LICENSE_STATUS_CACHE_KEY, ttl, status)
except Exception:
logger.warning("Failed to cache license status", exc_info=True)
return status
except Exception:
logger.exception("Failed to get enterprise license status")
return None

View File

@@ -7,6 +7,7 @@ from . import (
conversation,
dataset,
document,
enterprise,
file,
index,
message,
@@ -21,6 +22,7 @@ __all__ = [
"conversation",
"dataset",
"document",
"enterprise",
"file",
"index",
"message",

View File

@@ -0,0 +1,45 @@
"""Enterprise service errors."""
from services.errors.base import BaseServiceError
class EnterpriseServiceError(BaseServiceError):
"""Base exception for enterprise service errors."""
def __init__(self, description: str | None = None, status_code: int | None = None):
super().__init__(description)
self.status_code = status_code
class EnterpriseAPIError(EnterpriseServiceError):
"""Generic enterprise API error (non-2xx response)."""
pass
class EnterpriseAPINotFoundError(EnterpriseServiceError):
"""Enterprise API returned 404 Not Found."""
def __init__(self, description: str | None = None):
super().__init__(description, status_code=404)
class EnterpriseAPIForbiddenError(EnterpriseServiceError):
"""Enterprise API returned 403 Forbidden."""
def __init__(self, description: str | None = None):
super().__init__(description, status_code=403)
class EnterpriseAPIUnauthorizedError(EnterpriseServiceError):
"""Enterprise API returned 401 Unauthorized."""
def __init__(self, description: str | None = None):
super().__init__(description, status_code=401)
class EnterpriseAPIBadRequestError(EnterpriseServiceError):
"""Enterprise API returned 400 Bad Request."""
def __init__(self, description: str | None = None):
super().__init__(description, status_code=400)

View File

@@ -379,14 +379,19 @@ class FeatureService:
)
features.webapp_auth.sso_config.protocol = enterprise_info.get("SSOEnforcedForWebProtocol", "")
if is_authenticated and (license_info := enterprise_info.get("License")):
# SECURITY NOTE: Only license *status* is exposed to unauthenticated callers
# so the login page can detect an expired/inactive license after force-logout.
# All other license details (expiry date, workspace usage) remain auth-gated.
# This behavior reflects prior internal review of information-leakage risks.
if license_info := enterprise_info.get("License"):
features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
features.license.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
features.license.workspaces.enabled = workspaces_info.get("enabled", False)
features.license.workspaces.limit = workspaces_info.get("limit", 0)
features.license.workspaces.size = workspaces_info.get("used", 0)
if is_authenticated:
features.license.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
features.license.workspaces.enabled = workspaces_info.get("enabled", False)
features.license.workspaces.limit = workspaces_info.get("limit", 0)
features.license.workspaces.size = workspaces_info.get("used", 0)
if "PluginInstallationPermission" in enterprise_info:
plugin_installation_info = enterprise_info["PluginInstallationPermission"]

View File

@@ -700,8 +700,6 @@ def _model_to_insertion_dict(model: WorkflowDraftVariable) -> dict[str, Any]:
d["updated_at"] = model.updated_at
if model.description is not None:
d["description"] = model.description
if model.is_default_value is not None:
d["is_default_value"] = model.is_default_value
return d

View File

@@ -358,10 +358,9 @@ class TestFeatureService:
assert result is not None
assert isinstance(result, SystemFeatureModel)
# --- 1. Verify Response Payload Optimization (Data Minimization) ---
# Ensure only essential UI flags are returned to unauthenticated clients
# to keep the payload lightweight and adhere to architectural boundaries.
assert result.license.status == LicenseStatus.NONE
# --- 1. Verify only license *status* is exposed to unauthenticated clients ---
# Detailed license info (expiry, workspaces) remains auth-gated.
assert result.license.status == LicenseStatus.ACTIVE
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
assert result.license.workspaces.limit == 0

View File

@@ -1,70 +0,0 @@
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
RemoteFileUploadError,
TooManyFilesError,
UnsupportedFileTypeError,
)
class TestFilenameNotExistsError:
def test_defaults(self):
error = FilenameNotExistsError()
assert error.code == 400
assert error.description == "The specified filename does not exist."
class TestRemoteFileUploadError:
def test_defaults(self):
error = RemoteFileUploadError()
assert error.code == 400
assert error.description == "Error uploading remote file."
class TestFileTooLargeError:
def test_defaults(self):
error = FileTooLargeError()
assert error.code == 413
assert error.error_code == "file_too_large"
assert error.description == "File size exceeded. {message}"
class TestUnsupportedFileTypeError:
def test_defaults(self):
error = UnsupportedFileTypeError()
assert error.code == 415
assert error.error_code == "unsupported_file_type"
assert error.description == "File type not allowed."
class TestBlockedFileExtensionError:
def test_defaults(self):
error = BlockedFileExtensionError()
assert error.code == 400
assert error.error_code == "file_extension_blocked"
assert error.description == "The file extension is blocked for security reasons."
class TestTooManyFilesError:
def test_defaults(self):
error = TooManyFilesError()
assert error.code == 400
assert error.error_code == "too_many_files"
assert error.description == "Only one file is allowed."
class TestNoFileUploadedError:
def test_defaults(self):
error = NoFileUploadedError()
assert error.code == 400
assert error.error_code == "no_file_uploaded"
assert error.description == "Please upload your file."

View File

@@ -1,95 +1,22 @@
from flask import Response
from controllers.common.file_response import (
_normalize_mime_type,
enforce_download_for_html,
is_html_content,
)
from controllers.common.file_response import enforce_download_for_html, is_html_content
class TestNormalizeMimeType:
def test_returns_empty_string_for_none(self):
assert _normalize_mime_type(None) == ""
def test_returns_empty_string_for_empty_string(self):
assert _normalize_mime_type("") == ""
def test_normalizes_mime_type(self):
assert _normalize_mime_type("Text/HTML; Charset=UTF-8") == "text/html"
class TestIsHtmlContent:
def test_detects_html_via_mime_type(self):
class TestFileResponseHelpers:
def test_is_html_content_detects_mime_type(self):
mime_type = "text/html; charset=UTF-8"
result = is_html_content(
mime_type=mime_type,
filename="file.txt",
extension="txt",
)
result = is_html_content(mime_type, filename="file.txt", extension="txt")
assert result is True
def test_detects_html_via_extension_argument(self):
result = is_html_content(
mime_type="text/plain",
filename=None,
extension="html",
)
def test_is_html_content_detects_extension(self):
result = is_html_content("text/plain", filename="report.html", extension=None)
assert result is True
def test_detects_html_via_filename_extension(self):
result = is_html_content(
mime_type="text/plain",
filename="report.html",
extension=None,
)
assert result is True
def test_returns_false_when_no_html_detected_anywhere(self):
"""
Missing negative test:
- MIME type is not HTML
- filename has no HTML extension
- extension argument is not HTML
"""
result = is_html_content(
mime_type="application/json",
filename="data.json",
extension="json",
)
assert result is False
def test_returns_false_when_all_inputs_are_none(self):
result = is_html_content(
mime_type=None,
filename=None,
extension=None,
)
assert result is False
class TestEnforceDownloadForHtml:
def test_sets_attachment_when_filename_missing(self):
response = Response("payload", mimetype="text/html")
updated = enforce_download_for_html(
response,
mime_type="text/html",
filename=None,
extension="html",
)
assert updated is True
assert response.headers["Content-Disposition"] == "attachment"
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_sets_headers_when_filename_present(self):
def test_enforce_download_for_html_sets_headers(self):
response = Response("payload", mimetype="text/html")
updated = enforce_download_for_html(
@@ -100,12 +27,11 @@ class TestEnforceDownloadForHtml:
)
assert updated is True
assert response.headers["Content-Disposition"].startswith("attachment")
assert "unsafe.html" in response.headers["Content-Disposition"]
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_does_not_modify_response_for_non_html_content(self):
def test_enforce_download_for_html_no_change_for_non_html(self):
response = Response("payload", mimetype="text/plain")
updated = enforce_download_for_html(

View File

@@ -1,188 +0,0 @@
from uuid import UUID
import httpx
import pytest
from controllers.common import helpers
from controllers.common.helpers import FileInfo, guess_file_info_from_response
def make_response(
url="https://example.com/file.txt",
headers=None,
content=None,
):
return httpx.Response(
200,
request=httpx.Request("GET", url),
headers=headers or {},
content=content or b"",
)
class TestGuessFileInfoFromResponse:
def test_filename_from_url(self):
response = make_response(
url="https://example.com/test.pdf",
content=b"Hello World",
)
info = guess_file_info_from_response(response)
assert info.filename == "test.pdf"
assert info.extension == ".pdf"
assert info.mimetype == "application/pdf"
def test_filename_from_content_disposition(self):
headers = {
"Content-Disposition": "attachment; filename=myfile.csv",
"Content-Type": "text/csv",
}
response = make_response(
url="https://example.com/",
headers=headers,
content=b"Hello World",
)
info = guess_file_info_from_response(response)
assert info.filename == "myfile.csv"
assert info.extension == ".csv"
assert info.mimetype == "text/csv"
@pytest.mark.parametrize(
("magic_available", "expected_ext"),
[
(True, "txt"),
(False, "bin"),
],
)
def test_generated_filename_when_missing(self, monkeypatch, magic_available, expected_ext):
if magic_available:
if helpers.magic is None:
pytest.skip("python-magic is not installed, cannot run 'magic_available=True' test variant")
else:
monkeypatch.setattr(helpers, "magic", None)
response = make_response(
url="https://example.com/",
content=b"Hello World",
)
info = guess_file_info_from_response(response)
name, ext = info.filename.split(".")
UUID(name)
assert ext == expected_ext
def test_mimetype_from_header_when_unknown(self):
headers = {"Content-Type": "application/json"}
response = make_response(
url="https://example.com/file.unknown",
headers=headers,
content=b'{"a": 1}',
)
info = guess_file_info_from_response(response)
assert info.mimetype == "application/json"
def test_extension_added_when_missing(self):
headers = {"Content-Type": "image/png"}
response = make_response(
url="https://example.com/image",
headers=headers,
content=b"fakepngdata",
)
info = guess_file_info_from_response(response)
assert info.extension == ".png"
assert info.filename.endswith(".png")
def test_content_length_used_as_size(self):
headers = {
"Content-Length": "1234",
"Content-Type": "text/plain",
}
response = make_response(
url="https://example.com/a.txt",
headers=headers,
content=b"a" * 1234,
)
info = guess_file_info_from_response(response)
assert info.size == 1234
def test_size_minus_one_when_header_missing(self):
response = make_response(url="https://example.com/a.txt")
info = guess_file_info_from_response(response)
assert info.size == -1
def test_fallback_to_bin_extension(self):
headers = {"Content-Type": "application/octet-stream"}
response = make_response(
url="https://example.com/download",
headers=headers,
content=b"\x00\x01\x02\x03",
)
info = guess_file_info_from_response(response)
assert info.extension == ".bin"
assert info.filename.endswith(".bin")
def test_return_type(self):
response = make_response()
info = guess_file_info_from_response(response)
assert isinstance(info, FileInfo)
class TestMagicImportWarnings:
@pytest.mark.parametrize(
("platform_name", "expected_message"),
[
("Windows", "pip install python-magic-bin"),
("Darwin", "brew install libmagic"),
("Linux", "sudo apt-get install libmagic1"),
("Other", "install `libmagic`"),
],
)
def test_magic_import_warning_per_platform(
self,
monkeypatch,
platform_name,
expected_message,
):
import builtins
import importlib
# Force ImportError when "magic" is imported
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "magic":
raise ImportError("No module named magic")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
monkeypatch.setattr("platform.system", lambda: platform_name)
# Remove helpers so it imports fresh
import sys
original_helpers = sys.modules.get(helpers.__name__)
sys.modules.pop(helpers.__name__, None)
try:
with pytest.warns(UserWarning, match="To use python-magic") as warning:
imported_helpers = importlib.import_module(helpers.__name__)
assert expected_message in str(warning[0].message)
finally:
if original_helpers is not None:
sys.modules[helpers.__name__] = original_helpers

View File

@@ -1,189 +0,0 @@
import sys
from enum import StrEnum
from unittest.mock import MagicMock, patch
import pytest
from flask_restx import Namespace
from pydantic import BaseModel
class UserModel(BaseModel):
id: int
name: str
class ProductModel(BaseModel):
id: int
price: float
@pytest.fixture(autouse=True)
def mock_console_ns():
"""Mock the console_ns to avoid circular imports during test collection."""
mock_ns = MagicMock(spec=Namespace)
mock_ns.models = {}
# Inject mock before importing schema module
with patch.dict(sys.modules, {"controllers.console": MagicMock(console_ns=mock_ns)}):
yield mock_ns
def test_default_ref_template_value():
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0
assert DEFAULT_REF_TEMPLATE_SWAGGER_2_0 == "#/definitions/{model}"
def test_register_schema_model_calls_namespace_schema_model():
from controllers.common.schema import register_schema_model
namespace = MagicMock(spec=Namespace)
register_schema_model(namespace, UserModel)
namespace.schema_model.assert_called_once()
model_name, schema = namespace.schema_model.call_args.args
assert model_name == "UserModel"
assert isinstance(schema, dict)
assert "properties" in schema
def test_register_schema_model_passes_schema_from_pydantic():
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_model
namespace = MagicMock(spec=Namespace)
register_schema_model(namespace, UserModel)
schema = namespace.schema_model.call_args.args[1]
expected_schema = UserModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
assert schema == expected_schema
def test_register_schema_models_registers_multiple_models():
from controllers.common.schema import register_schema_models
namespace = MagicMock(spec=Namespace)
register_schema_models(namespace, UserModel, ProductModel)
assert namespace.schema_model.call_count == 2
called_names = [call.args[0] for call in namespace.schema_model.call_args_list]
assert called_names == ["UserModel", "ProductModel"]
def test_register_schema_models_calls_register_schema_model(monkeypatch):
from controllers.common.schema import register_schema_models
namespace = MagicMock(spec=Namespace)
calls = []
def fake_register(ns, model):
calls.append((ns, model))
monkeypatch.setattr(
"controllers.common.schema.register_schema_model",
fake_register,
)
register_schema_models(namespace, UserModel, ProductModel)
assert calls == [
(namespace, UserModel),
(namespace, ProductModel),
]
class StatusEnum(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
class PriorityEnum(StrEnum):
HIGH = "high"
LOW = "low"
def test_get_or_create_model_returns_existing_model(mock_console_ns):
from controllers.common.schema import get_or_create_model
existing_model = MagicMock()
mock_console_ns.models = {"TestModel": existing_model}
result = get_or_create_model("TestModel", {"key": "value"})
assert result == existing_model
mock_console_ns.model.assert_not_called()
def test_get_or_create_model_creates_new_model_when_not_exists(mock_console_ns):
from controllers.common.schema import get_or_create_model
mock_console_ns.models = {}
new_model = MagicMock()
mock_console_ns.model.return_value = new_model
field_def = {"name": {"type": "string"}}
result = get_or_create_model("NewModel", field_def)
assert result == new_model
mock_console_ns.model.assert_called_once_with("NewModel", field_def)
def test_get_or_create_model_does_not_call_model_if_exists(mock_console_ns):
from controllers.common.schema import get_or_create_model
existing_model = MagicMock()
mock_console_ns.models = {"ExistingModel": existing_model}
result = get_or_create_model("ExistingModel", {"key": "value"})
assert result == existing_model
mock_console_ns.model.assert_not_called()
def test_register_enum_models_registers_single_enum():
from controllers.common.schema import register_enum_models
namespace = MagicMock(spec=Namespace)
register_enum_models(namespace, StatusEnum)
namespace.schema_model.assert_called_once()
model_name, schema = namespace.schema_model.call_args.args
assert model_name == "StatusEnum"
assert isinstance(schema, dict)
def test_register_enum_models_registers_multiple_enums():
from controllers.common.schema import register_enum_models
namespace = MagicMock(spec=Namespace)
register_enum_models(namespace, StatusEnum, PriorityEnum)
assert namespace.schema_model.call_count == 2
called_names = [call.args[0] for call in namespace.schema_model.call_args_list]
assert called_names == ["StatusEnum", "PriorityEnum"]
def test_register_enum_models_uses_correct_ref_template():
from controllers.common.schema import register_enum_models
namespace = MagicMock(spec=Namespace)
register_enum_models(namespace, StatusEnum)
schema = namespace.schema_model.call_args.args[1]
# Verify the schema contains enum values
assert "enum" in schema or "anyOf" in schema

View File

@@ -124,12 +124,12 @@ def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch):
def start(self):
self.started = True
def fake_thread(*args, **kwargs):
def fake_thread(**kwargs):
thread = DummyThread(**kwargs)
captured["thread"] = thread
return thread
monkeypatch.setattr(message_cycle_manager, "Timer", fake_thread)
monkeypatch.setattr(message_cycle_manager, "Thread", fake_thread)
manager = MessageCycleManager(application_generate_entity=entity, task_state=MagicMock())
thread = manager.generate_conversation_name(conversation_id="existing-conversation-id", query="hello")

View File

@@ -1,9 +1,8 @@
"""Unit tests for enterprise service integrations.
This module covers the enterprise-only default workspace auto-join behavior:
- Enterprise mode disabled: no external calls
- Successful join / skipped join: no errors
- Failures (network/invalid response/invalid UUID): soft-fail wrapper must not raise
Covers:
- Default workspace auto-join behavior
- License status caching (get_cached_license_status)
"""
from unittest.mock import patch
@@ -11,6 +10,9 @@ from unittest.mock import patch
import pytest
from services.enterprise.enterprise_service import (
INVALID_LICENSE_CACHE_TTL,
LICENSE_STATUS_CACHE_KEY,
VALID_LICENSE_CACHE_TTL,
DefaultWorkspaceJoinResult,
EnterpriseService,
try_join_default_workspace,
@@ -37,7 +39,6 @@ class TestJoinDefaultWorkspace:
"/default-workspace/members",
json={"account_id": account_id},
timeout=1.0,
raise_for_status=True,
)
def test_join_default_workspace_invalid_response_format_raises(self):
@@ -139,3 +140,134 @@ class TestTryJoinDefaultWorkspace:
# Should not raise even though UUID parsing fails inside join_default_workspace
try_join_default_workspace("not-a-uuid")
# ---------------------------------------------------------------------------
# get_cached_license_status
# ---------------------------------------------------------------------------
_EE_SVC = "services.enterprise.enterprise_service"
class TestGetCachedLicenseStatus:
"""Tests for EnterpriseService.get_cached_license_status."""
def test_returns_none_when_enterprise_disabled(self):
with patch(f"{_EE_SVC}.dify_config") as mock_config:
mock_config.ENTERPRISE_ENABLED = False
assert EnterpriseService.get_cached_license_status() is None
def test_cache_hit_returns_license_status_enum(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = b"active"
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.ACTIVE
assert isinstance(result, LicenseStatus)
mock_get_info.assert_not_called()
def test_cache_miss_fetches_api_and_caches_valid_status(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_get_info.return_value = {"License": {"status": "active"}}
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.ACTIVE
mock_redis.setex.assert_called_once_with(
LICENSE_STATUS_CACHE_KEY, VALID_LICENSE_CACHE_TTL, LicenseStatus.ACTIVE
)
def test_cache_miss_fetches_api_and_caches_invalid_status_with_short_ttl(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_get_info.return_value = {"License": {"status": "expired"}}
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.EXPIRED
mock_redis.setex.assert_called_once_with(
LICENSE_STATUS_CACHE_KEY, INVALID_LICENSE_CACHE_TTL, LicenseStatus.EXPIRED
)
def test_redis_read_failure_falls_through_to_api(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.side_effect = ConnectionError("redis down")
mock_get_info.return_value = {"License": {"status": "active"}}
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.ACTIVE
mock_get_info.assert_called_once()
def test_redis_write_failure_still_returns_status(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_redis.setex.side_effect = ConnectionError("redis down")
mock_get_info.return_value = {"License": {"status": "expiring"}}
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.EXPIRING
def test_api_failure_returns_none(self):
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_get_info.side_effect = Exception("network failure")
assert EnterpriseService.get_cached_license_status() is None
def test_api_returns_no_license_info(self):
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_get_info.return_value = {} # no "License" key
assert EnterpriseService.get_cached_license_status() is None
mock_redis.setex.assert_not_called()

View File

@@ -25,7 +25,6 @@ from services.workflow_draft_variable_service import (
DraftVariableSaver,
VariableResetError,
WorkflowDraftVariableService,
_model_to_insertion_dict,
)
@@ -476,41 +475,3 @@ class TestWorkflowDraftVariableService:
assert node_var.visible == True
assert node_var.editable == True
assert node_var.node_execution_id == "exec-id"
class TestModelToInsertionDict:
"""Reproduce two production errors in _model_to_insertion_dict / _new()."""
def test_visible_and_is_default_value_always_present(self):
"""Problem 1: _new() did not set visible/is_default_value, causing
inconsistent dict keys across rows in multi-row INSERT and missing
is_default_value in the insertion dict entirely.
"""
conv_var = WorkflowDraftVariable.new_conversation_variable(
app_id="app-1",
name="counter",
value=StringSegment(value="0"),
)
# _new() should explicitly set these fields so they are not None
assert conv_var.visible is not None
assert conv_var.is_default_value is not None
d = _model_to_insertion_dict(conv_var)
# visible must appear in every row's dict
assert "visible" in d
# is_default_value must always be present
assert "is_default_value" in d
def test_description_passthrough(self):
"""_model_to_insertion_dict passes description as-is;
length validation is enforced earlier in build_conversation_variable_from_mapping.
"""
desc = "a" * 200
conv_var = WorkflowDraftVariable.new_conversation_variable(
app_id="app-1",
name="counter",
value=StringSegment(value="0"),
description=desc,
)
d = _model_to_insertion_dict(conv_var)
assert d["description"] == desc

View File

@@ -503,7 +503,7 @@ describe('TimePicker', () => {
const emitted = onChange.mock.calls[0][0]
expect(isDayjsObject(emitted)).toBe(true)
// 10:30 UTC converted to America/New_York (UTC-5 in Jan) = 05:30
expect(emitted.utcOffset()).toBe(dayjs.tz('2024-01-01', 'America/New_York').utcOffset())
expect(emitted.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset())
expect(emitted.hour()).toBe(5)
expect(emitted.minute()).toBe(30)
})

View File

@@ -20,7 +20,7 @@ describe('dayjs utilities', () => {
const result = toDayjs('07:15 PM', { timezone: tz })
expect(result).toBeDefined()
expect(result?.format('HH:mm')).toBe('19:15')
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone: tz }).startOf('day').utcOffset())
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone: tz }).utcOffset())
})
it('isDayjsObject detects dayjs instances', () => {

View File

@@ -225,97 +225,5 @@ describe('Compliance', () => {
payload: ACCOUNT_SETTING_TAB.BILLING,
})
})
// isPending branches: spinner visible, disabled class, guard blocks second call
it('should show spinner and guard against duplicate download when isPending is true', async () => {
// Arrange
let resolveDownload: (value: { url: string }) => void
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
resolveDownload = resolve
}))
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.team,
},
})
// Act
openMenuAndRender()
const downloadButtons = screen.getAllByText('common.operation.download')
fireEvent.click(downloadButtons[0])
// Assert - btn-disabled class and spinner should appear while mutation is pending
await waitFor(() => {
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
expect(menuItem).not.toBeNull()
const disabledBtn = menuItem!.querySelector('.cursor-not-allowed')
expect(disabledBtn).not.toBeNull()
}, { timeout: 10000 })
// Cleanup: resolve the pending promise
resolveDownload!({ url: 'http://example.com/doc.pdf' })
await waitFor(() => {
expect(downloadUrl).toHaveBeenCalled()
})
})
it('should not call downloadCompliance again while pending', async () => {
let resolveDownload: (value: { url: string }) => void
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
resolveDownload = resolve
}))
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.team,
},
})
openMenuAndRender()
const downloadButtons = screen.getAllByText('common.operation.download')
// First click starts download
fireEvent.click(downloadButtons[0])
// Wait for mutation to start and React to re-render (isPending=true)
await waitFor(() => {
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
const el = menuItem!.querySelector('.cursor-not-allowed')
expect(el).not.toBeNull()
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
}, { timeout: 10000 })
// Second click while pending - should be guarded by isPending check
fireEvent.click(downloadButtons[0])
resolveDownload!({ url: 'http://example.com/doc.pdf' })
await waitFor(() => {
expect(downloadUrl).toHaveBeenCalledTimes(1)
}, { timeout: 10000 })
// getDocDownloadUrl should still have only been called once
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
}, 20000)
// canShowUpgradeTooltip=false: enterprise plan has empty tooltip text → no TooltipContent
it('should show upgrade badge with empty tooltip for enterprise plan', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.enterprise,
},
})
// Act
openMenuAndRender()
// Assert - enterprise is not in any download list, so upgrade badges should appear
// The key branch: upgradeTooltip[Plan.enterprise] = '' → canShowUpgradeTooltip=false
expect(screen.getAllByText('billing.upgradeBtn.encourageShort').length).toBeGreaterThan(0)
})
})
})

View File

@@ -247,23 +247,6 @@ describe('AccountDropdown', () => {
// Assert
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
})
// Compound AND middle-false: IS_CLOUD_EDITION=true but isCurrentWorkspaceOwner=false
it('should hide Compliance in Cloud Edition when user is not workspace owner', () => {
// Arrange
mockConfig.IS_CLOUD_EDITION = true
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
isCurrentWorkspaceOwner: false,
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.queryByText('common.userProfile.compliance')).not.toBeInTheDocument()
})
})
describe('Actions', () => {

View File

@@ -36,8 +36,8 @@ vi.mock('@/config', async (importOriginal) => {
return {
...actual,
IS_CE_EDITION: false,
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value || '' },
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value || '' },
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value },
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value },
}
})
@@ -173,18 +173,25 @@ describe('Support', () => {
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
})
// Optional chain null guard: ZENDESK_WIDGET_KEY is null
it('should show Email Support when ZENDESK_WIDGET_KEY is null', () => {
it('should show email support if specified in the config', () => {
// Arrange
mockZendeskKey.value = null as unknown as string
mockZendeskKey.value = ''
mockSupportEmailKey.value = 'support@example.com'
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
},
})
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
expect(screen.queryByText('common.userProfile.emailSupport')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.emailSupport')?.closest('a')?.getAttribute('href')?.startsWith(`mailto:${mockSupportEmailKey.value}`)).toBe(true)
})
})

View File

@@ -136,32 +136,4 @@ describe('WorkplaceSelector', () => {
})
})
})
describe('Edge Cases', () => {
// find() returns undefined: no workspace with current: true
it('should not crash when no workspace has current: true', () => {
// Arrange
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
// name[0]?.toLocaleUpperCase() undefined: workspace with empty name
it('should not crash when workspace name is empty string', () => {
// Arrange
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
})
})

View File

@@ -388,33 +388,37 @@ describe('DataSourceNotion Component', () => {
})
describe('Additional Action Edge Cases', () => {
it.each([
undefined,
null,
{},
{ data: undefined },
{ data: null },
{ data: '' },
{ data: 0 },
{ data: false },
{ data: 'http' },
{ data: 'internal' },
{ data: 'unknown' },
])('should cover connection data branch: %s', async (val) => {
it('should cover all possible falsy/nullish branches for connection data in handleAuthAgain and useEffect', async () => {
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
render(<DataSourceNotion />)
// Trigger handleAuthAgain with these values
const workspaceItem = getWorkspaceItem('Workspace 1')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
fireEvent.click(authAgainBtn)
const connectionCases = [
undefined,
null,
{},
{ data: undefined },
{ data: null },
{ data: '' },
{ data: 0 },
{ data: false },
{ data: 'http' },
{ data: 'internal' },
{ data: 'unknown' },
]
expect(useNotionConnection).toHaveBeenCalled()
for (const val of connectionCases) {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
// Trigger handleAuthAgain with these values
const workspaceItem = getWorkspaceItem('Workspace 1')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
fireEvent.click(authAgainBtn)
}
await waitFor(() => expect(useNotionConnection).toHaveBeenCalled())
})
})

View File

@@ -134,46 +134,5 @@ describe('ConfigJinaReaderModal Component', () => {
resolveSave!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
})
it('should show encryption info and external link in the modal', async () => {
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Verify PKCS1_OAEP link exists
const pkcsLink = screen.getByText('PKCS1_OAEP')
expect(pkcsLink.closest('a')).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
// Verify the Jina Reader external link
const jinaLink = screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })
expect(jinaLink).toHaveAttribute('target', '_blank')
})
it('should return early when save is clicked while already saving (isSaving guard)', async () => {
const user = userEvent.setup()
// Arrange - a save that never resolves so isSaving stays true
let resolveFirst: (value: { result: 'success' }) => void
const neverResolves = new Promise<{ result: 'success' }>((resolve) => {
resolveFirst = resolve
})
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(neverResolves)
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
await user.type(apiKeyInput, 'valid-key')
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
// First click - starts saving, isSaving becomes true
await user.click(saveBtn)
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Second click using fireEvent bypasses disabled check - hits isSaving guard
const { fireEvent: fe } = await import('@testing-library/react')
fe.click(saveBtn)
// Still only called once because isSaving=true returns early
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Cleanup
resolveFirst!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalled())
})
})
})

View File

@@ -195,57 +195,4 @@ describe('DataSourceWebsite Component', () => {
expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
describe('Firecrawl Save Flow', () => {
it('should re-fetch sources after saving Firecrawl configuration', async () => {
// Arrange
await renderAndWait(DataSourceProvider.fireCrawl)
fireEvent.click(screen.getByText('common.dataSource.configure'))
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
vi.mocked(fetchDataSources).mockClear()
// Act - fill in required API key field and save
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
fireEvent.change(apiKeyInput, { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(fetchDataSources).toHaveBeenCalled()
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
})
})
})
describe('Cancel Flow', () => {
it('should close watercrawl modal when cancel is clicked', async () => {
// Arrange
await renderAndWait(DataSourceProvider.waterCrawl)
fireEvent.click(screen.getByText('common.dataSource.configure'))
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert - modal closed
await waitFor(() => {
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
})
})
it('should close jina reader modal when cancel is clicked', async () => {
// Arrange
await renderAndWait(DataSourceProvider.jinaReader)
fireEvent.click(screen.getByText('common.dataSource.configure'))
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert - modal closed
await waitFor(() => {
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
})
})
})
})

View File

@@ -1,9 +1,8 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Operate from './Operate'
describe('Operate', () => {
it('should render cancel and save when editing is open', () => {
it('renders cancel and save when editing', () => {
render(
<Operate
isOpen
@@ -19,7 +18,7 @@ describe('Operate', () => {
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
})
it('should show add-key prompt when closed', () => {
it('shows add key prompt when closed', () => {
render(
<Operate
isOpen={false}
@@ -34,7 +33,7 @@ describe('Operate', () => {
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
})
it('should show invalid state and edit prompt when status is fail', () => {
it('shows invalid state indicator and edit prompt when status is fail', () => {
render(
<Operate
isOpen={false}
@@ -50,7 +49,7 @@ describe('Operate', () => {
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
})
it('should show edit prompt without error text when status is success', () => {
it('shows edit prompt without error text when status is success', () => {
render(
<Operate
isOpen={false}
@@ -66,30 +65,11 @@ describe('Operate', () => {
expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull()
})
it('should not call onAdd when disabled', async () => {
const user = userEvent.setup()
const onAdd = vi.fn()
it('shows no actions for unsupported status', () => {
render(
<Operate
isOpen={false}
status="add"
disabled
onAdd={onAdd}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
await user.click(screen.getByText('common.provider.addKey'))
expect(onAdd).not.toHaveBeenCalled()
})
it('should show no actions when status is unsupported', () => {
render(
<Operate
isOpen={false}
// @ts-expect-error intentional invalid status for runtime fallback coverage
status="unknown"
status={'unknown' as never}
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}

View File

@@ -267,99 +267,6 @@ describe('MembersPage', () => {
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
})
it('should show non-billing member format for team plan even when billing is enabled', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
plan: {
type: Plan.team,
total: { teamMembers: 50 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
} as unknown as ReturnType<typeof useProviderContext>['plan'],
}))
render(<MembersPage />)
// Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
})
it('should show invite button when user is manager but not owner', () => {
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: true,
} as unknown as AppContextValue)
render(<MembersPage />)
expect(screen.getByRole('button', { name: /invite/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
})
it('should use created_at as fallback when last_active_at is empty', () => {
const memberNoLastActive: Member = {
...mockAccounts[1],
last_active_at: '',
created_at: '1700000000',
}
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [memberNoLastActive] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1700000000000)
})
it('should not show plural s when only one account in billing layout', () => {
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [mockAccounts[0]] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
plan: {
type: Plan.sandbox,
total: { teamMembers: 5 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
} as unknown as ReturnType<typeof useProviderContext>['plan'],
}))
render(<MembersPage />)
expect(screen.getByText(/plansCommon\.member/i)).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should not show plural s when only one account in non-billing layout', () => {
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [mockAccounts[0]] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should show normal role as fallback for unknown role', () => {
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: false,
} as unknown as AppContextValue)
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [{ ...mockAccounts[1], role: 'unknown_role' as Member['role'] }] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('should show upgrade button when member limit is full', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,

View File

@@ -1,5 +1,5 @@
import type { InvitationResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
@@ -171,66 +171,6 @@ describe('InviteModal', () => {
expect(screen.queryByText('user@example.com')).not.toBeInTheDocument()
})
it('should show unlimited label when workspace member limit is zero', async () => {
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 5, limit: 0 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
expect(await screen.findByText(/license\.unlimited/i)).toBeInTheDocument()
})
it('should initialize usedSize to zero when workspace_members.size is null', async () => {
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: null, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
// usedSize starts at 0 (via ?? 0 fallback), no emails added → counter shows 0
expect(await screen.findByText('0')).toBeInTheDocument()
})
it('should not call onSend when invite result is not success', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockResolvedValue({
result: 'error',
invitation_results: [],
} as unknown as InvitationResponse)
renderModal()
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockOnSend).not.toHaveBeenCalled()
expect(mockOnCancel).not.toHaveBeenCalled()
})
})
it('should show destructive text color when used size exceeds limit', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
const counter = screen.getByText('11')
expect(counter.closest('div')).toHaveClass('text-text-destructive')
})
it('should not submit if already submitting', async () => {
const user = userEvent.setup()
let resolveInvite: (value: InvitationResponse) => void
@@ -262,72 +202,4 @@ describe('InviteModal', () => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
const user = userEvent.setup()
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
// isLimitExceeded=true → button is disabled, cannot submit
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
expect(sendBtn).toBeDisabled()
expect(inviteMember).not.toHaveBeenCalled()
})
it('should hit isSubmitting guard inside handleSend when button is force-clicked during submission', async () => {
const user = userEvent.setup()
let resolveInvite: (value: InvitationResponse) => void
const invitePromise = new Promise<InvitationResponse>((resolve) => {
resolveInvite = resolve
})
vi.mocked(inviteMember).mockReturnValue(invitePromise)
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
// First click starts submission
await user.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Force-click bypasses disabled attribute → hits isSubmitting guard in handleSend
fireEvent.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Cleanup
resolveInvite!({ result: 'success', invitation_results: [] })
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
it('should not show error text color when isLimited is false even with many emails', async () => {
// size=0, limit=0 → isLimited=false, usedSize=emails.length
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 0, limit: 0 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
// isLimited=false → no destructive color
const counter = screen.getByText('1')
expect(counter.closest('div')).not.toHaveClass('text-text-destructive')
})
})

View File

@@ -2,12 +2,8 @@ import type { InvitationResult } from '@/models/common'
import { render, screen } from '@testing-library/react'
import InvitedModal from './index'
const mockConfigState = vi.hoisted(() => ({ isCeEdition: true }))
vi.mock('@/config', () => ({
get IS_CE_EDITION() {
return mockConfigState.isCeEdition
},
IS_CE_EDITION: true,
}))
describe('InvitedModal', () => {
@@ -17,11 +13,6 @@ describe('InvitedModal', () => {
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
]
beforeEach(() => {
vi.clearAllMocks()
mockConfigState.isCeEdition = true
})
it('should show success and failed invitation sections', async () => {
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
@@ -30,59 +21,4 @@ describe('InvitedModal', () => {
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
})
it('should hide invitation link section when there are no successes', () => {
const failedOnly: InvitationResult[] = [
{ email: 'fail@example.com', status: 'failed', message: 'Quota exceeded' },
]
render(<InvitedModal invitationResults={failedOnly} onCancel={mockOnCancel} />)
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
expect(screen.getByText(/members\.failedInvitationEmails/i)).toBeInTheDocument()
})
it('should hide failed section when there are only successes', () => {
const successOnly: InvitationResult[] = [
{ email: 'ok@example.com', status: 'success', url: 'http://invite.com/2' },
]
render(<InvitedModal invitationResults={successOnly} onCancel={mockOnCancel} />)
expect(screen.getByText(/members\.invitationLink/i)).toBeInTheDocument()
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
})
it('should hide both sections when results are empty', () => {
render(<InvitedModal invitationResults={[]} onCancel={mockOnCancel} />)
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
})
})
describe('InvitedModal (non-CE edition)', () => {
const mockOnCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockConfigState.isCeEdition = false
})
afterEach(() => {
mockConfigState.isCeEdition = true
})
it('should render invitationSentTip without CE edition content when IS_CE_EDITION is false', async () => {
const results: InvitationResult[] = [
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
]
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
// The !IS_CE_EDITION branch - should show the tip text
expect(await screen.findByText(/members\.invitationSentTip/i)).toBeInTheDocument()
// CE-only content should not be shown
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
})
})

View File

@@ -49,13 +49,13 @@ describe('Operation', () => {
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false })
})
it('should render the current role label when member has editor role', () => {
it('renders the current role label', () => {
renderOperation()
expect(screen.getByText('common.members.editor')).toBeInTheDocument()
})
it('should show dataset operator option when feature flag is enabled', async () => {
it('shows dataset operator option when the feature flag is enabled', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
@@ -66,7 +66,7 @@ describe('Operation', () => {
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
})
it('should show owner-allowed role options when operator role is admin', async () => {
it('shows owner-allowed role options for admin operators', async () => {
const user = userEvent.setup()
renderOperation({}, 'admin')
@@ -77,7 +77,7 @@ describe('Operation', () => {
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('should not show role options when operator role is unsupported', async () => {
it('does not show role options for unsupported operators', async () => {
const user = userEvent.setup()
renderOperation({}, 'normal')
@@ -88,7 +88,7 @@ describe('Operation', () => {
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
})
it('should call updateMemberRole and onOperate when selecting another role', async () => {
it('calls updateMemberRole and onOperate when selecting another role', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
@@ -102,24 +102,7 @@ describe('Operation', () => {
})
})
it('should show dataset operator option when operator is admin and feature flag is enabled', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
renderOperation({}, 'admin')
await user.click(screen.getByText('common.members.editor'))
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
})
it('should fall back to normal role label when member role is unknown', () => {
renderOperation({ role: 'unknown_role' as Member['role'] })
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('should call deleteMemberOrCancelInvitation when removing the member', async () => {
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)

View File

@@ -13,6 +13,11 @@ vi.mock('@/context/app-context')
vi.mock('@/service/common')
vi.mock('@/service/use-common')
// Mock Modal directly to avoid transition/portal issues in tests
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => isShow ? <div data-testid="mock-modal">{children}</div> : null,
}))
vi.mock('./member-selector', () => ({
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
@@ -35,13 +40,11 @@ describe('TransferOwnershipModal', () => {
data: { accounts: [] },
} as unknown as ReturnType<typeof useMembers>)
// Stub globalThis.location.reload (component calls globalThis.location.reload())
// Fix Location stubbing for reload
const mockReload = vi.fn()
vi.stubGlobal('location', {
...window.location,
reload: mockReload,
href: '',
assign: vi.fn(),
replace: vi.fn(),
} as unknown as Location)
})
@@ -102,8 +105,8 @@ describe('TransferOwnershipModal', () => {
await waitFor(() => {
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
expect(window.location.reload).toHaveBeenCalled()
}, { timeout: 10000 })
}, 15000)
})
})
it('should handle timer countdown and resend', async () => {
vi.useFakeTimers()
@@ -199,70 +202,6 @@ describe('TransferOwnershipModal', () => {
})
})
it('should handle sendOwnerEmail returning null data', async () => {
const user = userEvent.setup()
vi.mocked(sendOwnerEmail).mockResolvedValue({
data: null,
result: 'success',
} as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>)
renderModal()
await user.click(screen.getByTestId('transfer-modal-send-code'))
// Should advance to verify step even with null data
await waitFor(() => {
expect(screen.getByText(/members\.transferModal\.verifyEmail/i)).toBeInTheDocument()
})
})
it('should show fallback error prefix when sendOwnerEmail throws null', async () => {
const user = userEvent.setup()
vi.mocked(sendOwnerEmail).mockRejectedValue(null)
renderModal()
await user.click(screen.getByTestId('transfer-modal-send-code'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('Error sending verification code:'),
}))
})
})
it('should show fallback error prefix when verifyOwnerEmail throws null', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(verifyOwnerEmail).mockRejectedValue(null)
renderModal()
await goToTransferStep(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('Error verifying email:'),
}))
})
})
it('should show fallback error prefix when ownershipTransfer throws null', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(ownershipTransfer).mockRejectedValue(null)
renderModal()
await goToTransferStep(user)
await selectNewOwnerAndSubmit(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('Error ownership transfer:'),
}))
})
})
it('should close when close button is clicked', async () => {
const user = userEvent.setup()
renderModal()

View File

@@ -71,80 +71,9 @@ describe('MemberSelector', () => {
})
})
it('should filter list by email when name does not match', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'john@')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument()
})
it('should show placeholder when value does not match any account', () => {
render(<MemberSelector value="nonexistent-id" onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
})
it('should handle missing data gracefully', () => {
vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
})
it('should filter by email when account name is empty', async () => {
const user = userEvent.setup()
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [...mockAccounts, { id: '4', name: '', email: 'noname@example.com', avatar_url: '' }] },
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'noname@')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
})
it('should apply hover background class when dropdown is open', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
const trigger = screen.getByTestId('member-selector-trigger')
await user.click(trigger)
expect(trigger).toHaveClass('bg-state-base-hover-alt')
})
it('should not match account when neither name nor email contains search value', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'xyz-no-match-xyz')
expect(screen.queryByTestId('member-selector-item')).not.toBeInTheDocument()
})
it('should fall back to empty string for account with undefined email when searching', async () => {
const user = userEvent.setup()
vi.mocked(useMembers).mockReturnValue({
data: {
accounts: [
{ id: '1', name: 'John', email: undefined as unknown as string, avatar_url: '' },
],
},
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'john')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
})
})

View File

@@ -433,55 +433,6 @@ describe('hooks', () => {
expect(result.current.credentials).toBeUndefined()
})
it('should not call invalidateQueries when neither predefined nor custom is enabled', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useQuery as Mock).mockReturnValue({
data: undefined,
isPending: false,
})
// Both predefinedEnabled and customEnabled are false (no credentialId)
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
false,
undefined,
undefined,
))
act(() => {
result.current.mutate()
})
expect(invalidateQueries).not.toHaveBeenCalled()
})
it('should build URL without credentialId when not provided in predefined queryFn', async () => {
// Trigger the queryFn when credentialId is undefined but predefinedEnabled is true
; (useQuery as Mock).mockReturnValue({
data: { credentials: { api_key: 'k' } },
isPending: false,
})
const { result: _result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
true,
undefined,
undefined,
))
// Find and invoke the predefined queryFn
const queryCall = (useQuery as Mock).mock.calls.find(
call => call[0].queryKey?.[1] === 'credentials',
)
if (queryCall) {
await queryCall[0].queryFn()
expect(fetchModelProviderCredentials).toHaveBeenCalled()
}
})
})
describe('useModelList', () => {
@@ -1160,26 +1111,6 @@ describe('hooks', () => {
expect(result.current.plugins![0].plugin_id).toBe('plugin1')
})
it('should deduplicate plugins that exist in both collections and regular plugins', () => {
const duplicatePlugin = { plugin_id: 'shared-plugin', type: 'plugin' }
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [duplicatePlugin],
isLoading: false,
})
; (useMarketplacePlugins as Mock).mockReturnValue({
plugins: [{ ...duplicatePlugin }, { plugin_id: 'unique-plugin', type: 'plugin' }],
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
expect(result.current.plugins).toHaveLength(2)
expect(result.current.plugins!.filter(p => p.plugin_id === 'shared-plugin')).toHaveLength(1)
})
it('should handle loading states', () => {
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [],
@@ -1196,45 +1127,6 @@ describe('hooks', () => {
expect(result.current.isLoading).toBe(true)
})
it('should not crash when plugins is undefined', () => {
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [],
isLoading: false,
})
; (useMarketplacePlugins as Mock).mockReturnValue({
plugins: undefined,
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
expect(result.current.plugins).toBeDefined()
expect(result.current.isLoading).toBe(false)
})
it('should return search plugins (not allPlugins) when searchText is truthy', () => {
const searchPlugins = [{ plugin_id: 'search-result', type: 'plugin' }]
const collectionPlugins = [{ plugin_id: 'collection-only', type: 'plugin' }]
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: collectionPlugins,
isLoading: false,
})
; (useMarketplacePlugins as Mock).mockReturnValue({
plugins: searchPlugins,
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], 'openai'))
expect(result.current.plugins).toEqual(searchPlugins)
expect(result.current.plugins?.some(p => p.plugin_id === 'collection-only')).toBe(false)
})
})
describe('useRefreshModel', () => {
@@ -1342,35 +1234,6 @@ describe('hooks', () => {
expect(emit).not.toHaveBeenCalled()
})
it('should emit event and invalidate all supported model types when __model_type is undefined', () => {
const invalidateQueries = vi.fn()
const emit = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit },
})
const provider = createMockProvider()
const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields
const { result } = renderHook(() => useRefreshModel())
act(() => {
result.current.handleRefreshModel(provider, customFields, true)
})
expect(emit).toHaveBeenCalledWith({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: 'openai',
})
// When __model_type is undefined, all supported model types are invalidated
const modelListCalls = invalidateQueries.mock.calls.filter(
call => call[0]?.queryKey?.[0] === 'model-list',
)
expect(modelListCalls).toHaveLength(provider.supported_model_types.length)
})
it('should handle provider with single model type', () => {
const invalidateQueries = vi.fn()

View File

@@ -60,15 +60,7 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
type MockDefaultModelData = {
model: string
provider?: { provider: string }
} | null
const mockDefaultModelState: {
data: MockDefaultModelData
isLoading: boolean
} = {
const mockDefaultModelState = {
data: null,
isLoading: false,
}
@@ -204,129 +196,4 @@ describe('ModelProviderPage', () => {
])
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
})
it('should show not configured alert when all default models are absent', () => {
mockDefaultModelState.data = null
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
})
it('should not show not configured alert when default model is loading', () => {
mockDefaultModelState.data = null
mockDefaultModelState.isLoading = true
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should filter providers by label text', () => {
render(<ModelProviderPage searchText="OpenAI" />)
act(() => {
vi.advanceTimersByTime(600)
})
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
})
it('should classify system-enabled providers with matching quota as configured', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'sys-provider',
label: { en_US: 'System Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('sys-provider')).toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
})
it('should classify system-enabled provider with no matching quota as not configured', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'sys-no-quota',
label: { en_US: 'System No Quota' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [],
},
})
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('sys-no-quota')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
})
it('should preserve order of two non-fixed providers (sort returns 0)', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'alpha-provider',
label: { en_US: 'Alpha Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'beta-provider',
label: { en_US: 'Beta Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
render(<ModelProviderPage searchText="" />)
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
expect(renderedProviders).toEqual(['alpha-provider', 'beta-provider'])
})
it('should not show not configured alert when shared default model mock has data', () => {
mockDefaultModelState.data = { model: 'embed-model' }
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show not configured alert when rerankDefaultModel has data', () => {
mockDefaultModelState.data = { model: 'rerank-model', provider: { provider: 'cohere' } }
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show not configured alert when ttsDefaultModel has data', () => {
mockDefaultModelState.data = { model: 'tts-model', provider: { provider: 'openai' } }
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show not configured alert when speech2textDefaultModel has data', () => {
mockDefaultModelState.data = { model: 'whisper', provider: { provider: 'openai' } }
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
})

View File

@@ -96,97 +96,4 @@ describe('AddCredentialInLoadBalancing', () => {
expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0])
})
// renderTrigger with open=true: bg-state-base-hover style applied
it('should apply hover background when trigger is rendered with open=true', async () => {
vi.doMock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
Authorized: ({
renderTrigger,
}: {
renderTrigger: (open?: boolean) => React.ReactNode
}) => (
<div data-testid="open-trigger">{renderTrigger(true)}</div>
),
}))
// Must invalidate module cache so the component picks up the new mock
vi.resetModules()
try {
const { default: AddCredentialLB } = await import('./add-credential-in-load-balancing')
const { container } = render(
<AddCredentialLB
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
// The trigger div rendered by renderTrigger(true) should have bg-state-base-hover
// (the static class applied when open=true via cn())
const triggerDiv = container.querySelector('[data-testid="open-trigger"] > div')
expect(triggerDiv).toBeInTheDocument()
expect(triggerDiv!.className).toContain('bg-state-base-hover')
}
finally {
vi.doUnmock('@/app/components/header/account-setting/model-provider-page/model-auth')
vi.resetModules()
}
})
// customizableModel configuration method: component renders the add credential label
it('should render correctly with customizableModel configuration method', () => {
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.customizableModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
})
it('should handle undefined available_credentials gracefully using nullish coalescing', () => {
const credentialWithNoAvailable = {
available_credentials: undefined,
credentials: {},
load_balancing: { enabled: false, configs: [] },
} as unknown as typeof modelCredential
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={credentialWithNoAvailable}
onSelectCredential={vi.fn()}
/>,
)
// Component should render without error - the ?? [] fallback is used
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
})
it('should not throw when update action fires without onUpdate prop', () => {
// Arrange - no onUpdate prop
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
// Act - trigger the update without onUpdate being set (should not throw)
expect(() => {
fireEvent.click(screen.getByRole('button', { name: 'Run update' }))
}).not.toThrow()
})
})

View File

@@ -85,69 +85,4 @@ describe('CredentialItem', () => {
expect(onDelete).not.toHaveBeenCalled()
})
// All disable flags true → no action buttons rendered
it('should hide all action buttons when disableRename, disableEdit, and disableDelete are all true', () => {
// Act
render(
<CredentialItem
credential={credential}
onEdit={vi.fn()}
onDelete={vi.fn()}
disableRename
disableEdit
disableDelete
/>,
)
// Assert
expect(screen.queryByTestId('edit-icon')).not.toBeInTheDocument()
expect(screen.queryByTestId('delete-icon')).not.toBeInTheDocument()
})
// disabled=true guards: clicks on the item row and on delete should both be no-ops
it('should not call onItemClick when disabled=true and item is clicked', () => {
const onItemClick = vi.fn()
render(<CredentialItem credential={credential} disabled onItemClick={onItemClick} />)
fireEvent.click(screen.getByText('Test API Key'))
expect(onItemClick).not.toHaveBeenCalled()
})
it('should not call onDelete when disabled=true and delete button is clicked', () => {
const onDelete = vi.fn()
render(<CredentialItem credential={credential} disabled onDelete={onDelete} />)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
expect(onDelete).not.toHaveBeenCalled()
})
// showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match
it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => {
render(
<CredentialItem
credential={credential}
showSelectedIcon
selectedCredentialId="cred-1"
/>,
)
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
})
it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => {
render(
<CredentialItem
credential={credential}
showSelectedIcon
selectedCredentialId="other-cred"
/>,
)
expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
})
})

View File

@@ -24,6 +24,36 @@ vi.mock('../hooks', () => ({
}),
}))
let mockPortalOpen = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
mockPortalOpen = open
return <div data-testid="portal" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
if (!mockPortalOpen)
return null
return <div data-testid="portal-content">{children}</div>
},
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>
)
},
}))
vi.mock('./authorized-item', () => ({
default: ({ credentials, model, onEdit, onDelete, onItemClick }: {
credentials: Credential[]
@@ -75,127 +105,382 @@ describe('Authorized', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
mockDeleteCredentialId = null
mockDoingAction = false
})
it('should render trigger and open popup when trigger is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
describe('Rendering', () => {
it('should render trigger button', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /addApiKey/i })).toBeInTheDocument()
})
expect(screen.getByText(/Trigger/)).toBeInTheDocument()
})
it('should call handleOpenModal when triggerOnlyOpenModal is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
triggerOnlyOpenModal
/>,
)
it('should render portal content when open', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(mockHandleOpenModal).toHaveBeenCalled()
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
})
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
})
it('should call onItemClick when credential is selected', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
/>,
)
it('should not render portal content when closed', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should render Add API Key button when not model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
it('should call handleActiveCredential when onItemClick is not provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.getByText(/addApiKey/)).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
it('should render Add Model Credential button when is model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
isOpen
/>,
)
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
expect(screen.getByText(/addModelCredential/)).toBeInTheDocument()
})
it('should call handleOpenModal with fixed model fields when adding model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
currentCustomConfigurationModelFixedFields={{
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}}
/>,
)
it('should not render add action when hideAddAction is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
hideAddAction
isOpen
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(screen.getByText(/addModelCredential/))
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
})
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
it('should render popup title when provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
popupTitle="Select Credential"
isOpen
/>,
)
expect(screen.getByText('Select Credential')).toBeInTheDocument()
})
})
it('should not render add action when hideAddAction is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
hideAddAction
/>,
)
describe('User Interactions', () => {
it('should call onOpenChange when trigger is clicked in controlled mode', () => {
const onOpenChange = vi.fn()
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(screen.queryByRole('button', { name: /addApiKey/i })).not.toBeInTheDocument()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen={false}
onOpenChange={onOpenChange}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(onOpenChange).toHaveBeenCalledWith(true)
})
it('should toggle portal on trigger click', () => {
const { rerender } = render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
rerender(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should open modal when triggerOnlyOpenModal is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
triggerOnlyOpenModal
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModal).toHaveBeenCalled()
})
it('should call handleOpenModal when Add API Key is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getByText(/addApiKey/))
expect(mockHandleOpenModal).toHaveBeenCalled()
})
it('should call handleOpenModal with credential and model when edit is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Edit')[0])
expect(mockHandleOpenModal).toHaveBeenCalledWith(
mockCredentials[0],
mockItems[0].model,
)
})
it('should pass current model fields when adding model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
currentCustomConfigurationModelFixedFields={{
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}}
isOpen
/>,
)
fireEvent.click(screen.getByText(/addModelCredential/))
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
it('should call onItemClick when credential is selected', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should call handleActiveCredential when onItemClick is not provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should not call onItemClick when disableItemClick is true', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
disableItemClick
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(onItemClick).not.toHaveBeenCalled()
})
})
it('should show confirm dialog and call confirm handler when delete is confirmed', () => {
mockDeleteCredentialId = 'cred-1'
describe('Delete Confirmation', () => {
it('should show confirm dialog when deleteCredentialId is set', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /common.operation.confirm/i }))
expect(mockHandleConfirmDelete).toHaveBeenCalled()
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
it('should not show confirm dialog when deleteCredentialId is null', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
it('should call closeConfirmDelete when cancel is clicked', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByText('Cancel'))
expect(mockCloseConfirmDelete).toHaveBeenCalled()
})
it('should call handleConfirmDelete when confirm is clicked', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByText('Confirm'))
expect(mockHandleConfirmDelete).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty items array', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={[]}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
})
it('should not render add action when provider does not allow custom token', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<Authorized
provider={restrictedProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
})
})
})

View File

@@ -1,6 +1,5 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfigProvider from './config-provider'
const mockUseCredentialStatus = vi.fn()
@@ -55,8 +54,7 @@ describe('ConfigProvider', () => {
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should show setup label and unavailable tooltip when custom credentials are not allowed and no credential exists', async () => {
const user = userEvent.setup()
it('should still render setup label when custom credentials are not allowed', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: false,
@@ -67,50 +65,6 @@ describe('ConfigProvider', () => {
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
await user.hover(screen.getByText(/operation.setup/i))
expect(await screen.findByText(/auth\.credentialUnavailable/i)).toBeInTheDocument()
})
it('should show config label when hasCredential but not authorized', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: true,
authorized: false,
current_credential_id: 'cred-1',
current_credential_name: 'Key 1',
available_credentials: [],
})
render(<ConfigProvider provider={baseProvider} />)
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should show config label when custom credentials are not allowed but credential exists', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: true,
authorized: true,
current_credential_id: 'cred-1',
current_credential_name: 'Key 1',
available_credentials: [],
})
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should handle nullish credential values with fallbacks', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: false,
current_credential_id: null,
current_credential_name: null,
available_credentials: null,
})
render(<ConfigProvider provider={baseProvider} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
})
})

View File

@@ -1,12 +1,12 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { fireEvent, render, screen } from '@testing-library/react'
import CredentialSelector from './credential-selector'
// Mock components
vi.mock('./authorized/credential-item', () => ({
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick?: (c: unknown) => void }) => (
<button type="button" onClick={() => onItemClick?.(credential)}>
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick: (c: unknown) => void }) => (
<div data-testid="credential-item" onClick={() => onItemClick(credential)}>
{credential.credential_name}
</button>
</div>
),
}))
@@ -19,6 +19,22 @@ vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
}))
// Mock portal components
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => {
// We should only render children if open or if we want to test they are hidden
// The real component might handle this with CSS or conditional rendering.
// Let's use conditional rendering in the mock to avoid "multiple elements" errors.
return <div data-testid="portal-content">{children}</div>
},
}))
describe('CredentialSelector', () => {
const mockCredentials = [
{ credential_id: 'cred-1', credential_name: 'Key 1' },
@@ -30,7 +46,7 @@ describe('CredentialSelector', () => {
vi.clearAllMocks()
})
it('should render selected credential name when selectedCredential is provided', () => {
it('should render selected credential name', () => {
render(
<CredentialSelector
selectedCredential={mockCredentials[0]}
@@ -39,11 +55,12 @@ describe('CredentialSelector', () => {
/>,
)
expect(screen.getByText('Key 1')).toBeInTheDocument()
// Use getAllByText and take the first one (the one in the trigger)
expect(screen.getAllByText('Key 1')[0]).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toBeInTheDocument()
})
it('should render placeholder when selectedCredential is missing', () => {
it('should render placeholder when no credential selected', () => {
render(
<CredentialSelector
credentials={mockCredentials}
@@ -54,8 +71,7 @@ describe('CredentialSelector', () => {
expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument()
})
it('should call onSelect when a credential item is clicked', async () => {
const user = userEvent.setup()
it('should open portal on click', () => {
render(
<CredentialSelector
credentials={mockCredentials}
@@ -63,14 +79,26 @@ describe('CredentialSelector', () => {
/>,
)
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
await user.click(screen.getByRole('button', { name: 'Key 2' }))
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getAllByTestId('credential-item')).toHaveLength(2)
})
it('should call onSelect when a credential is clicked', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('Key 2'))
expect(mockOnSelect).toHaveBeenCalledWith(mockCredentials[1])
})
it('should call onSelect with add-new payload when add action is clicked', async () => {
const user = userEvent.setup()
it('should call onSelect with add new credential data when clicking add button', () => {
render(
<CredentialSelector
credentials={mockCredentials}
@@ -78,8 +106,8 @@ describe('CredentialSelector', () => {
/>,
)
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
await user.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
credential_id: '__add_new_credential',
@@ -87,8 +115,7 @@ describe('CredentialSelector', () => {
}))
})
it('should not open options when disabled is true', async () => {
const user = userEvent.setup()
it('should not open portal when disabled', () => {
render(
<CredentialSelector
disabled
@@ -97,7 +124,7 @@ describe('CredentialSelector', () => {
/>,
)
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
expect(screen.queryByRole('button', { name: 'Key 1' })).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
})
})

View File

@@ -1,11 +1,9 @@
import type { ReactNode } from 'react'
import type {
Credential,
CustomModel,
ModelProvider,
} from '../../declarations'
import { act, renderHook } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations'
import { useAuth } from './use-auth'
@@ -22,13 +20,9 @@ const mockAddModelCredential = vi.fn()
const mockEditProviderCredential = vi.fn()
const mockEditModelCredential = vi.fn()
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({ notify: mockNotify }),
}
})
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelModalHandler: () => mockOpenModelModal,
@@ -72,12 +66,6 @@ describe('useAuth', () => {
model_type: ModelTypeEnum.textGeneration,
}
const createWrapper = ({ children }: { children: ReactNode }) => (
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{children}
</ToastContext.Provider>
)
beforeEach(() => {
vi.clearAllMocks()
mockDeleteModelService.mockResolvedValue({ result: 'success' })
@@ -92,7 +80,7 @@ describe('useAuth', () => {
})
it('should open and close delete confirmation state', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
act(() => {
result.current.openConfirmDelete(credential, model)
@@ -112,7 +100,7 @@ describe('useAuth', () => {
})
it('should activate credential, notify success, and refresh models', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel))
await act(async () => {
await result.current.handleActiveCredential(credential, model)
@@ -132,7 +120,7 @@ describe('useAuth', () => {
})
it('should close delete dialog without calling services when nothing is pending', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
await act(async () => {
await result.current.handleConfirmDelete()
@@ -149,7 +137,7 @@ describe('useAuth', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, {
isModelCredential: false,
onRemove,
}), { wrapper: createWrapper })
}))
act(() => {
result.current.openConfirmDelete(credential, model)
@@ -173,7 +161,7 @@ describe('useAuth', () => {
const onRemove = vi.fn()
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, {
onRemove,
}), { wrapper: createWrapper })
}))
act(() => {
result.current.openConfirmDelete(undefined, model)
@@ -191,7 +179,7 @@ describe('useAuth', () => {
})
it('should add or edit credentials and refresh on successful save', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
await act(async () => {
await result.current.handleSaveCredential({ api_key: 'new-key' })
@@ -212,7 +200,7 @@ describe('useAuth', () => {
const deferred = createDeferred<{ result: string }>()
mockAddProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
let first!: Promise<void>
let second!: Promise<void>
@@ -238,7 +226,7 @@ describe('useAuth', () => {
isModelCredential: true,
onUpdate,
mode: ModelModalModeEnum.configModelCredential,
}), { wrapper: createWrapper })
}))
act(() => {
result.current.handleOpenModal(credential, model)
@@ -256,90 +244,4 @@ describe('useAuth', () => {
}),
)
})
it('should not notify or refresh when handleSaveCredential returns non-success result', async () => {
mockAddProviderCredential.mockResolvedValue({ result: 'error' })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
await act(async () => {
await result.current.handleSaveCredential({ api_key: 'some-key' })
})
expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'some-key' })
expect(mockNotify).not.toHaveBeenCalled()
expect(mockHandleRefreshModel).not.toHaveBeenCalled()
})
it('should pass undefined model and model_type when handleActiveCredential is called without a model parameter', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
await act(async () => {
await result.current.handleActiveCredential(credential)
})
expect(mockActiveProviderCredential).toHaveBeenCalledWith({
credential_id: 'cred-1',
model: undefined,
model_type: undefined,
})
})
// openConfirmDelete with credential only (no model): deleteCredentialId set, deleteModel stays null
it('should only set deleteCredentialId when openConfirmDelete is called without a model', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
act(() => {
result.current.openConfirmDelete(credential, undefined)
})
expect(result.current.deleteCredentialId).toBe('cred-1')
expect(result.current.deleteModel).toBeNull()
expect(result.current.pendingOperationCredentialId.current).toBe('cred-1')
expect(result.current.pendingOperationModel.current).toBeNull()
})
// doingActionRef guard: second handleConfirmDelete call while first is in progress is a no-op
it('should ignore a second handleConfirmDelete call while the first is still in progress', async () => {
const deferred = createDeferred<{ result: string }>()
mockDeleteProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
act(() => {
result.current.openConfirmDelete(credential, model)
})
let first!: Promise<void>
let second!: Promise<void>
await act(async () => {
first = result.current.handleConfirmDelete()
second = result.current.handleConfirmDelete()
deferred.resolve({ result: 'success' })
await Promise.all([first, second])
})
expect(mockDeleteProviderCredential).toHaveBeenCalledTimes(1)
})
// doingActionRef guard: second handleActiveCredential call while first is in progress is a no-op
it('should ignore a second handleActiveCredential call while the first is still in progress', async () => {
const deferred = createDeferred<{ result: string }>()
mockActiveProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
let first!: Promise<void>
let second!: Promise<void>
await act(async () => {
first = result.current.handleActiveCredential(credential)
second = result.current.handleActiveCredential(credential)
deferred.resolve({ result: 'success' })
await Promise.all([first, second])
})
expect(mockActiveProviderCredential).toHaveBeenCalledTimes(1)
})
})

View File

@@ -13,13 +13,11 @@ vi.mock('./hooks', () => ({
// Mock Authorized
vi.mock('./authorized', () => ({
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: Array<{ selectedCredential?: unknown }>, popupTitle: string }) => (
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: { length: number }, popupTitle: string }) => (
<div data-testid="authorized-mock">
<div data-testid="trigger-closed">{renderTrigger()}</div>
<div data-testid="trigger-open">{renderTrigger(true)}</div>
<div data-testid="trigger-container">{renderTrigger()}</div>
<div data-testid="popup-title">{popupTitle}</div>
<div data-testid="items-count">{items.length}</div>
<div data-testid="items-selected">{items.map((it, i) => <span key={i} data-testid={`selected-${i}`}>{it.selectedCredential ? 'has-cred' : 'no-cred'}</span>)}</div>
</div>
),
}))
@@ -57,41 +55,8 @@ describe('ManageCustomModelCredentials', () => {
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
expect(screen.getAllByText(/modelProvider.auth.manageCredentials/).length).toBeGreaterThan(0)
expect(screen.getByText(/modelProvider.auth.manageCredentials/)).toBeInTheDocument()
expect(screen.getByTestId('items-count')).toHaveTextContent('2')
expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials')
})
it('should render trigger in both open and closed states', () => {
const mockModels = [
{
model: 'gpt-4',
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
]
mockUseCustomModels.mockReturnValue(mockModels)
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('trigger-closed')).toBeInTheDocument()
expect(screen.getByTestId('trigger-open')).toBeInTheDocument()
})
it('should pass undefined selectedCredential when model has no current_credential_id', () => {
const mockModels = [
{
model: 'gpt-3.5',
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: '',
current_credential_name: '',
},
]
mockUseCustomModels.mockReturnValue(mockModels)
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('selected-0')).toHaveTextContent('no-cred')
})
})

View File

@@ -18,6 +18,15 @@ vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip-mock">
{children}
<div>{popupContent}</div>
</div>
),
}))
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
}))
@@ -116,131 +125,6 @@ describe('SwitchCredentialInLoadBalancing', () => {
/>,
)
fireEvent.mouseEnter(screen.getByText(/auth.credentialUnavailableInButton/))
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
})
// Empty credentials with allowed custom: no tooltip but still shows unavailable text
it('should show unavailable status without tooltip when custom credentials are allowed', () => {
// Act
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[]}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// Assert
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
})
// not_allowed_to_use=true: indicator is red and destructive button text is shown
it('should show red indicator and unavailable button text when credential has not_allowed_to_use=true', () => {
const unavailableCredential = { credential_id: 'cred-1', credential_name: 'Key 1', not_allowed_to_use: true }
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[unavailableCredential]}
customModelCredential={unavailableCredential}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
})
// from_enterprise=true on the selected credential: Enterprise badge appears in the trigger
it('should show Enterprise badge when selected credential has from_enterprise=true', () => {
const enterpriseCredential = { credential_id: 'cred-1', credential_name: 'Enterprise Key', from_enterprise: true }
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[enterpriseCredential]}
customModelCredential={enterpriseCredential}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText('Enterprise')).toBeInTheDocument()
})
// non-empty credentials with allow_custom_token=false: no tooltip (tooltip only for empty+notAllowCustom)
it('should not show unavailable tooltip when credentials are non-empty and allow_custom_token=false', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<SwitchCredentialInLoadBalancing
provider={restrictedProvider}
model={mockModel}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
fireEvent.mouseEnter(screen.getByText('Key 1'))
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
it('should pass undefined currentCustomConfigurationModelFixedFields when model is undefined', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
// @ts-expect-error testing runtime handling when model is omitted
model={undefined}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// Component still renders (Authorized receives undefined currentCustomConfigurationModelFixedFields)
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
it('should treat undefined credentials as empty list', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={undefined}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// credentials is undefined → empty=true → unavailable text shown
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument()
})
it('should render nothing for credential_name when it is empty string', () => {
const credWithEmptyName = { credential_id: 'cred-1', credential_name: '' }
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[credWithEmptyName]}
customModelCredential={credWithEmptyName}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// indicator-green shown (not authRemoved, not unavailable, not empty)
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
// credential_name is empty so nothing printed for name
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
})

View File

@@ -24,6 +24,10 @@ vi.mock('../hooks', () => ({
useLanguage: () => mockLanguage,
}))
vi.mock('@/app/components/base/icons/src/public/llm', () => ({
OpenaiYellow: () => <svg data-testid="openai-yellow-icon" />,
}))
const createI18nText = (value: string): I18nText => ({
en_US: value,
zh_Hans: value,
@@ -88,10 +92,10 @@ describe('ModelIcon', () => {
icon_small: createI18nText('openai.png'),
})
const { container } = render(<ModelIcon provider={provider} modelName="o1" />)
render(<ModelIcon provider={provider} modelName="o1" />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument()
})
// Edge case
@@ -101,25 +105,4 @@ describe('ModelIcon', () => {
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.firstChild).not.toBeNull()
})
it('should render OpenAI Yellow icon for langgenius/openai/openai provider with model starting with o', () => {
const provider = createModel({
provider: 'langgenius/openai/openai',
icon_small: createI18nText('openai.png'),
})
const { container } = render(<ModelIcon provider={provider} modelName="o3" />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should apply opacity-50 when isDeprecated is true', () => {
const provider = createModel()
const { container } = render(<ModelIcon provider={provider} isDeprecated={true} />)
const wrapper = container.querySelector('.opacity-50')
expect(wrapper).toBeInTheDocument()
})
})

View File

@@ -161,7 +161,7 @@ function Form<
const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name'))
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@@ -204,14 +204,13 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
)}
{tooltipContent}
</div>
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
<div className={cn('grid gap-3', `grid-cols-${options?.length}`)}>
{options.filter((option) => {
if (option.show_on.length)
@@ -230,7 +229,7 @@ function Form<
>
<RadioE isChecked={value[variable] === option.value} />
<div className="text-text-secondary system-sm-regular">{option.label[language] || option.label.en_US}</div>
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
</div>
))}
</div>
@@ -255,7 +254,7 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
@@ -296,9 +295,9 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className="flex items-center justify-between py-2 text-text-secondary system-sm-semibold">
<div className="system-sm-semibold flex items-center justify-between py-2 text-text-secondary">
<div className="flex items-center space-x-2">
<span className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>{label[language] || label.en_US}</span>
<span className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>{label[language] || label.en_US}</span>
{required && (
<span className="ml-1 text-red-500">*</span>
)}
@@ -327,7 +326,7 @@ function Form<
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@@ -359,7 +358,7 @@ function Form<
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@@ -423,7 +422,7 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@@ -452,7 +451,7 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>

View File

@@ -93,88 +93,4 @@ describe('Input', () => {
expect(onChange).not.toHaveBeenCalledWith('2')
expect(onChange).not.toHaveBeenCalledWith('6')
})
it('should not clamp when min and max are not provided', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Free"
onChange={onChange}
/>,
)
const input = screen.getByPlaceholderText('Free')
fireEvent.change(input, { target: { value: '999' } })
fireEvent.blur(input)
// onChange only called from change event, not from blur clamping
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('999')
})
it('should show check circle icon when validated is true', () => {
const { container } = render(
<Input
placeholder="Key"
onChange={vi.fn()}
validated
/>,
)
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).toBeInTheDocument()
})
it('should not show check circle icon when validated is false', () => {
const { container } = render(
<Input
placeholder="Key"
onChange={vi.fn()}
validated={false}
/>,
)
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).not.toBeInTheDocument()
})
it('should apply disabled attribute when disabled prop is true', () => {
render(
<Input
placeholder="Disabled"
onChange={vi.fn()}
disabled
/>,
)
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled()
})
it('should call onFocus when input receives focus', () => {
const onFocus = vi.fn()
render(
<Input
placeholder="Focus"
onChange={vi.fn()}
onFocus={onFocus}
/>,
)
fireEvent.focus(screen.getByPlaceholderText('Focus'))
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('should render with custom className', () => {
render(
<Input
placeholder="Styled"
onChange={vi.fn()}
className="custom-class"
/>,
)
expect(screen.getByPlaceholderText('Styled')).toHaveClass('custom-class')
})
})

View File

@@ -1,7 +1,5 @@
import type { ComponentProps } from 'react'
import type { Credential, CredentialFormSchema, CustomModel, ModelProvider } from '../declarations'
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@@ -45,6 +43,15 @@ const mockHandlers = vi.hoisted(() => ({
handleActiveCredential: vi.fn(),
}))
type FormResponse = {
isCheckValidated: boolean
values: Record<string, unknown>
}
const mockFormState = vi.hoisted(() => ({
responses: [] as FormResponse[],
setFieldValue: vi.fn(),
}))
vi.mock('../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: mockState.isLoading,
@@ -79,6 +86,36 @@ vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
const React = await import('react')
const AuthForm = React.forwardRef(({
onChange,
}: {
onChange?: (field: string, value: string) => void
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
}))
return (
<div>
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
</div>
)
})
return { default: AuthForm }
})
vi.mock('../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
<div>
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
</div>
),
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
@@ -121,7 +158,7 @@ const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
...overrides,
})
const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) => {
const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>>) => {
const provider = createProvider()
const props = {
provider,
@@ -131,50 +168,13 @@ const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) =>
onRemove: vi.fn(),
...overrides,
}
render(<ModelModal {...props} />)
return props
const view = render(<ModelModal {...props} />)
return {
...props,
unmount: view.unmount,
}
}
const mockFormRef1 = {
getFormValues: vi.fn(),
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
}
const mockFormRef2 = {
getFormValues: vi.fn(),
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
}
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
default: React.forwardRef((props: { formSchemas: Record<string, unknown>[], onChange?: (f: string, v: string) => void }, ref: React.ForwardedRef<unknown>) => {
React.useImperativeHandle(ref, () => {
// Return the mock depending on schemas passed (hacky but works for refs)
if (props.formSchemas.length > 0 && props.formSchemas[0].name === '__model_name')
return mockFormRef1
return mockFormRef2
})
return (
<div data-testid="auth-form" onClick={() => props.onChange?.('test-field', 'val')}>
AuthForm Mock (
{props.formSchemas.length}
{' '}
fields)
</div>
)
}),
}))
vi.mock('../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (val: unknown) => void }) => (
<button onClick={() => onSelect({ addNewCredential: true })} data-testid="credential-selector">
Select Credential
</button>
),
useAuth: vi.fn(),
useCredentialData: vi.fn(),
useModelFormSchemas: vi.fn(),
}))
describe('ModelModal', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -187,131 +187,167 @@ describe('ModelModal', () => {
mockState.formValues = {}
mockState.modelNameAndTypeFormSchemas = []
mockState.modelNameAndTypeFormValues = {}
// reset form refs
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __model_name: 'test', __model_type: ModelTypeEnum.textGeneration } })
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'test_auth', api_key: 'sk-test' } })
mockFormState.responses = []
})
it('should render title and loading state for predefined credential modal', () => {
it('should show title, description, and loading state for predefined models', () => {
mockState.isLoading = true
renderModal()
const predefined = renderModal()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
})
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
it('should render model credential title when mode is configModelCredential', () => {
renderModal({
mode: ModelModalModeEnum.configModelCredential,
model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration },
})
predefined.unmount()
const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel })
expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument()
customizable.unmount()
mockState.credentialData = { credentials: {}, available_credentials: [] }
renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } })
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
})
it('should render edit credential title when credential exists', () => {
renderModal({
mode: ModelModalModeEnum.configModelCredential,
credential: { credential_id: '1' } as unknown as Credential,
})
expect(screen.getByText('common.modelProvider.auth.editModelCredential')).toBeInTheDocument()
it('should reveal the credential label when adding a new credential', () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList })
expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Add New'))
expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument()
})
it('should change title to Add Model when mode is configCustomModel', () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
renderModal({ mode: ModelModalModeEnum.configCustomModel })
expect(screen.getByText('common.modelProvider.auth.addModel')).toBeInTheDocument()
it('should call onCancel when the cancel button is clicked', () => {
const { onCancel } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should validate and fail save if form is invalid in configCustomModel mode', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
})
it('should call onCancel when the escape key is pressed', () => {
const { onCancel } = renderModal()
it('should validate and save new credential and model in configCustomModel mode', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
const props = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'sk-test' },
name: 'test_auth',
model: 'test',
model_type: ModelTypeEnum.textGeneration,
})
expect(props.onSave).toHaveBeenCalled()
})
})
it('should save credential only in standard configProviderCredential mode', async () => {
const { onSave } = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'sk-test' },
name: 'test_auth',
})
expect(onSave).toHaveBeenCalled()
})
})
it('should save active credential and cancel when picking existing credential in addCustomModelToModelList mode', async () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm1', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
// By default selected is undefined so button clicks form
// Let's not click credential selector, so it evaluates without it. If selectedCredential is undefined, form validation is checked.
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
})
it('should save active credential when picking existing credential in addCustomModelToModelList mode', async () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm2', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
// Select existing credential (addNewCredential: true simulates new but we can simulate false if we just hack the mocked state in the component, but it's internal.
// The credential selector sets selectedCredential.
fireEvent.click(screen.getByTestId('credential-selector')) // Sets addNewCredential = true internally, so it proceeds to form save
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'auth', api: 'key' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api: 'key' },
name: 'auth',
model: 'm2',
model_type: ModelTypeEnum.textGeneration,
})
})
})
it('should open and confirm deletion of credential', () => {
mockState.credentialData = { credentials: { api_key: '123' }, available_credentials: [] }
mockState.formValues = { api_key: '123' } // To trigger isEditMode = true
const credential = { credential_id: 'c1' } as unknown as Credential
renderModal({ credential })
// Open Delete Confirm
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith(credential, undefined)
// Simulate the dialog appearing and confirming
mockState.deleteCredentialId = 'c1'
renderModal({ credential }) // Re-render logic mock
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.confirm' })[0])
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalled()
})
it('should bind escape key to cancel', () => {
const props = renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
expect(props.onCancel).toHaveBeenCalled()
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should confirm deletion when a delete dialog is shown', () => {
mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] }
mockState.deleteCredentialId = 'delete-id'
const credential: Credential = { credential_id: 'cred-1' }
const { onCancel } = renderModal({ credential })
expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should handle save flows for different modal modes', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema]
mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema]
mockFormState.responses = [
{ isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } },
{ isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } },
]
const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getAllByText('Model Name Change')[0])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model')
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'secret' },
name: 'Auth Name',
model: 'custom-model',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' })
configCustomModel.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }]
const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }
const configModelCredential = renderModal({
mode: ModelModalModeEnum.configModelCredential,
model,
credential: { credential_id: 'cred-123' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: 'cred-123',
credentials: { api_key: 'abc' },
name: 'Model Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' })
configModelCredential.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }]
const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'provider-key' },
name: 'Provider Auth',
})
})
configProviderCredential.unmount()
const addToModelList = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Choose Existing'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model)
expect(addToModelList.onCancel).toHaveBeenCalled()
addToModelList.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }]
const addToModelListWithNew = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Add New'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'new-key' },
name: 'New Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
addToModelListWithNew.unmount()
mockFormState.responses = [{ isCheckValidated: false, values: {} }]
const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4)
})
invalidSave.unmount()
mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] }
mockState.formValues = { api_key: 'value' }
const removable = renderModal({ credential: { credential_id: 'remove-1' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined)
removable.unmount()
})
})

View File

@@ -1,8 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ModelParameterModal from './index'
let isAPIKeySet = true
let parameterRules: Array<Record<string, unknown>> | undefined = [
let parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
@@ -61,17 +62,42 @@ vi.mock('../hooks', () => ({
}),
}))
// Mock PortalToFollowElem components to control visibility and simplify testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
return {
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => {
return (
<div>
<div data-testid="portal-wrapper">
{children}
</div>
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => (
<div data-testid="portal-content" className={className}>
{children}
</div>
),
}
})
vi.mock('./parameter-item', () => ({
default: ({ parameterRule, onChange, onSwitch }: {
parameterRule: { name: string, label: { en_US: string } }
onChange: (v: number) => void
onSwitch: (checked: boolean, val: unknown) => void
}) => (
default: ({ parameterRule, value, onChange, onSwitch }: { parameterRule: { name: string, label: { en_US: string } }, value: string | number, onChange: (v: number) => void, onSwitch: (checked: boolean, val: unknown) => void }) => (
<div data-testid={`param-${parameterRule.name}`}>
{parameterRule.label.en_US}
<button onClick={() => onChange(0.9)}>Change</button>
<button onClick={() => onSwitch(false, undefined)}>Remove</button>
<button onClick={() => onSwitch(true, 'assigned')}>Add</button>
<input
aria-label={parameterRule.name}
value={value || ''}
onChange={e => onChange(Number(e.target.value))}
/>
<button onClick={() => onSwitch?.(false, undefined)}>Remove</button>
<button onClick={() => onSwitch?.(true, 'assigned')}>Add</button>
</div>
),
}))
@@ -79,6 +105,7 @@ vi.mock('./parameter-item', () => ({
vi.mock('../model-selector', () => ({
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector">
Model Selector
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
</div>
),
@@ -94,11 +121,16 @@ vi.mock('./trigger', () => ({
default: () => <button>Open Settings</button>,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(' '),
}))
// Mock config
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
PROVIDER_WITH_PRESET_TONE: ['openai'],
PROVIDER_WITH_PRESET_TONE: ['openai'], // ensure presets mock renders
}
})
@@ -156,19 +188,21 @@ describe('ModelParameterModal', () => {
]
})
it('should render trigger and open modal content when trigger is clicked', () => {
it('should render trigger and content', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByText('Open Settings')).toBeInTheDocument()
expect(screen.getByText('Temperature')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
})
it('should call onCompletionParamsChange when parameter changes and switch actions happen', () => {
it('should update params when changed and handle switch add/remove', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText('Change'))
const input = screen.getByLabelText('temperature')
fireEvent.change(input, { target: { value: '0.9' } })
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 0.9,
@@ -184,18 +218,51 @@ describe('ModelParameterModal', () => {
})
})
it('should call onCompletionParamsChange when preset is selected', () => {
it('should handle preset selection', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText('Preset 1'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
})
it('should call setModel when model selector picks another model', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText('Select GPT-4.1'))
it('should handle debug mode toggle', () => {
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
const toggle = screen.getByText(/debugAsMultipleModel/i)
fireEvent.click(toggle)
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
rerender(<ModelParameterModal {...defaultProps} debugWithMultipleModel />)
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
})
it('should handle custom renderTrigger', () => {
const renderTrigger = vi.fn().mockReturnValue(<div>Custom Trigger</div>)
render(<ModelParameterModal {...defaultProps} renderTrigger={renderTrigger} readonly />)
expect(screen.getByText('Custom Trigger')).toBeInTheDocument()
expect(renderTrigger).toHaveBeenCalled()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(renderTrigger).toHaveBeenCalledTimes(1)
})
it('should handle model selection and advanced mode parameters', () => {
parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
rerender(<ModelParameterModal {...defaultProps} isAdvancedMode />)
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select GPT-4.1'))
expect(defaultProps.setModel).toHaveBeenCalledWith({
modelId: 'gpt-4.1',
provider: 'openai',
@@ -203,32 +270,4 @@ describe('ModelParameterModal', () => {
features: ['vision', 'tool-call'],
})
})
it('should toggle debug mode when debug footer is clicked', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText(/debugAsMultipleModel/i))
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
})
it('should render loading state when parameter rules are loading', () => {
isRulesLoading = true
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should not open content when readonly is true', () => {
render(<ModelParameterModal {...defaultProps} readonly />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
it('should render no parameter items when rules are undefined', () => {
parameterRules = undefined
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})

View File

@@ -1,182 +1,238 @@
import type { ModelParameterRule } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ParameterItem from './parameter-item'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/radio', () => {
const Radio = ({ children, value }: { children: React.ReactNode, value: boolean }) => <button data-testid={`radio-${value}`}>{children}</button>
Radio.Group = ({ children, onChange }: { children: React.ReactNode, onChange: (value: boolean) => void }) => (
<div>
{children}
<button onClick={() => onChange(true)}>Select True</button>
<button onClick={() => onChange(false)}>Select False</button>
</div>
)
return { default: Radio }
})
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: ({ onSelect, items }: { onSelect: (item: { value: string }) => void, items: { value: string, name: string }[] }) => (
<select onChange={e => onSelect({ value: e.target.value })}>
{items.map(item => (
<option key={item.value} value={item.value}>{item.name}</option>
))}
</select>
),
}))
vi.mock('@/app/components/base/slider', () => ({
default: ({ onChange }: { onChange: (v: number) => void }) => (
<button onClick={() => onChange(2)} data-testid="slider-btn">Slide 2</button>
default: ({ value, onChange }: { value: number, onChange: (val: number) => void }) => (
<input type="range" value={value} onChange={e => onChange(Number(e.target.value))} />
),
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({ onChange, value }: { onChange: (val: boolean) => void, value: boolean }) => (
<button onClick={() => onChange(!value)}>Switch</button>
),
}))
vi.mock('@/app/components/base/tag-input', () => ({
default: ({ onChange }: { onChange: (v: string[]) => void }) => (
<button onClick={() => onChange(['tag1', 'tag2'])} data-testid="tag-input">Tag</button>
default: ({ onChange }: { onChange: (val: string[]) => void }) => (
<input onChange={e => onChange(e.target.value.split(','))} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
describe('ParameterItem', () => {
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
name: 'temp',
label: { en_US: 'Temperature', zh_Hans: 'Temperature' },
type: 'float',
min: 0,
max: 1,
help: { en_US: 'Help text', zh_Hans: 'Help text' },
required: false,
...overrides,
})
const createProps = (overrides: {
parameterRule?: ModelParameterRule
value?: number | string | boolean | string[]
} = {}) => {
const onChange = vi.fn()
const onSwitch = vi.fn()
return {
parameterRule: createRule(),
value: 0.7,
onChange,
onSwitch,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
// Float tests
it('should render float controls and clamp numeric input to max', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} value={0.7} onChange={onChange} />)
it('should render float input with slider', () => {
const props = createProps()
const { rerender } = render(<ParameterItem {...props} />)
expect(screen.getByText('Temperature')).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '0.8' } })
expect(props.onChange).toHaveBeenCalledWith(0.8)
fireEvent.change(input, { target: { value: '1.4' } })
expect(onChange).toHaveBeenCalledWith(1)
expect(screen.getByTestId('slider-btn')).toBeInTheDocument()
})
expect(props.onChange).toHaveBeenCalledWith(1)
it('should clamp float numeric input to min', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0.1, max: 1 })} value={0.7} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '0.05' } })
expect(onChange).toHaveBeenCalledWith(0.1)
})
fireEvent.change(input, { target: { value: '-0.2' } })
expect(props.onChange).toHaveBeenCalledWith(0)
// Int tests
it('should render int controls and clamp numeric input', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 10 })} value={5} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '15' } })
expect(onChange).toHaveBeenCalledWith(10)
fireEvent.change(input, { target: { value: '-5' } })
expect(onChange).toHaveBeenCalledWith(0)
})
const slider = screen.getByRole('slider')
fireEvent.change(slider, { target: { value: '2' } })
expect(props.onChange).toHaveBeenCalledWith(1)
it('should adjust step based on max for int type', () => {
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 50 })} value={5} />)
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1')
fireEvent.change(slider, { target: { value: '-1' } })
expect(props.onChange).toHaveBeenCalledWith(0)
rerender(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 500 })} value={50} />)
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '10')
fireEvent.change(slider, { target: { value: '0.4' } })
expect(props.onChange).toHaveBeenCalledWith(0.4)
rerender(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 2000 })} value={50} />)
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '100')
})
it('should render int input without slider if min or max is missing', () => {
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0 })} value={5} />)
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
// No max -> precision step
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0')
})
// Slider events (uses generic value mock for slider)
it('should handle slide change and clamp values', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 10 })} value={0.7} onChange={onChange} />)
// Test that the actual slider triggers the onChange logic correctly
// The implementation of Slider uses onChange(val) directly via the mock
fireEvent.click(screen.getByTestId('slider-btn'))
expect(onChange).toHaveBeenCalledWith(2)
})
// Text & String tests
it('should render exact string input and propagate text changes', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'string', name: 'prompt' })} value="initial" onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'updated' } })
expect(onChange).toHaveBeenCalledWith('updated')
})
it('should render textarea for text type', () => {
const onChange = vi.fn()
const { container } = render(<ParameterItem parameterRule={createRule({ type: 'text' })} value="long text" onChange={onChange} />)
const textarea = container.querySelector('textarea')!
expect(textarea).toBeInTheDocument()
fireEvent.change(textarea, { target: { value: 'new long text' } })
expect(onChange).toHaveBeenCalledWith('new long text')
})
it('should render select for string with options', () => {
render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />)
// SimpleSelect renders an element with text 'a'
expect(screen.getByText('a')).toBeInTheDocument()
})
// Tag Tests
it('should render tag input for tag type', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'tag', tagPlaceholder: { en_US: 'placeholder', zh_Hans: 'placeholder' } })} value={['a']} onChange={onChange} />)
expect(screen.getByText('placeholder')).toBeInTheDocument()
// Trigger mock tag input
fireEvent.click(screen.getByTestId('tag-input'))
expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2'])
})
// Boolean tests
it('should render boolean radios and update value on click', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'boolean', default: false })} value={true} onChange={onChange} />)
fireEvent.click(screen.getByText('False'))
expect(onChange).toHaveBeenCalledWith(false)
})
// Switch tests
it('should call onSwitch with current value when optional switch is toggled off', () => {
const onSwitch = vi.fn()
render(<ParameterItem parameterRule={createRule()} value={0.7} onSwitch={onSwitch} />)
fireEvent.click(screen.getByRole('switch'))
expect(onSwitch).toHaveBeenCalledWith(false, 0.7)
})
it('should not render switch if required or name is stop', () => {
const { rerender } = render(<ParameterItem parameterRule={createRule({ required: true as unknown as false })} value={1} />)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
rerender(<ParameterItem parameterRule={createRule({ name: 'stop', required: false })} value={1} />)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
// Default Value Fallbacks (rendering without value)
it('should use default values if value is undefined', () => {
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'float', default: 0.5 })} />)
expect(screen.getByRole('spinbutton')).toHaveValue(0.5)
rerender(<ParameterItem parameterRule={createRule({ type: 'string', default: 'hello' })} />)
expect(screen.getByRole('textbox')).toHaveValue('hello')
rerender(<ParameterItem parameterRule={createRule({ type: 'boolean', default: true })} />)
expect(screen.getByText('True')).toBeInTheDocument()
expect(screen.getByText('False')).toBeInTheDocument()
// Without default
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />) // min is 0 by default in createRule
expect(screen.getByRole('spinbutton')).toHaveValue(0)
})
// Input Blur
it('should reset input to actual bound value on blur', () => {
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} />)
const input = screen.getByRole('spinbutton')
// change local state (which triggers clamp internally to let's say 1.4 -> 1 but leaves input text, though handleInputChange updates local state)
// Actually our test fires a change so localValue = 1, then blur sets it
fireEvent.change(input, { target: { value: '5' } })
fireEvent.blur(input)
expect(input).toHaveValue(1)
expect(input).toHaveValue(0.7)
const minBoundedProps = createProps({
parameterRule: createRule({ type: 'float', min: 1, max: 2 }),
value: 1.5,
})
rerender(<ParameterItem {...minBoundedProps} />)
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } })
expect(minBoundedProps.onChange).toHaveBeenCalledWith(1)
})
// Unsupported
it('should render no input for unsupported parameter type', () => {
render(<ParameterItem parameterRule={createRule({ type: 'unsupported' as unknown as string })} value={0.7} />)
it('should render boolean radio', () => {
const props = createProps({ parameterRule: createRule({ type: 'boolean', default: false }), value: true })
render(<ParameterItem {...props} />)
expect(screen.getByText('True')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select False'))
expect(props.onChange).toHaveBeenCalledWith(false)
})
it('should render string input and select options', () => {
const props = createProps({ parameterRule: createRule({ type: 'string' }), value: 'test' })
const { rerender } = render(<ParameterItem {...props} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new' } })
expect(props.onChange).toHaveBeenCalledWith('new')
const selectProps = createProps({
parameterRule: createRule({ type: 'string', options: ['opt1', 'opt2'] }),
value: 'opt1',
})
rerender(<ParameterItem {...selectProps} />)
const select = screen.getByRole('combobox')
fireEvent.change(select, { target: { value: 'opt2' } })
expect(selectProps.onChange).toHaveBeenCalledWith('opt2')
})
it('should handle switch toggle', () => {
const props = createProps()
let view = render(<ParameterItem {...props} />)
fireEvent.click(screen.getByText('Switch'))
expect(props.onSwitch).toHaveBeenCalledWith(false, 0.7)
const intDefaultProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, default: undefined }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...intDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(intDefaultProps.onSwitch).toHaveBeenCalledWith(true, 0)
const stringDefaultProps = createProps({
parameterRule: createRule({ type: 'string', default: 'preset-value' }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...stringDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(stringDefaultProps.onSwitch).toHaveBeenCalledWith(true, 'preset-value')
const booleanDefaultProps = createProps({
parameterRule: createRule({ type: 'boolean', default: true }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...booleanDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(booleanDefaultProps.onSwitch).toHaveBeenCalledWith(true, true)
const tagDefaultProps = createProps({
parameterRule: createRule({ type: 'tag', default: ['one'] }),
value: undefined,
})
view.unmount()
const tagView = render(<ParameterItem {...tagDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(tagDefaultProps.onSwitch).toHaveBeenCalledWith(true, ['one'])
const zeroValueProps = createProps({
parameterRule: createRule({ type: 'float', default: 0.5 }),
value: 0,
})
tagView.unmount()
render(<ParameterItem {...zeroValueProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(zeroValueProps.onSwitch).toHaveBeenCalledWith(false, 0)
})
it('should support text and tag parameter interactions', () => {
const textProps = createProps({
parameterRule: createRule({ type: 'text', name: 'prompt' }),
value: 'initial prompt',
})
const { rerender } = render(<ParameterItem {...textProps} />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'rewritten prompt' } })
expect(textProps.onChange).toHaveBeenCalledWith('rewritten prompt')
const tagProps = createProps({
parameterRule: createRule({
type: 'tag',
name: 'tags',
tagPlaceholder: { en_US: 'Tag hint', zh_Hans: 'Tag hint' },
}),
value: ['alpha'],
})
rerender(<ParameterItem {...tagProps} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one,two' } })
expect(tagProps.onChange).toHaveBeenCalledWith(['one', 'two'])
})
it('should support int parameters and unknown type fallback', () => {
const intProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, max: 500, default: 100 }),
value: 100,
})
const { rerender } = render(<ParameterItem {...intProps} />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '350' } })
expect(intProps.onChange).toHaveBeenCalledWith(350)
const unknownTypeProps = createProps({
parameterRule: createRule({ type: 'unsupported' }),
value: 0.7,
})
rerender(<ParameterItem {...unknownTypeProps} />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})

View File

@@ -2,6 +2,19 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import PresetsParameter from './presets-parameter'
vi.mock('@/app/components/base/dropdown', () => ({
default: ({ renderTrigger, items, onSelect }: { renderTrigger: (open: boolean) => React.ReactNode, items: { value: number, text: string }[], onSelect: (item: { value: number }) => void }) => (
<div>
{renderTrigger(false)}
{items.map(item => (
<button key={item.value} onClick={() => onSelect(item)}>
{item.text}
</button>
))}
</div>
),
}))
describe('PresetsParameter', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -13,39 +26,7 @@ describe('PresetsParameter', () => {
expect(screen.getByText('common.modelProvider.loadPresets')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
fireEvent.click(screen.getByText('common.model.tone.Creative'))
expect(onSelect).toHaveBeenCalledWith(1)
})
// open=true: trigger has bg-state-base-hover class
it('should apply hover background class when open is true', () => {
render(<PresetsParameter onSelect={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
const button = screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })
expect(button).toHaveClass('bg-state-base-hover')
})
// Tone map branch 2: Balanced → Scales02 icon
it('should call onSelect with tone id 2 when Balanced is clicked', () => {
const onSelect = vi.fn()
render(<PresetsParameter onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
fireEvent.click(screen.getByText('common.model.tone.Balanced'))
expect(onSelect).toHaveBeenCalledWith(2)
})
// Tone map branch 3: Precise → Target04 icon
it('should call onSelect with tone id 3 when Precise is clicked', () => {
const onSelect = vi.fn()
render(<PresetsParameter onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
fireEvent.click(screen.getByText('common.model.tone.Precise'))
expect(onSelect).toHaveBeenCalledWith(3)
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import StatusIndicators from './status-indicators'
@@ -9,6 +8,10 @@ vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => <div>{`SwitchVersion:${uniqueIdentifier}`}</div>,
}))
@@ -35,95 +38,57 @@ describe('StatusIndicators', () => {
expect(container).toBeEmptyDOMElement()
})
it('should render deprecated tooltip when provider model is disabled and in model list', async () => {
const user = userEvent.setup()
const { container } = render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>,
it('should render warning states when provider model is disabled', () => {
const parentClick = vi.fn()
const { rerender } = render(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
)
expect(screen.getByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
expect(await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
})
it('should render model-not-support tooltip when disabled model is not in model list and has no pluginInfo', async () => {
const user = userEvent.setup()
const { container } = render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={null}
t={t}
/>,
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
)
expect(screen.getByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
expect(screen.getByText('nodes.agent.linkToPlugin').closest('a')).toHaveAttribute('href', '/plugins')
fireEvent.click(screen.getByText('nodes.agent.modelNotSupport.title'))
fireEvent.click(screen.getByText('nodes.agent.linkToPlugin'))
expect(parentClick).not.toHaveBeenCalled()
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
expect(await screen.findByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
})
it('should render switch plugin version when pluginInfo exists for disabled unsupported model', () => {
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'demo-plugin' }}
t={t}
/>,
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'demo-plugin' }}
t={t}
/>
</div>,
)
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
})
it('should render nothing when needsConfiguration is true even with disabled and modelProvider', () => {
const { container } = render(
<StatusIndicators
needsConfiguration={true}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render SwitchVersion with empty identifier when plugin is not in installed list', () => {
installedPlugins = []
it('should render marketplace warning when provider is unavailable', () => {
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'missing-plugin' }}
t={t}
/>,
)
expect(screen.getByText('SwitchVersion:')).toBeInTheDocument()
})
it('should render marketplace warning tooltip when provider is unavailable', async () => {
const user = userEvent.setup()
const { container } = render(
<StatusIndicators
needsConfiguration={false}
modelProvider={false}
@@ -133,11 +98,6 @@ describe('StatusIndicators', () => {
t={t}
/>,
)
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
expect(await screen.findByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
expect(screen.getByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Trigger from './trigger'
vi.mock('../hooks', () => ({
@@ -25,10 +24,6 @@ describe('Trigger', () => {
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
beforeEach(() => {
vi.clearAllMocks()
})
it('should render initialized state', () => {
render(
<Trigger
@@ -49,92 +44,4 @@ describe('Trigger', () => {
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
// isInWorkflow=true: workflow border class + RiArrowDownSLine arrow
it('should render workflow styles when isInWorkflow is true', () => {
// Act
const { container } = render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
isInWorkflow
/>,
)
// Assert
expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg')
expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg')
expect(container.querySelectorAll('svg').length).toBe(2)
})
// disabled=true + hasDeprecated=true: AlertTriangle + deprecated tooltip
it('should show deprecated warning when disabled with hasDeprecated', () => {
// Act
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
disabled
hasDeprecated
/>,
)
// Assert - AlertTriangle renders with warning color
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
expect(warningIcon).toBeInTheDocument()
})
// disabled=true + modelDisabled=true: status text tooltip
it('should show model status tooltip when disabled with modelDisabled', () => {
// Act
render(
<Trigger
currentProvider={currentProvider}
currentModel={{ ...currentModel, status: 'no-configure' } as unknown as typeof currentModel}
disabled
modelDisabled
/>,
)
// Assert - AlertTriangle warning icon should be present
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
expect(warningIcon).toBeInTheDocument()
})
it('should render empty tooltip content when disabled without deprecated or modelDisabled', async () => {
const user = userEvent.setup()
const { container } = render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
disabled
hasDeprecated={false}
modelDisabled={false}
/>,
)
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
expect(warningIcon).toBeInTheDocument()
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
const tooltip = screen.queryByRole('tooltip')
if (tooltip)
expect(tooltip).toBeEmptyDOMElement()
expect(screen.queryByText('modelProvider.deprecated')).not.toBeInTheDocument()
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
})
// providerName not matching any provider: find() returns undefined
it('should render without crashing when providerName does not match any provider', () => {
// Act
render(
<Trigger
modelId="gpt-4"
providerName="unknown-provider"
/>,
)
// Assert
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
})

View File

@@ -10,22 +10,4 @@ describe('EmptyTrigger', () => {
render(<EmptyTrigger open={false} />)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
})
// open=true: hover bg class present
it('should apply hover background class when open is true', () => {
// Act
const { container } = render(<EmptyTrigger open={true} />)
// Assert
expect(container.firstChild).toHaveClass('bg-components-input-bg-hover')
})
// className prop truthy: custom className appears on root
it('should apply custom className when provided', () => {
// Act
const { container } = render(<EmptyTrigger open={false} className="custom-class" />)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -10,13 +10,12 @@ import PopupItem from './popup-item'
const mockUpdateModelList = vi.hoisted(() => vi.fn())
const mockUpdateModelProviders = vi.hoisted(() => vi.fn())
const mockLanguageRef = vi.hoisted(() => ({ value: 'en_US' }))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useLanguage: () => mockLanguageRef.value,
useLanguage: () => 'en_US',
useUpdateModelList: () => mockUpdateModelList,
useUpdateModelProviders: () => mockUpdateModelProviders,
}
@@ -70,7 +69,6 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
describe('PopupItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguageRef.value = 'en_US'
mockUseProviderContext.mockReturnValue({
modelProviders: [{ provider: 'openai' }],
})
@@ -146,87 +144,4 @@ describe('PopupItem', () => {
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should not show check icon when model matches but provider does not', () => {
const defaultModel: DefaultModel = { provider: 'anthropic', model: 'gpt-4' }
render(
<PopupItem
defaultModel={defaultModel}
model={makeModel()}
onSelect={vi.fn()}
/>,
)
const checkIcons = document.querySelectorAll('.h-4.w-4.shrink-0.text-text-accent')
expect(checkIcons.length).toBe(0)
})
it('should not show mode badge when model_properties.mode is absent', () => {
const modelItem = makeModelItem({ model_properties: {} })
render(
<PopupItem
model={makeModel({ models: [modelItem] })}
onSelect={vi.fn()}
/>,
)
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
})
it('should fall back to en_US label when current locale translation is empty', () => {
mockLanguageRef.value = 'zh_Hans'
const model = makeModel({
label: { en_US: 'English Label', zh_Hans: '' },
})
render(<PopupItem model={model} onSelect={vi.fn()} />)
expect(screen.getByText('English Label')).toBeInTheDocument()
})
it('should not show context_size badge when absent', () => {
const modelItem = makeModelItem({ model_properties: { mode: 'chat' } })
render(
<PopupItem
model={makeModel({ models: [modelItem] })}
onSelect={vi.fn()}
/>,
)
expect(screen.queryByText(/K$/)).not.toBeInTheDocument()
})
it('should not show capabilities section when features are empty', () => {
const modelItem = makeModelItem({ features: [] })
render(
<PopupItem
model={makeModel({ models: [modelItem] })}
onSelect={vi.fn()}
/>,
)
expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument()
})
it('should not show capabilities for non-qualifying model types', () => {
const modelItem = makeModelItem({
model_type: ModelTypeEnum.tts,
features: [ModelFeatureEnum.vision],
})
render(
<PopupItem
model={makeModel({ models: [modelItem] })}
onSelect={vi.fn()}
/>,
)
expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument()
})
it('should show en_US label when language is fr_FR and fr_FR key is absent', () => {
mockLanguageRef.value = 'fr_FR'
const model = makeModel({ label: { en_US: 'FallbackLabel', zh_Hans: 'FallbackLabel' } })
render(<PopupItem model={model} onSelect={vi.fn()} />)
expect(screen.getByText('FallbackLabel')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
import type { Model, ModelItem } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
@@ -23,6 +22,21 @@ vi.mock('@/utils/tool-call', () => ({
supportFunctionCall: mockSupportFunctionCall,
}))
const mockCloseActiveTooltip = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({
tooltipManager: {
closeActiveTooltip: mockCloseActiveTooltip,
register: vi.fn(),
clear: vi.fn(),
},
}))
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
XCircle: ({ onClick }: { onClick?: () => void }) => (
<button type="button" aria-label="clear-search" onClick={onClick} />
),
}))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
@@ -56,13 +70,10 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
})
describe('Popup', () => {
let closeActiveTooltipSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en_US'
mockSupportFunctionCall.mockReturnValue(true)
closeActiveTooltipSpy = vi.spyOn(tooltipManager, 'closeActiveTooltip')
})
it('should filter models by search and allow clearing search', () => {
@@ -80,9 +91,8 @@ describe('Popup', () => {
fireEvent.change(input, { target: { value: 'not-found' } })
expect(screen.getByText('No model found for “not-found”')).toBeInTheDocument()
fireEvent.change(input, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'clear-search' }))
expect((input as HTMLInputElement).value).toBe('')
expect(screen.getByText('openai')).toBeInTheDocument()
})
it('should filter by scope features including toolCall and non-toolCall checks', () => {
@@ -158,24 +168,6 @@ describe('Popup', () => {
expect(screen.getByText('openai')).toBeInTheDocument()
})
it('should filter out model when features array exists but does not include required scopeFeature', () => {
const modelWithToolCallOnly = makeModel({
models: [makeModelItem({ features: [ModelFeatureEnum.toolCall] })],
})
render(
<Popup
modelList={[modelWithToolCallOnly]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
// The model item should be filtered out because it has toolCall but not vision
expect(screen.queryByText('openai')).not.toBeInTheDocument()
})
it('should close tooltip on scroll', () => {
const { container } = render(
<Popup
@@ -186,7 +178,7 @@ describe('Popup', () => {
)
fireEvent.scroll(container.firstElementChild as HTMLElement)
expect(closeActiveTooltipSpy).toHaveBeenCalled()
expect(mockCloseActiveTooltip).toHaveBeenCalled()
})
it('should open provider settings when clicking footer link', () => {
@@ -204,35 +196,4 @@ describe('Popup', () => {
payload: 'provider',
})
})
it('should call onHide when footer settings link is clicked', () => {
const mockOnHide = vi.fn()
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={mockOnHide}
/>,
)
fireEvent.click(screen.getByText('common.model.settingsLink'))
expect(mockOnHide).toHaveBeenCalled()
})
it('should match model label when searchText is non-empty and label key exists for current language', () => {
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
// GPT-4 label has en_US key, so modelItem.label[language] is defined
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
fireEvent.change(input, { target: { value: 'gpt' } })
expect(screen.getByText('openai')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
import type { ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { changeModelProviderPriority } from '@/service/common'
import { ConfigurationMethodEnum } from '../declarations'
import CredentialPanel from './credential-panel'
@@ -25,15 +24,11 @@ vi.mock('@/config', async (importOriginal) => {
}
})
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({
notify: mockNotify,
}),
}
})
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
@@ -98,14 +93,8 @@ describe('CredentialPanel', () => {
})
})
const renderCredentialPanel = (provider: ModelProvider) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<CredentialPanel provider={provider} />
</ToastContext.Provider>,
)
it('should show credential name and configuration actions', () => {
renderCredentialPanel(mockProvider)
render(<CredentialPanel provider={mockProvider} />)
expect(screen.getByText('test-credential')).toBeInTheDocument()
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
@@ -114,7 +103,7 @@ describe('CredentialPanel', () => {
it('should show unauthorized status label when credential is missing', () => {
mockCredentialStatus.hasCredential = false
renderCredentialPanel(mockProvider)
render(<CredentialPanel provider={mockProvider} />)
expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument()
})
@@ -122,7 +111,7 @@ describe('CredentialPanel', () => {
it('should show removed credential label and priority tip for custom preference', () => {
mockCredentialStatus.authorized = false
mockCredentialStatus.authRemoved = true
renderCredentialPanel({ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider)
render(<CredentialPanel provider={{ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider} />)
expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument()
@@ -131,7 +120,7 @@ describe('CredentialPanel', () => {
it('should change priority and refresh related data after success', async () => {
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
mockChangePriority.mockResolvedValue({ result: 'success' })
renderCredentialPanel(mockProvider)
render(<CredentialPanel provider={mockProvider} />)
fireEvent.click(screen.getByTestId('priority-selector'))
@@ -149,70 +138,8 @@ describe('CredentialPanel', () => {
...mockProvider,
provider_credential_schema: null,
} as unknown as ModelProvider
renderCredentialPanel(providerNoSchema)
render(<CredentialPanel provider={providerNoSchema} />)
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument()
})
it('should show gray indicator when notAllowedToUse is true', () => {
mockCredentialStatus.notAllowedToUse = true
renderCredentialPanel(mockProvider)
expect(screen.getByTestId('indicator')).toHaveTextContent('gray')
})
it('should not notify or update when priority change returns non-success', async () => {
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
mockChangePriority.mockResolvedValue({ result: 'error' })
renderCredentialPanel(mockProvider)
fireEvent.click(screen.getByTestId('priority-selector'))
await waitFor(() => {
expect(mockChangePriority).toHaveBeenCalled()
})
expect(mockNotify).not.toHaveBeenCalled()
expect(mockUpdateModelProviders).not.toHaveBeenCalled()
expect(mockEventEmitter.emit).not.toHaveBeenCalled()
})
it('should show empty label when authorized is false and authRemoved is false', () => {
mockCredentialStatus.authorized = false
mockCredentialStatus.authRemoved = false
renderCredentialPanel(mockProvider)
expect(screen.queryByText(/modelProvider\.auth\.unAuthorized/)).not.toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.auth\.authRemoved/)).not.toBeInTheDocument()
})
it('should not show PriorityUseTip when priorityUseType is system', () => {
renderCredentialPanel(mockProvider)
expect(screen.queryByTestId('priority-use-tip')).not.toBeInTheDocument()
})
it('should not iterate configurateMethods for non-predefinedModel methods', async () => {
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
mockChangePriority.mockResolvedValue({ result: 'success' })
const providerWithCustomMethod = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
renderCredentialPanel(providerWithCustomMethod)
fireEvent.click(screen.getByTestId('priority-selector'))
await waitFor(() => {
expect(mockChangePriority).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalled()
})
expect(mockUpdateModelList).not.toHaveBeenCalled()
})
it('should show red indicator when hasCredential is false', () => {
mockCredentialStatus.hasCredential = false
renderCredentialPanel(mockProvider)
expect(screen.getByTestId('indicator')).toHaveTextContent('red')
})
})

View File

@@ -125,48 +125,6 @@ describe('ProviderAddedCard', () => {
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
})
it('should show loading spinner while model list is being fetched', async () => {
let resolvePromise: (value: unknown) => void = () => {}
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve
})
vi.mocked(fetchModelProviderModelList).mockReturnValue(pendingPromise as ReturnType<typeof fetchModelProviderModelList>)
render(<ProviderAddedCard provider={mockProvider} />)
fireEvent.click(screen.getByTestId('show-models-button'))
expect(document.querySelector('.i-ri-loader-2-line.animate-spin')).toBeInTheDocument()
await act(async () => {
resolvePromise({ data: [] })
})
})
it('should show modelsNum text after models have loaded', async () => {
const models = [
{ model: 'gpt-4' },
{ model: 'gpt-3.5' },
]
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: models } as unknown as { data: ModelItem[] })
render(<ProviderAddedCard provider={mockProvider} />)
fireEvent.click(screen.getByTestId('show-models-button'))
await screen.findByTestId('model-list')
const collapseBtn = screen.getByRole('button', { name: 'collapse list' })
fireEvent.click(collapseBtn)
await waitFor(() => expect(screen.queryByTestId('model-list')).not.toBeInTheDocument())
const numTexts = screen.getAllByText(/modelProvider\.modelsNum/)
expect(numTexts.length).toBeGreaterThan(0)
expect(screen.getByText(/modelProvider\.showModelsNum/)).toBeInTheDocument()
})
it('should render configure tip when provider is not in quota list and not configured', () => {
const providerWithoutQuota = {
...mockProvider,
@@ -205,16 +163,6 @@ describe('ProviderAddedCard', () => {
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
})
it('should apply anthropic background class for anthropic provider', () => {
const anthropicProvider = {
...mockProvider,
provider: 'langgenius/anthropic/anthropic',
} as unknown as ModelProvider
const { container } = render(<ProviderAddedCard provider={anthropicProvider} />)
expect(container.querySelector('.bg-third-party-model-bg-anthropic')).toBeInTheDocument()
})
it('should render custom model actions for workspace managers', () => {
const customConfigProvider = {
...mockProvider,
@@ -229,36 +177,4 @@ describe('ProviderAddedCard', () => {
rerender(<ProviderAddedCard provider={customConfigProvider} />)
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
})
it('should render credential panel when showCredential is true', () => {
// Arrange: use ConfigurationMethodEnum.predefinedModel ('predefined-model') so showCredential=true
const predefinedProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
} as unknown as ModelProvider
mockIsCurrentWorkspaceManager = true
// Act
render(<ProviderAddedCard provider={predefinedProvider} />)
// Assert: credential-panel is rendered (showCredential = true branch)
expect(screen.getByTestId('credential-panel')).toBeInTheDocument()
})
it('should not render credential panel when user is not workspace manager', () => {
// Arrange: predefined-model but manager=false so showCredential=false
const predefinedProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
} as unknown as ModelProvider
mockIsCurrentWorkspaceManager = false
// Act
render(<ProviderAddedCard provider={predefinedProvider} />)
// Assert: credential-panel is not rendered (showCredential = false)
expect(screen.queryByTestId('credential-panel')).not.toBeInTheDocument()
})
})

View File

@@ -5,7 +5,6 @@ import { ModelStatusEnum } from '../declarations'
import ModelListItem from './model-list-item'
let mockModelLoadBalancingEnabled = false
let mockPlanType: string = 'pro'
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
@@ -15,7 +14,7 @@ vi.mock('@/context/app-context', () => ({
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: { type: mockPlanType },
plan: { type: 'pro' },
}),
useProviderContextSelector: () => mockModelLoadBalancingEnabled,
}))
@@ -61,7 +60,6 @@ describe('ModelListItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelLoadBalancingEnabled = false
mockPlanType = 'pro'
})
it('should render model item with icon and name', () => {
@@ -129,127 +127,4 @@ describe('ModelListItem', () => {
fireEvent.click(screen.getByRole('button', { name: 'modify load balancing' }))
expect(onModifyLoadBalancing).toHaveBeenCalledWith(mockModel)
})
// Deprecated branches: opacity-60, disabled switch, no ConfigModel
it('should show deprecated model with opacity and disabled switch', () => {
// Arrange
const deprecatedModel = { ...mockModel, deprecated: true } as unknown as ModelItem
mockModelLoadBalancingEnabled = true
// Act
const { container } = render(
<ModelListItem
model={deprecatedModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert
expect(container.querySelector('.opacity-60')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument()
})
// Load balancing badge: visible when all 4 conditions met
it('should show load balancing badge when all conditions are met', () => {
// Arrange
mockModelLoadBalancingEnabled = true
const lbModel = {
...mockModel,
load_balancing_enabled: true,
has_invalid_load_balancing_configs: false,
deprecated: false,
} as unknown as ModelItem
// Act
render(
<ModelListItem
model={lbModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert - Badge component should render
const badge = document.querySelector('.border-text-accent-secondary')
expect(badge).toBeInTheDocument()
})
// Plan.sandbox: ConfigModel shown without load balancing enabled
it('should show ConfigModel for sandbox plan even without load balancing enabled', () => {
// Arrange - set plan type to sandbox and keep load balancing disabled
mockModelLoadBalancingEnabled = false
mockPlanType = 'sandbox'
// Act
render(
<ModelListItem
model={mockModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert - ConfigModel should show because plan.type === 'sandbox'
expect(screen.getByRole('button', { name: 'modify load balancing' })).toBeInTheDocument()
})
// Negative proof: non-sandbox plan without load balancing should NOT show ConfigModel
it('should hide ConfigModel for non-sandbox plan without load balancing enabled', () => {
// Arrange - set plan type to non-sandbox and keep load balancing disabled
mockModelLoadBalancingEnabled = false
mockPlanType = 'pro'
// Act
render(
<ModelListItem
model={mockModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert - ConfigModel should NOT show because plan.type !== 'sandbox' and load balancing is disabled
expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument()
})
// model.status=credentialRemoved: switch disabled, no ConfigModel
it('should disable switch and hide ConfigModel when status is credentialRemoved', () => {
// Arrange
const removedModel = { ...mockModel, status: ModelStatusEnum.credentialRemoved } as unknown as ModelItem
mockModelLoadBalancingEnabled = true
// Act
render(
<ModelListItem
model={removedModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert - ConfigModel should not render because status is not active/disabled
expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument()
const statusSwitch = screen.getByRole('switch')
expect(statusSwitch).toHaveClass('!cursor-not-allowed')
fireEvent.click(statusSwitch)
expect(statusSwitch).toHaveAttribute('aria-checked', 'false')
expect(enableModel).not.toHaveBeenCalled()
expect(disableModel).not.toHaveBeenCalled()
})
// isConfigurable=true: hover class on row
it('should apply hover class when isConfigurable is true', () => {
// Act
const { container } = render(
<ModelListItem
model={mockModel}
provider={mockProvider}
isConfigurable={true}
/>,
)
// Assert
expect(container.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
import type { ModelItem, ModelProvider } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum } from '../declarations'
import ModelList from './model-list'
const mockSetShowModelLoadBalancingModal = vi.fn()
@@ -106,120 +105,4 @@ describe('ModelList', () => {
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
// isConfigurable=false: predefinedModel only provider hides custom model actions
it('should hide custom model actions when provider uses predefinedModel only', () => {
// Arrange
const predefinedProvider = {
provider: 'test-provider',
configurate_methods: ['predefinedModel'],
} as unknown as ModelProvider
// Act
render(
<ModelList
provider={predefinedProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
// Assert
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
it('should call onSave (onChange) and onClose from the load balancing modal callbacks', () => {
render(
<ModelList
provider={mockProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'gpt-4' }))
expect(mockSetShowModelLoadBalancingModal).toHaveBeenCalled()
const callArg = mockSetShowModelLoadBalancingModal.mock.calls[0][0]
callArg.onSave('test-provider')
expect(mockOnChange).toHaveBeenCalledWith('test-provider')
callArg.onClose()
expect(mockSetShowModelLoadBalancingModal).toHaveBeenCalledWith(null)
})
// fetchFromRemote filtered out: provider with only fetchFromRemote
it('should hide custom model actions when provider uses fetchFromRemote only', () => {
// Arrange
const fetchOnlyProvider = {
provider: 'test-provider',
configurate_methods: ['fetchFromRemote'],
} as unknown as ModelProvider
// Act
render(
<ModelList
provider={fetchOnlyProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
// Assert
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
it('should show custom model actions when provider is configurable and user is workspace manager', () => {
// Arrange: use ConfigurationMethodEnum.customizableModel ('customizable-model') so isConfigurable=true
const configurableProvider = {
provider: 'test-provider',
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
mockIsCurrentWorkspaceManager = true
// Act
render(
<ModelList
provider={configurableProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
// Assert: custom model actions are shown (isConfigurable=true && isCurrentWorkspaceManager=true)
expect(screen.getByTestId('manage-credentials')).toBeInTheDocument()
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
})
it('should hide custom model actions when provider is configurable but user is not workspace manager', () => {
// Arrange: use ConfigurationMethodEnum.customizableModel ('customizable-model') so isConfigurable=true, but manager=false
const configurableProvider = {
provider: 'test-provider',
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
mockIsCurrentWorkspaceManager = false
// Act
render(
<ModelList
provider={configurableProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
// Assert: custom model actions are hidden (isCurrentWorkspaceManager=false covers the && short-circuit)
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
})

View File

@@ -5,7 +5,7 @@ import type {
ModelLoadBalancingConfig,
ModelProvider,
} from '../declarations'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { AddCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
@@ -261,128 +261,6 @@ describe('ModelLoadBalancingConfigs', () => {
expect(screen.getByText('common.modelProvider.defaultConfig')).toBeInTheDocument()
})
it('should remove credential at index 0', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
// Create config where the target credential is at index 0
const config: ModelLoadBalancingConfig = {
enabled: true,
configs: [
{ id: 'cfg-target', credential_id: 'cred-2', enabled: true, name: 'Key 2' },
{ id: 'cfg-other', credential_id: 'cred-1', enabled: true, name: 'Key 1' },
],
} as ModelLoadBalancingConfig
render(<StatefulHarness initialConfig={config} onRemove={onRemove} />)
await user.click(screen.getByRole('button', { name: 'trigger remove' }))
expect(onRemove).toHaveBeenCalledWith('cred-2')
expect(screen.queryByText('Key 2')).not.toBeInTheDocument()
})
it('should not toggle load balancing when modelLoadBalancingEnabled=false and enabling via switch', async () => {
const user = userEvent.setup()
mockModelLoadBalancingEnabled = false
render(<StatefulHarness initialConfig={createDraftConfig(false)} withSwitch />)
const mainSwitch = screen.getByTestId('load-balancing-switch-main')
await user.click(mainSwitch)
// Switch is disabled so toggling to true should not work
expect(mainSwitch).toHaveAttribute('aria-checked', 'false')
})
it('should toggle load balancing to false when modelLoadBalancingEnabled=false but enabled=true via switch', async () => {
const user = userEvent.setup()
mockModelLoadBalancingEnabled = false
// When draftConfig.enabled=true and !enabled (toggling off): condition `(modelLoadBalancingEnabled || !enabled)` = (!enabled) = true
render(<StatefulHarness initialConfig={createDraftConfig(true)} withSwitch />)
const mainSwitch = screen.getByTestId('load-balancing-switch-main')
await user.click(mainSwitch)
expect(mainSwitch).toHaveAttribute('aria-checked', 'false')
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
it('should not show provider badge when isProviderManaged=true but configurationMethod is customizableModel', () => {
const inheritConfig: ModelLoadBalancingConfig = {
enabled: true,
configs: [
{ id: 'cfg-inherit', credential_id: '', enabled: true, name: '__inherit__' },
],
} as ModelLoadBalancingConfig
render(
<StatefulHarness
initialConfig={inheritConfig}
configurationMethod={ConfigurationMethodEnum.customizableModel}
/>,
)
expect(screen.getByText('common.modelProvider.defaultConfig')).toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.providerManaged')).not.toBeInTheDocument()
})
it('should show upgrade panel when modelLoadBalancingEnabled=false and not CE edition', () => {
mockModelLoadBalancingEnabled = false
render(<StatefulHarness initialConfig={createDraftConfig(false)} />)
expect(screen.getByText('upgrade')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.upgradeForLoadBalancing')).toBeInTheDocument()
})
it('should pass explicit boolean state to toggleConfigEntryEnabled (typeof state === boolean branch)', async () => {
// Arrange: render with a config entry; the Switch onChange passes explicit boolean value
const user = userEvent.setup()
render(<StatefulHarness initialConfig={createDraftConfig(true)} />)
// Act: click the switch which calls toggleConfigEntryEnabled(index, value) where value is boolean
const entrySwitch = screen.getByTestId('load-balancing-switch-cfg-1')
await user.click(entrySwitch)
// Assert: component still renders after the toggle (state = explicit boolean true/false)
expect(screen.getByTestId('load-balancing-main-panel')).toBeInTheDocument()
})
it('should render with credential that has not_allowed_to_use flag (covers credential?.not_allowed_to_use ? false branch)', () => {
// Arrange: config where the credential is not allowed to use
const restrictedConfig: ModelLoadBalancingConfig = {
enabled: true,
configs: [
{ id: 'cfg-restricted', credential_id: 'cred-restricted', enabled: true, name: 'Restricted Key' },
],
} as ModelLoadBalancingConfig
const mockModelCredentialWithRestricted = {
available_credentials: [
{
credential_id: 'cred-restricted',
credential_name: 'Restricted Key',
not_allowed_to_use: true,
},
],
} as unknown as ModelCredential
// Act
render(
<ModelLoadBalancingConfigs
draftConfig={restrictedConfig}
setDraftConfig={vi.fn()}
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={mockModelCredentialWithRestricted}
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
/>,
)
// Assert: Switch value should be false (credential?.not_allowed_to_use ? false branch)
const entrySwitch = screen.getByTestId('load-balancing-switch-cfg-restricted')
expect(entrySwitch).toHaveAttribute('aria-checked', 'false')
})
it('should handle edge cases where draftConfig becomes null during callbacks', async () => {
let capturedAdd: ((credential: Credential) => void) | null = null
let capturedUpdate: ((payload?: unknown, formValues?: Record<string, unknown>) => void) | null = null
@@ -420,82 +298,4 @@ describe('ModelLoadBalancingConfigs', () => {
// Should not throw and just return prev (which is undefined)
})
it('should not toggle load balancing when modelLoadBalancingEnabled=false and clicking panel to enable', async () => {
// Arrange: load balancing not enabled in context, draftConfig.enabled=false (so panel is clickable)
const user = userEvent.setup()
mockModelLoadBalancingEnabled = false
render(<StatefulHarness initialConfig={createDraftConfig(false)} withSwitch={false} />)
// Act: clicking the panel calls toggleModalBalancing(true)
// but (modelLoadBalancingEnabled || !enabled) = (false || false) = false → condition fails
const panel = screen.getByTestId('load-balancing-main-panel')
await user.click(panel)
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
it('should return early from addConfigEntry setDraftConfig when prev is undefined', async () => {
// Arrange: use a controlled wrapper that exposes a way to force draftConfig to undefined
let capturedAdd: ((credential: Credential) => void) | null = null
const MockChild = ({ onSelectCredential }: {
onSelectCredential: (credential: Credential) => void
}) => {
capturedAdd = onSelectCredential
return null
}
vi.mocked(AddCredentialInLoadBalancing).mockImplementation(MockChild as unknown as typeof AddCredentialInLoadBalancing)
// Use a setDraftConfig spy that tracks calls and simulates null prev
const setDraftConfigSpy = vi.fn((updater: ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) | ModelLoadBalancingConfig | undefined) => {
if (typeof updater === 'function')
updater(undefined)
})
render(
<ModelLoadBalancingConfigs
draftConfig={createDraftConfig(true)}
setDraftConfig={setDraftConfigSpy}
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={mockModelCredential}
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
/>,
)
// Act: trigger addConfigEntry with undefined prev via the spy
act(() => {
if (capturedAdd)
(capturedAdd as (credential: Credential) => void)({ credential_id: 'new', credential_name: 'New' } as Credential)
})
// Assert: setDraftConfig was called and the updater returned early (prev was undefined)
expect(setDraftConfigSpy).toHaveBeenCalled()
})
it('should return early from updateConfigEntry setDraftConfig when prev is undefined', async () => {
// Arrange: use setDraftConfig spy that invokes updater with undefined prev
const setDraftConfigSpy = vi.fn((updater: ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) | ModelLoadBalancingConfig | undefined) => {
if (typeof updater === 'function')
updater(undefined)
})
render(
<ModelLoadBalancingConfigs
draftConfig={createDraftConfig(true)}
setDraftConfig={setDraftConfigSpy}
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={mockModelCredential}
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
/>,
)
// Act: click remove button which triggers updateConfigEntry → setDraftConfig with prev=undefined
const removeBtn = screen.getByTestId('load-balancing-remove-cfg-1')
fireEvent.click(removeBtn)
// Assert: setDraftConfig was called and handled undefined prev gracefully
expect(setDraftConfigSpy).toHaveBeenCalled()
})
})

View File

@@ -130,7 +130,7 @@ const ModelLoadBalancingConfigs = ({
const handleRemove = useCallback((credentialId: string) => {
const index = draftConfig?.configs.findIndex(item => item.credential_id === credentialId && item.name !== '__inherit__')
if (typeof index === 'number' && index > -1)
if (index && index > -1)
updateConfigEntry(index, () => undefined)
onRemove?.(credentialId)
}, [draftConfig?.configs, updateConfigEntry, onRemove])

View File

@@ -1,18 +1,8 @@
import type { ModelItem, ModelProvider } from '../declarations'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ToastContext } from '@/app/components/base/toast/context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ConfigurationMethodEnum } from '../declarations'
import ModelLoadBalancingModal from './model-load-balancing-modal'
vi.mock('@headlessui/react', () => ({
Transition: ({ show, children }: { show: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null),
TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogPanel: ({ children, className }: { children: React.ReactNode, className?: string }) => <div className={className}>{children}</div>,
DialogTitle: ({ children, className }: { children: React.ReactNode, className?: string }) => <h3 className={className}>{children}</h3>,
}))
type CredentialData = {
load_balancing: {
enabled: boolean
@@ -53,15 +43,11 @@ let mockCredentialData: CredentialData | undefined = {
current_credential_name: 'Default',
}
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({
notify: mockNotify,
}),
}
})
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/service/use-models', () => ({
useGetModelCredential: () => ({
@@ -116,8 +102,6 @@ vi.mock('../model-name', () => ({
}))
describe('ModelLoadBalancingModal', () => {
let user: ReturnType<typeof userEvent.setup>
const mockProvider = {
provider: 'test-provider',
provider_credential_schema: {
@@ -134,15 +118,8 @@ describe('ModelLoadBalancingModal', () => {
fetch_from: 'predefined-model',
} as unknown as ModelItem
const renderModal = (node: Parameters<typeof render>[0]) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{node}
</ToastContext.Provider>,
)
beforeEach(() => {
vi.clearAllMocks()
user = userEvent.setup()
mockDeleteModel = null
mockCredentialData = {
load_balancing: {
@@ -166,7 +143,7 @@ describe('ModelLoadBalancingModal', () => {
it('should show loading area while draft config is not ready', () => {
mockCredentialData = undefined
renderModal(
render(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
@@ -179,7 +156,7 @@ describe('ModelLoadBalancingModal', () => {
})
it('should render predefined model content', () => {
renderModal(
render(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
@@ -196,7 +173,7 @@ describe('ModelLoadBalancingModal', () => {
it('should render custom model actions and close when update has no credentials', async () => {
const onClose = vi.fn()
mockRefetch.mockResolvedValue({ data: { available_credentials: [] } })
renderModal(
render(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
@@ -208,7 +185,7 @@ describe('ModelLoadBalancingModal', () => {
expect(screen.getByText(/modelProvider\.auth\.removeModel/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'switch credential' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'config add credential' }))
fireEvent.click(screen.getByRole('button', { name: 'config add credential' }))
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
@@ -218,7 +195,7 @@ describe('ModelLoadBalancingModal', () => {
const onSave = vi.fn()
const onClose = vi.fn()
renderModal(
render(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
@@ -229,9 +206,9 @@ describe('ModelLoadBalancingModal', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'config add credential' }))
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
await user.click(screen.getByText(/operation\.save/))
fireEvent.click(screen.getByRole('button', { name: 'config add credential' }))
fireEvent.click(screen.getByRole('button', { name: 'config rename credential' }))
fireEvent.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
@@ -249,7 +226,7 @@ describe('ModelLoadBalancingModal', () => {
const onClose = vi.fn()
mockRefetch.mockResolvedValue({ data: { available_credentials: [] } })
renderModal(
render(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
@@ -259,7 +236,7 @@ describe('ModelLoadBalancingModal', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'switch credential' }))
fireEvent.click(screen.getByRole('button', { name: 'switch credential' }))
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
@@ -269,7 +246,7 @@ describe('ModelLoadBalancingModal', () => {
const onClose = vi.fn()
mockDeleteModel = { model: 'gpt-4' }
renderModal(
render(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
@@ -279,8 +256,8 @@ describe('ModelLoadBalancingModal', () => {
/>,
)
await user.click(screen.getByText(/modelProvider\.auth\.removeModel/))
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
fireEvent.click(screen.getByText(/modelProvider\.auth\.removeModel/))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockOpenConfirmDelete).toHaveBeenCalled()
@@ -288,479 +265,4 @@ describe('ModelLoadBalancingModal', () => {
expect(onClose).toHaveBeenCalled()
})
})
// Disabled load balancing: title shows configModel text
it('should show configModel title when load balancing is disabled', () => {
mockCredentialData = {
...mockCredentialData!,
load_balancing: {
enabled: false,
configs: mockCredentialData!.load_balancing.configs,
},
}
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
expect(screen.getByText(/modelProvider\.auth\.configModel/)).toBeInTheDocument()
})
// Modal hidden when open=false
it('should not render modal content when open is false', () => {
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open={false}
/>,
)
expect(screen.queryByText(/modelProvider\.auth\.configLoadBalancing/)).not.toBeInTheDocument()
})
// Config rename: updates name in draft config
it('should rename credential in draft config', async () => {
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
// Config remove: removes credential from draft
it('should remove credential from draft config', async () => {
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'config remove' }))
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
// Save error: shows error toast
it('should show error toast when save fails', async () => {
mockMutateAsync.mockResolvedValue({ result: 'error' })
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalled()
})
})
// No current_credential_id: modelCredential is undefined
it('should handle missing current_credential_id', () => {
mockCredentialData = {
...mockCredentialData!,
current_credential_id: '',
}
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
model={mockModel}
open
/>,
)
expect(screen.getByRole('button', { name: 'switch credential' })).toBeInTheDocument()
})
it('should disable save button when less than 2 configs are enabled', () => {
mockCredentialData = {
...mockCredentialData!,
load_balancing: {
enabled: true,
configs: [
{ id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Only One', credentials: { api_key: 'key' } },
{ id: 'cfg-2', credential_id: 'cred-2', enabled: false, name: 'Disabled', credentials: { api_key: 'key2' } },
],
},
}
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
expect(screen.getByText(/operation\.save/)).toBeDisabled()
})
it('should encode config entry without id as non-hidden value', async () => {
mockCredentialData = {
...mockCredentialData!,
load_balancing: {
enabled: true,
configs: [
{ id: '', credential_id: 'cred-new', enabled: true, name: 'New Entry', credentials: { api_key: 'new-key' } },
{ id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: 'Backup', credentials: { api_key: 'backup-key' } },
],
},
}
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: Array<{ credentials: { api_key: string } }> } }
// Entry without id should NOT be encoded as hidden
expect(payload.load_balancing.configs[0].credentials.api_key).toBe('new-key')
})
})
it('should add new credential to draft config when update finds matching credential', async () => {
mockRefetch.mockResolvedValue({
data: {
available_credentials: [
{ credential_id: 'cred-new', credential_name: 'New Key' },
],
},
})
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'config add credential' }))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
// Save after adding credential to verify it was added to draft
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should not update draft config when handleUpdate credential name does not match any available credential', async () => {
mockRefetch.mockResolvedValue({
data: {
available_credentials: [
{ credential_id: 'cred-other', credential_name: 'Other Key' },
],
},
})
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
// "config add credential" triggers onUpdate(undefined, { __authorization_name__: 'New Key' })
// But refetch returns 'Other Key' not 'New Key', so find() returns undefined → no config update
await user.click(screen.getByRole('button', { name: 'config add credential' }))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
// The payload configs should only have the original 2 entries (no new one added)
const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: unknown[] } }
expect(payload.load_balancing.configs).toHaveLength(2)
})
})
it('should toggle modal from enabled to disabled when clicking the card', async () => {
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
// draftConfig.enabled=true → title shows configLoadBalancing
expect(screen.getByText(/modelProvider\.auth\.configLoadBalancing/)).toBeInTheDocument()
// Clicking the card when enabled=true toggles to disabled
const card = screen.getByText(/modelProvider\.auth\.providerManaged$/).closest('div[class]')!.closest('div[class]')!
await user.click(card)
// After toggling, title should show configModel (disabled state)
expect(screen.getByText(/modelProvider\.auth\.configModel/)).toBeInTheDocument()
})
it('should use customModelCredential credential_id when present in handleSave', async () => {
// Arrange: set up credential data so customModelCredential is initialized from current_credential_id
mockCredentialData = {
...mockCredentialData!,
current_credential_id: 'cred-1',
current_credential_name: 'Default',
}
const onSave = vi.fn()
const onClose = vi.fn()
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
model={mockModel}
open
onSave={onSave}
onClose={onClose}
credential={{ credential_id: 'cred-1', credential_name: 'Default' } as unknown as Parameters<typeof ModelLoadBalancingModal>[0]['credential']}
/>,
)
// Act: save triggers handleSave which uses customModelCredential?.credential_id
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
const payload = mockMutateAsync.mock.calls[0][0] as { credential_id: string }
// credential_id should come from customModelCredential
expect(payload.credential_id).toBe('cred-1')
})
})
it('should use null fallback for available_credentials when result.data is missing in handleUpdate', async () => {
// Arrange: refetch returns data without available_credentials
const onClose = vi.fn()
mockRefetch.mockResolvedValue({ data: undefined })
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onClose={onClose}
/>,
)
// Act: trigger handleUpdate which does `result.data?.available_credentials || []`
await user.click(screen.getByRole('button', { name: 'config add credential' }))
// Assert: available_credentials falls back to [], so onClose is called
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
})
it('should use null fallback for available_credentials in handleUpdateWhenSwitchCredential when result.data is missing', async () => {
// Arrange: refetch returns data without available_credentials
const onClose = vi.fn()
mockRefetch.mockResolvedValue({ data: undefined })
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
model={mockModel}
open
onClose={onClose}
/>,
)
// Act: trigger handleUpdateWhenSwitchCredential which does `result.data?.available_credentials || []`
await user.click(screen.getByRole('button', { name: 'switch credential' }))
// Assert: available_credentials falls back to [], onClose is called
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
})
it('should use predefined provider schema without fallback when credential_form_schemas is undefined', () => {
// Arrange: provider with no credential_form_schemas → triggers ?? [] fallback
const providerWithoutSchemas = {
provider: 'test-provider',
provider_credential_schema: {
credential_form_schemas: undefined,
},
model_credential_schema: {
credential_form_schemas: undefined,
},
} as unknown as ModelProvider
renderModal(
<ModelLoadBalancingModal
provider={providerWithoutSchemas}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
// Assert: component renders without error (extendedSecretFormSchemas = [])
expect(screen.getByText(/modelProvider\.auth\.configLoadBalancing/)).toBeInTheDocument()
})
it('should use custom model credential schema without fallback when credential_form_schemas is undefined', () => {
// Arrange: provider with no model credential schemas → triggers ?? [] fallback for custom model path
const providerWithoutModelSchemas = {
provider: 'test-provider',
provider_credential_schema: {
credential_form_schemas: undefined,
},
model_credential_schema: {
credential_form_schemas: undefined,
},
} as unknown as ModelProvider
renderModal(
<ModelLoadBalancingModal
provider={providerWithoutModelSchemas}
configurateMethod={ConfigurationMethodEnum.customizableModel}
model={mockModel}
open
/>,
)
// Assert: component renders without error (extendedSecretFormSchemas = [])
expect(screen.getAllByText(/modelProvider\.auth\.specifyModelCredential/).length).toBeGreaterThan(0)
})
it('should not update draft config when rename finds no matching index in prevIndex', async () => {
// Arrange: credential in payload does not match any config (prevIndex = -1)
mockRefetch.mockResolvedValue({
data: {
available_credentials: [
{ credential_id: 'cred-99', credential_name: 'Unknown' },
],
},
})
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
// Act: "config rename credential" triggers onUpdate with credential: { credential_id: 'cred-1' }
// but refetch returns cred-99, so newIndex for cred-1 is -1
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
// Save to verify the config was not changed
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: unknown[] } }
// Config count unchanged (still 2 from original)
expect(payload.load_balancing.configs).toHaveLength(2)
})
})
it('should encode credential_name as empty string when available_credentials has no name', async () => {
// Arrange: available_credentials has a credential with no credential_name
mockRefetch.mockResolvedValue({
data: {
available_credentials: [
{ credential_id: 'cred-1', credential_name: '' },
{ credential_id: 'cred-2', credential_name: 'Backup' },
],
},
})
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
// Act: rename cred-1 which now has empty credential_name
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
})

View File

@@ -163,18 +163,6 @@ const ModelLoadBalancingModal = ({
onSave?.(provider.provider)
onClose?.()
}
else {
notify({
type: 'error',
message: (res as { error?: string })?.error || t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }),
})
}
}
catch (error) {
notify({
type: 'error',
message: error instanceof Error ? error.message : t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }),
})
}
finally {
setLoading(false)
@@ -230,7 +218,7 @@ const ModelLoadBalancingModal = ({
}
})
}
}, [refetch, onClose])
}, [refetch, credential])
const handleUpdateWhenSwitchCredential = useCallback(async () => {
const result = await refetch()
@@ -262,7 +250,7 @@ const ModelLoadBalancingModal = ({
modelName={model!.model}
/>
<ModelName
className="grow text-text-secondary system-md-regular"
className="system-md-regular grow text-text-secondary"
modelItem={model!}
showModelType
showMode

View File

@@ -1,45 +1,14 @@
import type { i18n } from 'i18next'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as reactI18next from 'react-i18next'
import { render } from '@testing-library/react'
import PriorityUseTip from './priority-use-tip'
describe('PriorityUseTip', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
it('should render tooltip with icon content', async () => {
const user = userEvent.setup()
it('should render tooltip with icon content', () => {
const { container } = render(<PriorityUseTip />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
expect(await screen.findByText('common.modelProvider.priorityUsing')).toBeInTheDocument()
expect(container.querySelector('[data-state]')).toBeInTheDocument()
})
it('should render the component without crashing', () => {
const { container } = render(<PriorityUseTip />)
expect(container.firstChild).toBeInTheDocument()
})
it('should exercise || fallback when t() returns empty string', async () => {
const user = userEvent.setup()
vi.spyOn(reactI18next, 'useTranslation').mockReturnValue({
t: () => '',
i18n: {} as unknown as i18n,
ready: true,
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
const { container } = render(<PriorityUseTip />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
expect(screen.queryByText('common.modelProvider.priorityUsing')).not.toBeInTheDocument()
expect(document.querySelector('.rounded-md.bg-components-panel-bg')).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
import type { ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import QuotaPanel from './quota-panel'
let mockWorkspace = {
@@ -14,6 +13,18 @@ let mockPlugins = [{
latest_package_identifier: 'openai@1.0.0',
}]
vi.mock('@/app/components/base/icons/src/public/llm', () => {
const Icon = ({ label }: { label: string }) => <span>{label}</span>
return {
OpenaiSmall: () => <Icon label="openai" />,
AnthropicShortLight: () => <Icon label="anthropic" />,
Gemini: () => <Icon label="gemini" />,
Grok: () => <Icon label="x" />,
Deepseek: () => <Icon label="deepseek" />,
Tongyi: () => <Icon label="tongyi" />,
}
})
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: mockWorkspace,
@@ -69,18 +80,6 @@ describe('QuotaPanel', () => {
mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }]
})
const getTrialProviderIconTrigger = (container: HTMLElement) => {
const providerIcon = container.querySelector('svg.h-6.w-6.rounded-lg')
expect(providerIcon).toBeInTheDocument()
const trigger = providerIcon?.closest('[data-state]') as HTMLDivElement | null
expect(trigger).toBeInTheDocument()
return trigger as HTMLDivElement
}
const clickFirstTrialProviderIcon = (container: HTMLElement) => {
fireEvent.click(getTrialProviderIconTrigger(container))
}
it('should render loading state', () => {
render(
<QuotaPanel
@@ -117,17 +116,17 @@ describe('QuotaPanel', () => {
})
it('should open install modal when clicking an unsupported trial provider', () => {
const { container } = render(<QuotaPanel providers={[]} />)
render(<QuotaPanel providers={[]} />)
clickFirstTrialProviderIcon(container)
fireEvent.click(screen.getByText('openai'))
expect(screen.getByText('install modal')).toBeInTheDocument()
})
it('should close install modal when provider becomes installed', async () => {
const { rerender, container } = render(<QuotaPanel providers={[]} />)
const { rerender } = render(<QuotaPanel providers={[]} />)
clickFirstTrialProviderIcon(container)
fireEvent.click(screen.getByText('openai'))
expect(screen.getByText('install modal')).toBeInTheDocument()
rerender(<QuotaPanel providers={mockProviders} />)
@@ -136,61 +135,4 @@ describe('QuotaPanel', () => {
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
})
})
it('should not open install modal when clicking an already installed provider', () => {
const { container } = render(<QuotaPanel providers={mockProviders} />)
clickFirstTrialProviderIcon(container)
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
})
it('should not open install modal when plugin is not found in marketplace', () => {
mockPlugins = []
const { container } = render(<QuotaPanel providers={[]} />)
clickFirstTrialProviderIcon(container)
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
})
it('should show destructive border when credits are zero or negative', () => {
mockWorkspace = {
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: '',
}
const { container } = render(<QuotaPanel providers={mockProviders} />)
expect(container.querySelector('.border-state-destructive-border')).toBeInTheDocument()
})
it('should show modelAPI tooltip for configured provider with custom preference', async () => {
const user = userEvent.setup()
const { container } = render(<QuotaPanel providers={mockProviders} />)
const trigger = getTrialProviderIconTrigger(container)
await user.hover(trigger as HTMLElement)
expect(await screen.findByText(/common\.modelProvider\.card\.modelAPI/)).toHaveTextContent('OpenAI')
})
it('should show modelSupported tooltip for installed provider without custom config', async () => {
const user = userEvent.setup()
const systemProviders = [
{
provider: 'langgenius/openai/openai',
preferred_provider_type: 'system',
custom_configuration: { available_credentials: [] },
},
] as unknown as ModelProvider[]
const { container } = render(<QuotaPanel providers={systemProviders} />)
const trigger = getTrialProviderIconTrigger(container)
await user.hover(trigger as HTMLElement)
expect(await screen.findByText(/common\.modelProvider\.card\.modelSupported/)).toHaveTextContent('OpenAI')
})
})

View File

@@ -1,7 +1,6 @@
import type { DefaultModelResponse } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
import { ModelTypeEnum } from '../declarations'
import SystemModel from './index'
@@ -43,15 +42,11 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({
notify: mockNotify,
}),
}
})
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('../hooks', () => ({
useModelList: () => ({
@@ -94,24 +89,18 @@ const defaultProps = {
}
describe('SystemModel', () => {
const renderSystemModel = (props: typeof defaultProps) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<SystemModel {...props} />
</ToastContext.Provider>,
)
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
})
it('should render settings button', () => {
renderSystemModel(defaultProps)
render(<SystemModel {...defaultProps} />)
expect(screen.getByRole('button', { name: /system model settings/i })).toBeInTheDocument()
})
it('should open modal when button is clicked', async () => {
renderSystemModel(defaultProps)
render(<SystemModel {...defaultProps} />)
const button = screen.getByRole('button', { name: /system model settings/i })
fireEvent.click(button)
await waitFor(() => {
@@ -120,12 +109,12 @@ describe('SystemModel', () => {
})
it('should disable button when loading', () => {
renderSystemModel({ ...defaultProps, isLoading: true })
render(<SystemModel {...defaultProps} isLoading />)
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
})
it('should close modal when cancel is clicked', async () => {
renderSystemModel(defaultProps)
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
@@ -137,7 +126,7 @@ describe('SystemModel', () => {
})
it('should save selected models and show success feedback', async () => {
renderSystemModel(defaultProps)
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
@@ -161,103 +150,11 @@ describe('SystemModel', () => {
it('should disable save when user is not workspace manager', async () => {
mockIsCurrentWorkspaceManager = false
renderSystemModel(defaultProps)
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled()
})
})
it('should render primary variant button when notConfigured is true', () => {
renderSystemModel({ ...defaultProps, notConfigured: true })
const button = screen.getByRole('button', { name: /system model settings/i })
expect(button.className).toContain('btn-primary')
})
it('should keep modal open when save returns non-success result', async () => {
mockUpdateDefaultModel.mockResolvedValueOnce({ result: 'error' })
renderSystemModel(defaultProps)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
selectorButtons.forEach(button => fireEvent.click(button))
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
expect(mockNotify).not.toHaveBeenCalled()
})
// Modal should still be open after failed save
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
it('should not add duplicate model type to changedModelTypes when same type is selected twice', async () => {
renderSystemModel(defaultProps)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
// Click the first selector twice (textGeneration type)
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
fireEvent.click(selectorButtons[0])
fireEvent.click(selectorButtons[0])
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
// textGeneration was changed, so updateModelList is called once for it
expect(mockUpdateModelList).toHaveBeenCalledTimes(1)
})
})
it('should call updateModelList for speech2text and tts types on save', async () => {
renderSystemModel(defaultProps)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
// Click speech2text (index 3) and tts (index 4) selectors
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
fireEvent.click(selectorButtons[3])
fireEvent.click(selectorButtons[4])
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateModelList).toHaveBeenCalledTimes(2)
})
})
it('should call updateModelList for each unique changed model type on save', async () => {
renderSystemModel(defaultProps)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
// Click embedding and rerank selectors (indices 1 and 2)
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
fireEvent.click(selectorButtons[1])
fireEvent.click(selectorButtons[2])
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateModelList).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -33,7 +33,7 @@ vi.mock('@/service/common', () => ({
}))
describe('utils', () => {
beforeEach(() => {
afterEach(() => {
vi.clearAllMocks()
})
@@ -97,18 +97,6 @@ describe('utils', () => {
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'network error' })
})
it('should return Unknown error when non-Error is thrown', async () => {
(validateModelProvider as unknown as Mock).mockRejectedValue('string error')
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Unknown error' })
})
it('should return default error message when error field is empty', async () => {
(validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'error', error: '' })
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'error' })
})
})
describe('validateLoadBalancingCredentials', () => {
@@ -152,24 +140,6 @@ describe('utils', () => {
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' })
})
it('should return Unknown error when non-Error is thrown', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockRejectedValue(42)
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Unknown error' })
})
it('should handle exception with Error', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockRejectedValue(new Error('Timeout'))
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Timeout' })
})
it('should return default error message when error field is empty', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'error', error: '' })
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'error' })
})
})
describe('saveCredentials', () => {
@@ -246,19 +216,6 @@ describe('utils', () => {
},
})
})
it('should remove predefined credentials without credentialId', async () => {
await removeCredentials(true, 'provider', {})
expect(deleteModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/credentials',
body: undefined,
})
})
it('should not call delete endpoint when non-predefined payload is falsy', async () => {
await removeCredentials(false, 'provider', null as unknown as Record<string, unknown>)
expect(deleteModelProvider).not.toHaveBeenCalled()
})
})
describe('genModelTypeFormSchema', () => {
@@ -271,22 +228,11 @@ describe('utils', () => {
})
describe('genModelNameFormSchema', () => {
it('should generate default form schema when no model provided', () => {
it('should generate form schema', () => {
const schema = genModelNameFormSchema()
expect(schema.type).toBe(FormTypeEnum.textInput)
expect(schema.variable).toBe('__model_name')
expect(schema.required).toBe(true)
expect(schema.label.en_US).toBe('Model Name')
expect(schema.placeholder!.en_US).toBe('Please enter model name')
})
it('should use provided label and placeholder when model is given', () => {
const schema = genModelNameFormSchema({
label: { en_US: 'Custom', zh_Hans: 'Custom' },
placeholder: { en_US: 'Enter custom', zh_Hans: 'Enter custom' },
})
expect(schema.label.en_US).toBe('Custom')
expect(schema.placeholder!.en_US).toBe('Enter custom')
})
})
})

View File

@@ -146,15 +146,14 @@ export const removeCredentials = async (predefined: boolean, provider: string, v
}
}
else {
if (!v)
return
const { __model_name, __model_type } = v
body = {
model: __model_name,
model_type: __model_type,
if (v) {
const { __model_name, __model_type } = v
body = {
model: __model_name,
model_type: __model_type,
}
url = `/workspaces/current/model-providers/${provider}/models`
}
url = `/workspaces/current/model-providers/${provider}/models`
}
return deleteModelProvider({ url, body })

View File

@@ -20,13 +20,9 @@ const mockEventEmitter = vi.hoisted(() => {
}
})
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: vi.fn(),
}
})
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),

View File

@@ -14,15 +14,11 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({
notify: vi.fn(),
}),
}
})
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: vi.fn(),
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({

View File

@@ -264,78 +264,4 @@ describe('AppNav', () => {
await user.click(screen.getByTestId('load-more'))
expect(fetchNextPage).not.toHaveBeenCalled()
})
// Non-editor link path: isCurrentWorkspaceEditor=false → link ends with /overview
it('should build overview links when user is not editor', () => {
// Arrange
setupDefaultMocks({ isEditor: false })
// Act
render(<AppNav />)
// Assert
expect(screen.getByText('App 1 -> /app/app-1/overview')).toBeInTheDocument()
})
// !!appId false: query disabled, no nav items
it('should render no nav items when appId is undefined', () => {
// Arrange
setupDefaultMocks()
mockUseParams.mockReturnValue({} as ReturnType<typeof useParams>)
mockUseInfiniteAppList.mockReturnValue({
data: undefined,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useInfiniteAppList>)
// Act
render(<AppNav />)
// Assert
const navItems = screen.getByTestId('nav-items')
expect(navItems.children).toHaveLength(0)
})
// ADVANCED_CHAT OR branch: editor + ADVANCED_CHAT mode → link ends with /workflow
it('should build workflow link for ADVANCED_CHAT mode when user is editor', () => {
// Arrange
setupDefaultMocks({
isEditor: true,
appData: [
{
id: 'app-3',
name: 'Chat App',
mode: AppModeEnum.ADVANCED_CHAT,
icon_type: 'emoji',
icon: '💬',
icon_background: null,
icon_url: null,
},
],
})
// Act
render(<AppNav />)
// Assert
expect(screen.getByText('Chat App -> /app/app-3/workflow')).toBeInTheDocument()
})
// No-match update path: appDetail.id doesn't match any nav item
it('should not change nav item names when appDetail id does not match any item', async () => {
// Arrange
setupDefaultMocks({ isEditor: true })
const { rerender } = render(<AppNav />)
// Act - set appDetail to a non-matching id
mockAppDetail = { id: 'non-existent-id', name: 'Unknown' }
rerender(<AppNav />)
// Assert - original name should be unchanged
await waitFor(() => {
expect(screen.getByText('App 1 -> /app/app-1/configuration')).toBeInTheDocument()
})
})
})

View File

@@ -6,6 +6,10 @@ function createMockComponent(testId: string) {
return () => <div data-testid={testId} />
}
vi.mock('@/app/components/base/logo/dify-logo', () => ({
default: createMockComponent('dify-logo'),
}))
vi.mock('@/app/components/header/account-dropdown/workplace-selector', () => ({
default: createMockComponent('workplace-selector'),
}))
@@ -125,7 +129,7 @@ describe('Header', () => {
it('should render header with main nav components', () => {
render(<Header />)
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.getByTestId('dify-logo')).toBeInTheDocument()
expect(screen.getByTestId('workplace-selector')).toBeInTheDocument()
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
expect(screen.getByTestId('account-dropdown')).toBeInTheDocument()
@@ -169,7 +173,7 @@ describe('Header', () => {
mockMedia = 'mobile'
render(<Header />)
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.getByTestId('dify-logo')).toBeInTheDocument()
expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
})
@@ -182,70 +186,6 @@ describe('Header', () => {
expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
expect(screen.queryByRole('img', { name: /dify logo/i })).not.toBeInTheDocument()
})
it('should show default Dify logo when branding is enabled but no workspace_logo', () => {
mockBrandingEnabled = true
mockBrandingTitle = 'Custom Title'
mockBrandingLogo = null
render(<Header />)
expect(screen.getByText('Custom Title')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
})
it('should show default Dify text when branding enabled but no application_title', () => {
mockBrandingEnabled = true
mockBrandingTitle = null
mockBrandingLogo = null
render(<Header />)
expect(screen.getByText('Dify')).toBeInTheDocument()
})
it('should show dataset nav for editor who is not dataset operator', () => {
mockIsWorkspaceEditor = true
mockIsDatasetOperator = false
render(<Header />)
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
expect(screen.getByTestId('explore-nav')).toBeInTheDocument()
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
})
it('should hide dataset nav when neither editor nor dataset operator', () => {
mockIsWorkspaceEditor = false
mockIsDatasetOperator = false
render(<Header />)
expect(screen.queryByTestId('dataset-nav')).not.toBeInTheDocument()
})
it('should render mobile layout with dataset operator nav restrictions', () => {
mockMedia = 'mobile'
mockIsDatasetOperator = true
render(<Header />)
expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-nav')).not.toBeInTheDocument()
expect(screen.queryByTestId('tools-nav')).not.toBeInTheDocument()
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
})
it('should render mobile layout with billing enabled', () => {
mockMedia = 'mobile'
mockEnableBilling = true
mockPlanType = 'sandbox'
render(<Header />)
expect(screen.getByTestId('plan-badge')).toBeInTheDocument()
expect(screen.queryByTestId('license-nav')).not.toBeInTheDocument()
expect(screen.queryByTestId('dify-logo')).not.toBeInTheDocument()
})
})

View File

@@ -1,61 +0,0 @@
import { generateMailToLink, mailToSupport } from './util'
describe('generateMailToLink', () => {
// Email-only: both subject and body branches false
it('should return mailto link with email only when no subject or body provided', () => {
// Act
const result = generateMailToLink('test@example.com')
// Assert
expect(result).toBe('mailto:test@example.com')
})
// Subject provided, body not: subject branch true, body branch false
it('should append subject when subject is provided without body', () => {
// Act
const result = generateMailToLink('test@example.com', 'Hello World')
// Assert
expect(result).toBe('mailto:test@example.com?subject=Hello%20World')
})
// Body provided, no subject: subject branch false, body branch true
it('should append body with question mark when body is provided without subject', () => {
// Act
const result = generateMailToLink('test@example.com', undefined, 'Some body text')
// Assert
expect(result).toBe('mailto:test@example.com&body=Some%20body%20text')
})
// Both subject and body provided: both branches true
it('should append both subject and body when both are provided', () => {
// Act
const result = generateMailToLink('test@example.com', 'Subject', 'Body text')
// Assert
expect(result).toBe('mailto:test@example.com?subject=Subject&body=Body%20text')
})
})
describe('mailToSupport', () => {
// Transitive coverage: exercises generateMailToLink with all params
it('should generate a mailto link with support recipient, plan, account, and version info', () => {
// Act
const result = mailToSupport('user@test.com', 'Pro', '1.0.0')
// Assert
expect(result.startsWith('mailto:support@dify.ai?')).toBe(true)
const query = result.split('?')[1]
expect(query).toBeDefined()
const params = new URLSearchParams(query)
expect(params.get('subject')).toBe('Technical Support Request Pro user@test.com')
const body = params.get('body')
expect(body).toContain('Current Plan: Pro')
expect(body).toContain('Account: user@test.com')
expect(body).toContain('Version: 1.0.0')
})
})

View File

@@ -231,8 +231,6 @@ const ChatVariableModal = ({
}
}
const MAX_DESCRIPTION_LENGTH = 255
const handleSave = () => {
if (!checkVariableName(name))
return
@@ -243,8 +241,6 @@ const ChatVariableModal = ({
// return notify({ type: 'error', message: 'value can not be empty' })
if (type === ChatVarType.Object && objectValue.some(item => !item.key && !!item.value))
return notify({ type: 'error', message: 'object key can not be empty' })
if (description.length > MAX_DESCRIPTION_LENGTH)
return notify({ type: 'error', message: t('chatVariable.modal.descriptionTooLong', { ns: 'workflow', maxLength: MAX_DESCRIPTION_LENGTH }) })
onSave({
id: chatVar ? chatVar.id : uuid4(),
@@ -277,7 +273,7 @@ const ChatVariableModal = ({
<div
className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', type === ChatVarType.Object && 'w-[480px]')}
>
<div className="mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary system-xl-semibold">
<div className="system-xl-semibold mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary">
{!chatVar ? t('chatVariable.modal.title', { ns: 'workflow' }) : t('chatVariable.modal.editTitle', { ns: 'workflow' })}
<div className="flex items-center">
<div
@@ -291,7 +287,7 @@ const ChatVariableModal = ({
<div className="max-h-[480px] overflow-y-auto px-4 py-2">
{/* name */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.name', { ns: 'workflow' })}</div>
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('chatVariable.modal.name', { ns: 'workflow' })}</div>
<div className="flex">
<Input
placeholder={t('chatVariable.modal.namePlaceholder', { ns: 'workflow' }) || ''}
@@ -304,7 +300,7 @@ const ChatVariableModal = ({
</div>
{/* type */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.type', { ns: 'workflow' })}</div>
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('chatVariable.modal.type', { ns: 'workflow' })}</div>
<div className="flex">
<VariableTypeSelector
value={type}
@@ -316,7 +312,7 @@ const ChatVariableModal = ({
</div>
{/* default value */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center justify-between text-text-secondary system-sm-semibold">
<div className="system-sm-semibold mb-1 flex h-6 items-center justify-between text-text-secondary">
<div>{t('chatVariable.modal.value', { ns: 'workflow' })}</div>
{(type === ChatVarType.ArrayString || type === ChatVarType.ArrayNumber || type === ChatVarType.ArrayBoolean) && (
<Button
@@ -345,7 +341,7 @@ const ChatVariableModal = ({
{type === ChatVarType.String && (
// Input will remove \n\r, so use Textarea just like description area
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={value}
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setValue(e.target.value)}
@@ -408,20 +404,15 @@ const ChatVariableModal = ({
</div>
{/* description */}
<div className="">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.description', { ns: 'workflow' })}</div>
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('chatVariable.modal.description', { ns: 'workflow' })}</div>
<div className="flex">
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={description}
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setDescription(e.target.value)}
/>
</div>
<div className={cn('mt-1 text-right system-xs-regular', description.length > MAX_DESCRIPTION_LENGTH ? 'text-text-destructive' : 'text-text-quaternary')}>
{description.length}
/
{MAX_DESCRIPTION_LENGTH}
</div>
</div>
</div>
<div className="flex flex-row-reverse rounded-b-2xl p-4 pt-2">

View File

@@ -4752,6 +4752,9 @@
"no-restricted-imports": {
"count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 10
},
"ts/no-explicit-any": {
"count": 6
}
@@ -4928,6 +4931,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}

View File

@@ -90,7 +90,6 @@
"chatVariable.modal.arrayValue": "Value",
"chatVariable.modal.description": "Description",
"chatVariable.modal.descriptionPlaceholder": "Describe the variable",
"chatVariable.modal.descriptionTooLong": "Description must be {{maxLength}} characters or less",
"chatVariable.modal.editInForm": "Edit in Form",
"chatVariable.modal.editInJSON": "Edit in JSON",
"chatVariable.modal.editTitle": "Edit Conversation Variable",

View File

@@ -90,7 +90,6 @@
"chatVariable.modal.arrayValue": "值",
"chatVariable.modal.description": "描述",
"chatVariable.modal.descriptionPlaceholder": "变量的描述",
"chatVariable.modal.descriptionTooLong": "描述不能超过 {{maxLength}} 个字符",
"chatVariable.modal.editInForm": "在表单中编辑",
"chatVariable.modal.editInJSON": "在 JSON 中编辑",
"chatVariable.modal.editTitle": "编辑会话变量",