Compare commits

...

15 Commits

Author SHA1 Message Date
yyh
e62b9ec8ce Merge branch 'main' into fix/query-client-infra-improvements 2026-02-23 18:16:02 +08:00
Tyson Cung
4c48e3b997 refactor: inherit ABC in AppQueueManager for proper abstract method usage (#32461)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-02-23 15:46:30 +09:00
dependabot[bot]
46f0cebbb0 chore(deps): update redis[hiredis] requirement from ~=6.1.0 to ~=7.2.0 in /api (#32464)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 15:41:12 +09:00
dependabot[bot]
2d54192f35 chore(deps): update python-docx requirement from ~=1.1.0 to ~=1.2.0 in /api (#32463)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-23 15:38:20 +09:00
dependabot[bot]
80a5398dea chore(deps): update pydantic requirement from ~=2.11.4 to ~=2.12.5 in /api (#32462)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-23 15:37:44 +09:00
Saumya Talwani
ab64c4adf9 test: add test cases for some base components (#32314) 2026-02-23 13:17:46 +08:00
mahammadasim
ce8354a42a test: Add unit tests for Data Source Integrations (Notion, Website) and Modals (#32313)
Co-authored-by: akashseth-ifp <akash.seth@infocusp.com>
2026-02-23 13:00:02 +08:00
akashseth-ifp
d0bb642fc5 test(web): Added test for model-auth files in header folder (#32358) 2026-02-23 12:57:00 +08:00
mahammadasim
e4ddf07194 test: header account about, account setting and account dropdown (#32283) 2026-02-23 12:15:57 +08:00
akashseth-ifp
aad980f267 test: tighten user-visible specs and raise coverage for key-validator… (#32281) 2026-02-23 12:15:34 +08:00
wangxiaolei
8141e3af99 fix: fix node after change can not select start node (#32441)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-02-21 14:04:21 +08:00
Asuka Minato
b108de6607 refactor: refine some type in trial (#32426)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-21 14:02:41 +08:00
dependabot[bot]
7b3b3dbe52 chore(deps): bump flask from 3.1.2 to 3.1.3 in /api (#32432)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 20:00:39 +09:00
dependabot[bot]
5d7aeaa7e5 chore(deps): bump werkzeug from 3.1.5 to 3.1.6 in /api (#32431)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 20:00:17 +09:00
dependabot[bot]
41e2812349 chore(deps): bump pypdf from 6.6.2 to 6.7.1 in /api (#32427)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 15:11:03 +09:00
84 changed files with 10797 additions and 173 deletions

View File

@@ -10,7 +10,7 @@ import services
from controllers.common.fields import Parameters as ParametersResponse from controllers.common.fields import Parameters as ParametersResponse
from controllers.common.fields import Site as SiteResponse from controllers.common.fields import Site as SiteResponse
from controllers.common.schema import get_or_create_model from controllers.common.schema import get_or_create_model
from controllers.console import api, console_ns from controllers.console import console_ns
from controllers.console.app.error import ( from controllers.console.app.error import (
AppUnavailableError, AppUnavailableError,
AudioTooLargeError, AudioTooLargeError,
@@ -469,7 +469,7 @@ class TrialSitApi(Resource):
"""Resource for trial app sites.""" """Resource for trial app sites."""
@trial_feature_enable @trial_feature_enable
@get_app_model_with_trial @get_app_model_with_trial(None)
def get(self, app_model): def get(self, app_model):
"""Retrieve app site info. """Retrieve app site info.
@@ -491,7 +491,7 @@ class TrialAppParameterApi(Resource):
"""Resource for app variables.""" """Resource for app variables."""
@trial_feature_enable @trial_feature_enable
@get_app_model_with_trial @get_app_model_with_trial(None)
def get(self, app_model): def get(self, app_model):
"""Retrieve app parameters.""" """Retrieve app parameters."""
@@ -520,7 +520,7 @@ class TrialAppParameterApi(Resource):
class AppApi(Resource): class AppApi(Resource):
@trial_feature_enable @trial_feature_enable
@get_app_model_with_trial @get_app_model_with_trial(None)
@marshal_with(app_detail_with_site_model) @marshal_with(app_detail_with_site_model)
def get(self, app_model): def get(self, app_model):
"""Get app detail""" """Get app detail"""
@@ -533,7 +533,7 @@ class AppApi(Resource):
class AppWorkflowApi(Resource): class AppWorkflowApi(Resource):
@trial_feature_enable @trial_feature_enable
@get_app_model_with_trial @get_app_model_with_trial(None)
@marshal_with(workflow_model) @marshal_with(workflow_model)
def get(self, app_model): def get(self, app_model):
"""Get workflow detail""" """Get workflow detail"""
@@ -552,7 +552,7 @@ class AppWorkflowApi(Resource):
class DatasetListApi(Resource): class DatasetListApi(Resource):
@trial_feature_enable @trial_feature_enable
@get_app_model_with_trial @get_app_model_with_trial(None)
def get(self, app_model): def get(self, app_model):
page = request.args.get("page", default=1, type=int) page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int) limit = request.args.get("limit", default=20, type=int)
@@ -570,27 +570,31 @@ class DatasetListApi(Resource):
return response return response
api.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion") console_ns.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
api.add_resource( console_ns.add_resource(
TrialMessageSuggestedQuestionApi, TrialMessageSuggestedQuestionApi,
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions", "/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
endpoint="trial_app_suggested_question", endpoint="trial_app_suggested_question",
) )
api.add_resource(TrialChatAudioApi, "/trial-apps/<uuid:app_id>/audio-to-text", endpoint="trial_app_audio") console_ns.add_resource(TrialChatAudioApi, "/trial-apps/<uuid:app_id>/audio-to-text", endpoint="trial_app_audio")
api.add_resource(TrialChatTextApi, "/trial-apps/<uuid:app_id>/text-to-audio", endpoint="trial_app_text") console_ns.add_resource(TrialChatTextApi, "/trial-apps/<uuid:app_id>/text-to-audio", endpoint="trial_app_text")
api.add_resource(TrialCompletionApi, "/trial-apps/<uuid:app_id>/completion-messages", endpoint="trial_app_completion") console_ns.add_resource(
TrialCompletionApi, "/trial-apps/<uuid:app_id>/completion-messages", endpoint="trial_app_completion"
)
api.add_resource(TrialSitApi, "/trial-apps/<uuid:app_id>/site") console_ns.add_resource(TrialSitApi, "/trial-apps/<uuid:app_id>/site")
api.add_resource(TrialAppParameterApi, "/trial-apps/<uuid:app_id>/parameters", endpoint="trial_app_parameters") console_ns.add_resource(TrialAppParameterApi, "/trial-apps/<uuid:app_id>/parameters", endpoint="trial_app_parameters")
api.add_resource(AppApi, "/trial-apps/<uuid:app_id>", endpoint="trial_app") console_ns.add_resource(AppApi, "/trial-apps/<uuid:app_id>", endpoint="trial_app")
api.add_resource(TrialAppWorkflowRunApi, "/trial-apps/<uuid:app_id>/workflows/run", endpoint="trial_app_workflow_run") console_ns.add_resource(
api.add_resource(TrialAppWorkflowTaskStopApi, "/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop") TrialAppWorkflowRunApi, "/trial-apps/<uuid:app_id>/workflows/run", endpoint="trial_app_workflow_run"
)
console_ns.add_resource(TrialAppWorkflowTaskStopApi, "/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
api.add_resource(AppWorkflowApi, "/trial-apps/<uuid:app_id>/workflows", endpoint="trial_app_workflow") console_ns.add_resource(AppWorkflowApi, "/trial-apps/<uuid:app_id>/workflows", endpoint="trial_app_workflow")
api.add_resource(DatasetListApi, "/trial-apps/<uuid:app_id>/datasets", endpoint="trial_app_datasets") console_ns.add_resource(DatasetListApi, "/trial-apps/<uuid:app_id>/datasets", endpoint="trial_app_datasets")

View File

@@ -105,9 +105,9 @@ def trial_app_required(view: Callable[Concatenate[App, P], R] | None = None):
return decorator return decorator
def trial_feature_enable(view: Callable[..., R]) -> Callable[..., R]: def trial_feature_enable(view: Callable[P, R]):
@wraps(view) @wraps(view)
def decorated(*args, **kwargs): def decorated(*args: P.args, **kwargs: P.kwargs):
features = FeatureService.get_system_features() features = FeatureService.get_system_features()
if not features.enable_trial_app: if not features.enable_trial_app:
abort(403, "Trial app feature is not enabled.") abort(403, "Trial app feature is not enabled.")
@@ -116,9 +116,9 @@ def trial_feature_enable(view: Callable[..., R]) -> Callable[..., R]:
return decorated return decorated
def explore_banner_enabled(view: Callable[..., R]) -> Callable[..., R]: def explore_banner_enabled(view: Callable[P, R]):
@wraps(view) @wraps(view)
def decorated(*args, **kwargs): def decorated(*args: P.args, **kwargs: P.kwargs):
features = FeatureService.get_system_features() features = FeatureService.get_system_features()
if not features.enable_explore_banner: if not features.enable_explore_banner:
abort(403, "Explore banner feature is not enabled.") abort(403, "Explore banner feature is not enabled.")

View File

@@ -2,7 +2,7 @@ import logging
import queue import queue
import threading import threading
import time import time
from abc import abstractmethod from abc import ABC, abstractmethod
from enum import IntEnum, auto from enum import IntEnum, auto
from typing import Any from typing import Any
@@ -31,7 +31,7 @@ class PublishFrom(IntEnum):
TASK_PIPELINE = auto() TASK_PIPELINE = auto()
class AppQueueManager: class AppQueueManager(ABC):
def __init__(self, task_id: str, user_id: str, invoke_from: InvokeFrom): def __init__(self, task_id: str, user_id: str, invoke_from: InvokeFrom):
if not user_id: if not user_id:
raise ValueError("user is required") raise ValueError("user is required")

View File

@@ -65,16 +65,16 @@ dependencies = [
"psycogreen~=1.0.2", "psycogreen~=1.0.2",
"psycopg2-binary~=2.9.6", "psycopg2-binary~=2.9.6",
"pycryptodome==3.23.0", "pycryptodome==3.23.0",
"pydantic~=2.11.4", "pydantic~=2.12.5",
"pydantic-extra-types~=2.10.3", "pydantic-extra-types~=2.10.3",
"pydantic-settings~=2.12.0", "pydantic-settings~=2.12.0",
"pyjwt~=2.10.1", "pyjwt~=2.10.1",
"pypdfium2==5.2.0", "pypdfium2==5.2.0",
"python-docx~=1.1.0", "python-docx~=1.2.0",
"python-dotenv==1.0.1", "python-dotenv==1.0.1",
"pyyaml~=6.0.1", "pyyaml~=6.0.1",
"readabilipy~=0.3.0", "readabilipy~=0.3.0",
"redis[hiredis]~=6.1.0", "redis[hiredis]~=7.2.0",
"resend~=2.9.0", "resend~=2.9.0",
"sentry-sdk[flask]~=2.28.0", "sentry-sdk[flask]~=2.28.0",
"sqlalchemy~=2.0.29", "sqlalchemy~=2.0.29",

127
api/uv.lock generated
View File

@@ -1633,16 +1633,16 @@ requires-dist = [
{ name = "psycogreen", specifier = "~=1.0.2" }, { name = "psycogreen", specifier = "~=1.0.2" },
{ name = "psycopg2-binary", specifier = "~=2.9.6" }, { name = "psycopg2-binary", specifier = "~=2.9.6" },
{ name = "pycryptodome", specifier = "==3.23.0" }, { name = "pycryptodome", specifier = "==3.23.0" },
{ name = "pydantic", specifier = "~=2.11.4" }, { name = "pydantic", specifier = "~=2.12.5" },
{ name = "pydantic-extra-types", specifier = "~=2.10.3" }, { name = "pydantic-extra-types", specifier = "~=2.10.3" },
{ name = "pydantic-settings", specifier = "~=2.12.0" }, { name = "pydantic-settings", specifier = "~=2.12.0" },
{ name = "pyjwt", specifier = "~=2.10.1" }, { name = "pyjwt", specifier = "~=2.10.1" },
{ name = "pypdfium2", specifier = "==5.2.0" }, { name = "pypdfium2", specifier = "==5.2.0" },
{ name = "python-docx", specifier = "~=1.1.0" }, { name = "python-docx", specifier = "~=1.2.0" },
{ name = "python-dotenv", specifier = "==1.0.1" }, { name = "python-dotenv", specifier = "==1.0.1" },
{ name = "pyyaml", specifier = "~=6.0.1" }, { name = "pyyaml", specifier = "~=6.0.1" },
{ name = "readabilipy", specifier = "~=0.3.0" }, { name = "readabilipy", specifier = "~=0.3.0" },
{ name = "redis", extras = ["hiredis"], specifier = "~=6.1.0" }, { name = "redis", extras = ["hiredis"], specifier = "~=7.2.0" },
{ name = "resend", specifier = "~=2.9.0" }, { name = "resend", specifier = "~=2.9.0" },
{ name = "sendgrid", specifier = "~=6.12.3" }, { name = "sendgrid", specifier = "~=6.12.3" },
{ name = "sentry-sdk", extras = ["flask"], specifier = "~=2.28.0" }, { name = "sentry-sdk", extras = ["flask"], specifier = "~=2.28.0" },
@@ -2019,7 +2019,7 @@ wheels = [
[[package]] [[package]]
name = "flask" name = "flask"
version = "3.1.2" version = "3.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "blinker" }, { name = "blinker" },
@@ -2029,9 +2029,9 @@ dependencies = [
{ name = "markupsafe" }, { name = "markupsafe" },
{ name = "werkzeug" }, { name = "werkzeug" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
] ]
[[package]] [[package]]
@@ -4854,7 +4854,7 @@ wheels = [
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.11.10" version = "2.12.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-types" }, { name = "annotated-types" },
@@ -4862,57 +4862,64 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload-time = "2025-10-04T10:40:41.338Z" } sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload-time = "2025-10-04T10:40:39.055Z" }, { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
] ]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.33.2" version = "2.41.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
] ]
[[package]] [[package]]
@@ -5043,11 +5050,11 @@ wheels = [
[[package]] [[package]]
name = "pypdf" name = "pypdf"
version = "6.6.2" version = "6.7.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/bb/a44bab1ac3c54dbcf653d7b8bcdee93dddb2d3bf025a3912cacb8149a2f2/pypdf-6.6.2.tar.gz", hash = "sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016", size = 5281850, upload-time = "2026-01-26T11:57:55.964Z" } sdist = { url = "https://files.pythonhosted.org/packages/ff/63/3437c4363483f2a04000a48f1cd48c40097f69d580363712fa8b0b4afe45/pypdf-6.7.1.tar.gz", hash = "sha256:6b7a63be5563a0a35d54c6d6b550d75c00b8ccf36384be96365355e296e6b3b0", size = 5302208, upload-time = "2026-02-17T17:00:48.88Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/be/549aaf1dfa4ab4aed29b09703d2fb02c4366fc1f05e880948c296c5764b9/pypdf-6.6.2-py3-none-any.whl", hash = "sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba", size = 329132, upload-time = "2026-01-26T11:57:54.099Z" }, { url = "https://files.pythonhosted.org/packages/68/77/38bd7744bb9e06d465b0c23879e6d2c187d93a383f8fa485c862822bb8a3/pypdf-6.7.1-py3-none-any.whl", hash = "sha256:a02ccbb06463f7c334ce1612e91b3e68a8e827f3cee100b9941771e6066b094e", size = 331048, upload-time = "2026-02-17T17:00:46.991Z" },
] ]
[[package]] [[package]]
@@ -5251,15 +5258,15 @@ wheels = [
[[package]] [[package]]
name = "python-docx" name = "python-docx"
version = "1.1.2" version = "1.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "lxml" }, { name = "lxml" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/35/e4/386c514c53684772885009c12b67a7edd526c15157778ac1b138bc75063e/python_docx-1.1.2.tar.gz", hash = "sha256:0cf1f22e95b9002addca7948e16f2cd7acdfd498047f1941ca5d293db7762efd", size = 5656581, upload-time = "2024-05-01T19:41:57.772Z" } sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/3d/330d9efbdb816d3f60bf2ad92f05e1708e4a1b9abe80461ac3444c83f749/python_docx-1.1.2-py3-none-any.whl", hash = "sha256:08c20d6058916fb19853fcf080f7f42b6270d89eac9fa5f8c15f691c0017fabe", size = 244315, upload-time = "2024-05-01T19:41:47.006Z" }, { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
] ]
[[package]] [[package]]
@@ -5469,14 +5476,14 @@ wheels = [
[[package]] [[package]]
name = "redis" name = "redis"
version = "6.1.1" version = "7.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" }, { name = "async-timeout", marker = "python_full_version < '3.11.3'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/07/8b/14ef373ffe71c0d2fde93c204eab78472ea13c021d9aee63b0e11bd65896/redis-6.1.1.tar.gz", hash = "sha256:88c689325b5b41cedcbdbdfd4d937ea86cf6dab2222a83e86d8a466e4b3d2600", size = 4629515, upload-time = "2025-06-02T11:44:04.137Z" } sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/cd/29503c609186104c363ef1f38d6e752e7d91ef387fc90aa165e96d69f446/redis-6.1.1-py3-none-any.whl", hash = "sha256:ed44d53d065bbe04ac6d76864e331cfe5c5353f86f6deccc095f8794fd15bb2e", size = 273930, upload-time = "2025-06-02T11:44:02.705Z" }, { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -7207,14 +7214,14 @@ wheels = [
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.5" version = "3.1.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markupsafe" }, { name = "markupsafe" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
] ]
[[package]] [[package]]

View File

@@ -0,0 +1,299 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import mermaid from 'mermaid'
import Flowchart from './index'
vi.mock('mermaid', () => ({
default: {
initialize: vi.fn(),
render: vi.fn().mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg</svg>', diagramType: 'flowchart' }),
mermaidAPI: {
render: vi.fn().mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg-api</svg>', diagramType: 'flowchart' }),
},
},
}))
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
svgToBase64: vi.fn().mockResolvedValue('data:image/svg+xml;base64,dGVzdC1zdmc='),
waitForDOMElement: vi.fn((cb: () => Promise<unknown>) => cb()),
}
})
describe('Mermaid Flowchart Component', () => {
const mockCode = 'graph TD\n A-->B'
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(mermaid.initialize).mockImplementation(() => { })
})
describe('Rendering', () => {
it('should initialize mermaid on mount', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} />)
})
expect(mermaid.initialize).toHaveBeenCalled()
})
it('should render mermaid chart after debounce', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should render gantt charts with specific formatting', async () => {
const ganttCode = 'gantt\ntitle T\nTask :after task1, after task2'
await act(async () => {
render(<Flowchart PrimitiveCode={ganttCode} />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should render mindmap and sequenceDiagram charts', async () => {
const mindmapCode = 'mindmap\n root\n topic1'
const { unmount } = await act(async () => {
return render(<Flowchart PrimitiveCode={mindmapCode} />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
unmount()
const sequenceCode = 'sequenceDiagram\n A->>B: Hello'
await act(async () => {
render(<Flowchart PrimitiveCode={sequenceCode} />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should handle dark theme configuration', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} theme="dark" />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
})
describe('Interactions', () => {
it('should switch between classic and handDrawn looks', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} />)
})
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
const handDrawnBtn = screen.getByText(/handDrawn/i)
await act(async () => {
fireEvent.click(handDrawnBtn)
})
await waitFor(() => {
expect(screen.getByText('test-svg-api')).toBeInTheDocument()
}, { timeout: 3000 })
const classicBtn = screen.getByText(/classic/i)
await act(async () => {
fireEvent.click(classicBtn)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should toggle theme manually', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} theme="light" />)
})
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
const toggleBtn = screen.getByRole('button')
await act(async () => {
fireEvent.click(toggleBtn)
})
await waitFor(() => {
expect(mermaid.initialize).toHaveBeenCalled()
}, { timeout: 3000 })
})
it('should open image preview when clicking the chart', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} />)
})
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
const chartDiv = screen.getByText('test-svg').closest('.mermaid')
await act(async () => {
fireEvent.click(chartDiv!)
})
await waitFor(() => {
expect(document.body.querySelector('.image-preview-container')).toBeInTheDocument()
}, { timeout: 3000 })
})
})
describe('Edge Cases', () => {
it('should not render when code is too short', async () => {
const shortCode = 'graph'
vi.useFakeTimers()
render(<Flowchart PrimitiveCode={shortCode} />)
await vi.advanceTimersByTimeAsync(1000)
expect(mermaid.render).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('should handle rendering errors gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const errorMsg = 'Syntax error'
vi.mocked(mermaid.render).mockRejectedValue(new Error(errorMsg))
// Use unique code to avoid hitting the module-level diagramCache from previous tests
const uniqueCode = 'graph TD\n X-->Y\n Y-->Z'
const { container } = render(<Flowchart PrimitiveCode={uniqueCode} />)
await waitFor(() => {
const errorSpan = container.querySelector('.text-red-500 span.ml-2')
expect(errorSpan).toBeInTheDocument()
expect(errorSpan?.textContent).toContain('Rendering failed')
}, { timeout: 5000 })
consoleSpy.mockRestore()
// Restore default mock to prevent leaking into subsequent tests
vi.mocked(mermaid.render).mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg</svg>', diagramType: 'flowchart' })
}, 10000)
it('should use cached diagram if available', async () => {
const { rerender } = render(<Flowchart PrimitiveCode={mockCode} />)
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
vi.mocked(mermaid.render).mockClear()
await act(async () => {
rerender(<Flowchart PrimitiveCode={mockCode} />)
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 500))
})
expect(mermaid.render).not.toHaveBeenCalled()
})
it('should handle invalid mermaid code completion', async () => {
const invalidCode = 'graph TD\nA -->' // Incomplete
await act(async () => {
render(<Flowchart PrimitiveCode={invalidCode} />)
})
await waitFor(() => {
expect(screen.getByText('Diagram code is not complete or invalid.')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should handle unmount cleanup', async () => {
const { unmount } = render(<Flowchart PrimitiveCode={mockCode} />)
await act(async () => {
unmount()
})
})
})
})
describe('Mermaid Flowchart Component Module Isolation', () => {
const mockCode = 'graph TD\n A-->B'
let mermaidFresh: typeof mermaid
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
const mod = await import('mermaid') as unknown as { default: typeof mermaid } | typeof mermaid
mermaidFresh = 'default' in mod ? mod.default : mod
vi.mocked(mermaidFresh.initialize).mockImplementation(() => { })
})
describe('Error Handling', () => {
it('should handle initialization failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const { default: FlowchartFresh } = await import('./index')
vi.mocked(mermaidFresh.initialize).mockImplementationOnce(() => {
throw new Error('Init fail')
})
await act(async () => {
render(<FlowchartFresh PrimitiveCode={mockCode} />)
})
expect(consoleSpy).toHaveBeenCalledWith('Mermaid initialization error:', expect.any(Error))
consoleSpy.mockRestore()
})
it('should handle mermaidAPI missing fallback', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const originalMermaidAPI = mermaidFresh.mermaidAPI
// @ts-expect-error need to set undefined for testing
mermaidFresh.mermaidAPI = undefined
const { default: FlowchartFresh } = await import('./index')
const { container } = render(<FlowchartFresh PrimitiveCode={mockCode} />)
// Wait for initial render to complete
await waitFor(() => {
expect(screen.getByText(/handDrawn/)).toBeInTheDocument()
}, { timeout: 3000 })
const handDrawnBtn = screen.getByText(/handDrawn/)
await act(async () => {
fireEvent.click(handDrawnBtn)
})
// When mermaidAPI is undefined, handDrawn style falls back to mermaid.render.
// The module captures mermaidAPI at import time, so setting it to undefined on
// the mocked object may not affect the module's internal reference.
// Verify that the rendering completes (either with svg or error)
await waitFor(() => {
const hasSvg = container.querySelector('.mermaid div')
const hasError = container.querySelector('.text-red-500')
expect(hasSvg || hasError).toBeTruthy()
}, { timeout: 5000 })
mermaidFresh.mermaidAPI = originalMermaidAPI
consoleSpy.mockRestore()
}, 10000)
it('should handle configuration failure', async () => {
vi.mocked(mermaidFresh.initialize).mockImplementation(() => {
throw new Error('Config fail')
})
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const { default: FlowchartFresh } = await import('./index')
await act(async () => {
render(<FlowchartFresh PrimitiveCode={mockCode} />)
})
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Mermaid initialization error:', expect.any(Error))
})
consoleSpy.mockRestore()
})
})
})

View File

@@ -1,59 +1,265 @@
import { cleanUpSvgCode, prepareMermaidCode, sanitizeMermaidCode } from './utils' import { cleanUpSvgCode, isMermaidCodeComplete, prepareMermaidCode, processSvgForTheme, sanitizeMermaidCode, svgToBase64, waitForDOMElement } from './utils'
describe('cleanUpSvgCode', () => { describe('cleanUpSvgCode', () => {
it('replaces old-style <br> tags with the new style', () => { it('should replace old-style <br> tags with self-closing <br/>', () => {
const result = cleanUpSvgCode('<br>test<br>') const result = cleanUpSvgCode('<br>test<br>')
expect(result).toEqual('<br/>test<br/>') expect(result).toEqual('<br/>test<br/>')
}) })
}) })
describe('sanitizeMermaidCode', () => { describe('sanitizeMermaidCode', () => {
it('removes click directives to prevent link/callback injection', () => { describe('Edge Cases', () => {
const unsafeProtocol = ['java', 'script:'].join('') it('should handle null/non-string input', () => {
const input = [ // @ts-expect-error need to test null input
'gantt', expect(sanitizeMermaidCode(null)).toBe('')
'title Demo', // @ts-expect-error need to test undefined input
'section S1', expect(sanitizeMermaidCode(undefined)).toBe('')
'Task 1 :a1, 2020-01-01, 1d', // @ts-expect-error need to test non-string input
`click A href "${unsafeProtocol}alert(location.href)"`, expect(sanitizeMermaidCode(123)).toBe('')
'click B call callback()', })
].join('\n')
const result = sanitizeMermaidCode(input)
expect(result).toContain('gantt')
expect(result).toContain('Task 1')
expect(result).not.toContain('click A')
expect(result).not.toContain('click B')
expect(result).not.toContain(unsafeProtocol)
}) })
it('removes Mermaid init directives to prevent config overrides', () => { describe('Security', () => {
const input = [ it('should remove click directives to prevent link/callback injection', () => {
'%%{init: {"securityLevel":"loose"}}%%', const unsafeProtocol = ['java', 'script:'].join('')
'graph TD', const input = [
'A-->B', 'gantt',
].join('\n') 'title Demo',
'section S1',
'Task 1 :a1, 2020-01-01, 1d',
`click A href "${unsafeProtocol}alert(location.href)"`,
'click B call callback()',
].join('\n')
const result = sanitizeMermaidCode(input) const result = sanitizeMermaidCode(input)
expect(result).toEqual(['graph TD', 'A-->B'].join('\n')) expect(result).toContain('gantt')
expect(result).toContain('Task 1')
expect(result).not.toContain('click A')
expect(result).not.toContain('click B')
expect(result).not.toContain(unsafeProtocol)
})
it('should remove Mermaid init directives to prevent config overrides', () => {
const input = [
'%%{init: {"securityLevel":"loose"}}%%',
'graph TD',
'A-->B',
].join('\n')
const result = sanitizeMermaidCode(input)
expect(result).toEqual(['graph TD', 'A-->B'].join('\n'))
})
}) })
}) })
describe('prepareMermaidCode', () => { describe('prepareMermaidCode', () => {
it('sanitizes click directives in flowcharts', () => { describe('Edge Cases', () => {
const unsafeProtocol = ['java', 'script:'].join('') it('should handle null/non-string input', () => {
const input = [ // @ts-expect-error need to test null input
'graph TD', expect(prepareMermaidCode(null, 'classic')).toBe('')
'A[Click]-->B', })
`click A href "${unsafeProtocol}alert(1)"`, })
].join('\n')
const result = prepareMermaidCode(input, 'classic') describe('Sanitization', () => {
it('should sanitize click directives in flowcharts', () => {
const unsafeProtocol = ['java', 'script:'].join('')
const input = [
'graph TD',
'A[Click]-->B',
`click A href "${unsafeProtocol}alert(1)"`,
].join('\n')
expect(result).toContain('graph TD') const result = prepareMermaidCode(input, 'classic')
expect(result).not.toContain('click ')
expect(result).not.toContain(unsafeProtocol) expect(result).toContain('graph TD')
expect(result).not.toContain('click ')
expect(result).not.toContain(unsafeProtocol)
})
it('should replace <br> with newline', () => {
const input = 'graph TD\nA[Node<br>Line]-->B'
const result = prepareMermaidCode(input, 'classic')
expect(result).toContain('Node\nLine')
})
})
describe('HandDrawn Style', () => {
it('should handle handDrawn style specifically', () => {
const input = 'flowchart TD\nstyle A fill:#fff\nlinkStyle 0 stroke:#000\nA-->B'
const result = prepareMermaidCode(input, 'handDrawn')
expect(result).toContain('graph TD')
expect(result).not.toContain('style ')
expect(result).not.toContain('linkStyle ')
expect(result).toContain('A-->B')
})
it('should add TD fallback for handDrawn if missing', () => {
const input = 'A-->B'
const result = prepareMermaidCode(input, 'handDrawn')
expect(result).toBe('graph TD\nA-->B')
})
})
})
describe('svgToBase64', () => {
describe('Rendering', () => {
it('should return empty string for empty input', async () => {
expect(await svgToBase64('')).toBe('')
})
it('should convert svg to base64', async () => {
const svg = '<svg>test</svg>'
const result = await svgToBase64(svg)
expect(result).toContain('base64,')
expect(result).toContain('image/svg+xml')
})
it('should convert svg with xml declaration to base64', async () => {
const svg = '<?xml version="1.0" encoding="UTF-8"?><svg>test</svg>'
const result = await svgToBase64(svg)
expect(result).toContain('base64,')
expect(result).toContain('image/svg+xml')
})
})
describe('Edge Cases', () => {
it('should handle errors gracefully', async () => {
const encoderSpy = vi.spyOn(globalThis, 'TextEncoder').mockImplementation(() => ({
encoding: 'utf-8',
encode: () => { throw new Error('Encoder fail') },
encodeInto: () => ({ read: 0, written: 0 }),
} as unknown as TextEncoder))
const result = await svgToBase64('<svg>fail</svg>')
expect(result).toBe('')
encoderSpy.mockRestore()
})
})
})
describe('processSvgForTheme', () => {
const themes = {
light: {
nodeColors: [{ bg: '#fefefe' }, { bg: '#eeeeee' }],
connectionColor: '#cccccc',
},
dark: {
nodeColors: [{ bg: '#121212' }, { bg: '#222222' }],
connectionColor: '#333333',
},
}
describe('Light Theme', () => {
it('should process light theme node colors', () => {
const svg = '<rect fill="#ffffff" class="node-1"/>'
const result = processSvgForTheme(svg, false, false, themes)
expect(result).toContain('fill="#fefefe"')
})
it('should process handDrawn style for light theme', () => {
const svg = '<path fill="#ffffff" stroke="#ffffff"/>'
const result = processSvgForTheme(svg, false, true, themes)
expect(result).toContain('fill="#fefefe"')
expect(result).toContain('stroke="#cccccc"')
})
})
describe('Dark Theme', () => {
it('should process dark theme node colors and general elements', () => {
const svg = '<rect fill="#ffffff" class="node-1"/><path stroke="#ffffff"/><rect fill="#ffffff" style="fill: #000000; stroke: #000000"/>'
const result = processSvgForTheme(svg, true, false, themes)
expect(result).toContain('fill="#121212"')
expect(result).toContain('fill="#1e293b"') // Generic rect replacement
expect(result).toContain('stroke="#333333"')
})
it('should handle multiple node colors in cyclic manner', () => {
const svg = '<rect fill="#ffffff" class="node-1"/><rect fill="#ffffff" class="node-2"/><rect fill="#ffffff" class="node-3"/>'
const result = processSvgForTheme(svg, true, false, themes)
const fillMatches = result.match(/fill="#[a-fA-F0-9]{6}"/g)
expect(fillMatches).toContain('fill="#121212"')
expect(fillMatches).toContain('fill="#222222"')
expect(fillMatches?.filter(f => f === 'fill="#121212"').length).toBe(2)
})
it('should process handDrawn style for dark theme', () => {
const svg = '<path fill="#ffffff" stroke="#ffffff"/>'
const result = processSvgForTheme(svg, true, true, themes)
expect(result).toContain('fill="#121212"')
expect(result).toContain('stroke="#333333"')
})
})
})
describe('isMermaidCodeComplete', () => {
describe('Edge Cases', () => {
it('should return false for empty input', () => {
expect(isMermaidCodeComplete('')).toBe(false)
expect(isMermaidCodeComplete(' ')).toBe(false)
})
it('should detect common syntax errors', () => {
expect(isMermaidCodeComplete('graph TD\nA--> undefined')).toBe(false)
expect(isMermaidCodeComplete('graph TD\nA--> [object Object]')).toBe(false)
expect(isMermaidCodeComplete('graph TD\nA-->')).toBe(false)
})
it('should handle validation error gracefully', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const startsWithSpy = vi.spyOn(String.prototype, 'startsWith').mockImplementation(() => {
throw new Error('Start fail')
})
expect(isMermaidCodeComplete('graph TD')).toBe(false)
expect(consoleSpy).toHaveBeenCalledWith('Mermaid code validation error:', expect.any(Error))
startsWithSpy.mockRestore()
consoleSpy.mockRestore()
})
})
describe('Chart Types', () => {
it('should validate gantt charts', () => {
expect(isMermaidCodeComplete('gantt\ntitle T\nsection S\nTask')).toBe(true)
expect(isMermaidCodeComplete('gantt\ntitle T')).toBe(false)
})
it('should validate mindmaps', () => {
expect(isMermaidCodeComplete('mindmap\nroot')).toBe(true)
expect(isMermaidCodeComplete('mindmap')).toBe(false)
})
it('should validate other chart types', () => {
expect(isMermaidCodeComplete('graph TD\nA-->B')).toBe(true)
expect(isMermaidCodeComplete('pie title P\n"A": 10')).toBe(true)
expect(isMermaidCodeComplete('invalid chart')).toBe(false)
})
})
})
describe('waitForDOMElement', () => {
it('should resolve when callback resolves', async () => {
const cb = vi.fn().mockResolvedValue('success')
const result = await waitForDOMElement(cb)
expect(result).toBe('success')
expect(cb).toHaveBeenCalledTimes(1)
})
it('should retry on failure', async () => {
const cb = vi.fn()
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValue('success')
const result = await waitForDOMElement(cb, 3, 10)
expect(result).toBe('success')
expect(cb).toHaveBeenCalledTimes(2)
})
it('should reject after max attempts', async () => {
const cb = vi.fn().mockRejectedValue(new Error('fail'))
await expect(waitForDOMElement(cb, 2, 10)).rejects.toThrow('fail')
expect(cb).toHaveBeenCalledTimes(2)
}) })
}) })

View File

@@ -0,0 +1,104 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { fireEvent, render, screen } from '@testing-library/react'
import { useStore } from '@/app/components/app/store'
import MessageLogModal from './index'
let clickAwayHandler: (() => void) | null = null
vi.mock('ahooks', () => ({
useClickAway: (fn: () => void) => {
clickAwayHandler = fn
},
}))
vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(),
}))
vi.mock('@/app/components/workflow/run', () => ({
default: ({ activeTab, runDetailUrl, tracingListUrl }: { activeTab: string, runDetailUrl: string, tracingListUrl: string }) => (
<div
data-testid="workflow-run"
data-active-tab={activeTab}
data-run-detail-url={runDetailUrl}
data-tracing-list-url={tracingListUrl}
/>
),
}))
const mockLog = {
id: 'msg-1',
content: 'mock log message',
workflow_run_id: 'run-1',
isAnswer: true,
}
describe('MessageLogModal', () => {
const onCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
clickAwayHandler = null
// eslint-disable-next-line ts/no-explicit-any
vi.mocked(useStore).mockImplementation((selector: any) => selector({
appDetail: { id: 'app-1' },
}))
})
describe('Render', () => {
it('renders nothing if currentLogItem is missing', () => {
const { container } = render(<MessageLogModal width={800} onCancel={onCancel} />)
expect(container.firstChild).toBeNull()
})
it('renders nothing if currentLogItem.workflow_run_id is missing', () => {
const { container } = render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={{ id: '1' } as IChatItem} />)
expect(container.firstChild).toBeNull()
})
it('renders modal with correct title and Run component', () => {
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
expect(screen.getByText(/title/i)).toBeInTheDocument()
expect(screen.getByTestId('workflow-run')).toBeInTheDocument()
})
})
describe('Props', () => {
it('passes correct props to Run component', () => {
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} defaultTab="TRACING" />)
const runComponent = screen.getByTestId('workflow-run')
expect(runComponent.getAttribute('data-active-tab')).toBe('TRACING')
expect(runComponent.getAttribute('data-run-detail-url')).toBe('/apps/app-1/workflow-runs/run-1')
expect(runComponent.getAttribute('data-tracing-list-url')).toBe('/apps/app-1/workflow-runs/run-1/node-executions')
})
it('sets fixed style when fixedWidth is false (floating)', () => {
const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={false} />)
const modal = container.firstChild as HTMLElement
expect(modal.style.position).toBe('fixed')
expect(modal.style.width).toBe('480px')
})
it('sets fixed width when fixedWidth is true', () => {
const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={true} />)
const modal = container.firstChild as HTMLElement
expect(modal.style.width).toBe('1000px')
})
})
describe('Interaction', () => {
it('calls onCancel when close icon is clicked', () => {
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
const closeButton = screen.getByTestId('close-button')
expect(closeButton).toBeInTheDocument()
fireEvent.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('calls onCancel when clicked away', () => {
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
expect(clickAwayHandler).toBeTruthy()
clickAwayHandler!()
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -57,8 +57,8 @@ const MessageLogModal: FC<MessageLogModalProps> = ({
}} }}
ref={ref} ref={ref}
> >
<h1 className="system-xl-semibold shrink-0 px-4 py-1 text-text-primary">{t('runDetail.title', { ns: 'appLog' })}</h1> <h1 className="shrink-0 px-4 py-1 text-text-primary system-xl-semibold">{t('runDetail.title', { ns: 'appLog' })}</h1>
<span className="absolute right-3 top-4 z-20 cursor-pointer p-1" onClick={onCancel}> <span className="absolute right-3 top-4 z-20 cursor-pointer p-1" onClick={onCancel} data-testid="close-button">
<RiCloseLine className="h-4 w-4 text-text-tertiary" /> <RiCloseLine className="h-4 w-4 text-text-tertiary" />
</span> </span>
<Run <Run

View File

@@ -0,0 +1,84 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import ModalLikeWrap from '.'
describe('ModalLikeWrap', () => {
const defaultProps = {
title: 'Test Title',
onClose: vi.fn(),
onConfirm: vi.fn(),
children: <div>Test Content</div>,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Render', () => {
it('renders title and content correctly', () => {
render(<ModalLikeWrap {...defaultProps} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('renders beforeHeader if provided', () => {
const beforeHeader = <div data-testid="before-header">Before Header</div>
render(<ModalLikeWrap {...defaultProps} beforeHeader={beforeHeader} />)
expect(screen.getByTestId('before-header')).toBeInTheDocument()
expect(screen.getByText('Before Header')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls onClose when close icon is clicked', async () => {
render(<ModalLikeWrap {...defaultProps} />)
const closeBtn = screen.getByTestId('modal-close-btn')
expect(closeBtn).toBeInTheDocument()
await act(async () => {
fireEvent.click(closeBtn)
})
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('calls onClose when Cancel button is clicked', async () => {
render(<ModalLikeWrap {...defaultProps} />)
const cancelBtn = screen.getByText('common.operation.cancel')
await act(async () => {
fireEvent.click(cancelBtn)
})
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('calls onConfirm when Save button is clicked', async () => {
render(<ModalLikeWrap {...defaultProps} />)
const saveBtn = screen.getByText('common.operation.save')
await act(async () => {
fireEvent.click(saveBtn)
})
expect(defaultProps.onConfirm).toHaveBeenCalled()
})
})
describe('Props', () => {
it('hides close icon when hideCloseBtn is true', () => {
render(<ModalLikeWrap {...defaultProps} hideCloseBtn={true} />)
const closeBtn = document.querySelector('.remixicon')
expect(closeBtn).not.toBeInTheDocument()
})
it('applies custom className', () => {
const { container } = render(<ModalLikeWrap {...defaultProps} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
})
})

View File

@@ -1,6 +1,5 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@@ -31,13 +30,13 @@ const ModalLikeWrap: FC<Props> = ({
<div className={cn('w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-3 pb-4 pt-3.5 shadow-xl', className)}> <div className={cn('w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-3 pb-4 pt-3.5 shadow-xl', className)}>
{beforeHeader || null} {beforeHeader || null}
<div className="mb-1 flex h-6 items-center justify-between"> <div className="mb-1 flex h-6 items-center justify-between">
<div className="system-xl-semibold text-text-primary">{title}</div> <div className="text-text-primary system-xl-semibold">{title}</div>
{!hideCloseBtn && ( {!hideCloseBtn && (
<div <div
className="cursor-pointer p-1.5 text-text-tertiary" className="cursor-pointer p-1.5 text-text-tertiary"
onClick={onClose} onClick={onClose}
> >
<RiCloseLine className="size-4" /> <span className="i-ri-close-line size-4" data-testid="modal-close-btn" />
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,185 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Modal from '.'
describe('Modal', () => {
describe('Render', () => {
it('should not render content when isShow is false', () => {
render(
<Modal isShow={false} title="Test Modal">
<div>Modal Content</div>
</Modal>,
)
expect(screen.queryByText('Test Modal')).not.toBeInTheDocument()
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument()
})
it('should render content when isShow is true', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal">
<div>Modal Content</div>
</Modal>,
)
})
expect(screen.getByText('Test Modal')).toBeInTheDocument()
expect(screen.getByText('Modal Content')).toBeInTheDocument()
})
it('should render description when provided', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" description="Test Description">
<div>Content</div>
</Modal>,
)
})
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
})
describe('Interaction', () => {
it('should call onClose when close button is clicked', async () => {
const handleClose = vi.fn()
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" closable={true} onClose={handleClose}>
<div>Content</div>
</Modal>,
)
})
const closeButton = screen.getByTestId('modal-close-button')
expect(closeButton).toBeInTheDocument()
await act(async () => {
fireEvent.click(closeButton!)
})
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should prevent propagation when clicking the scrollable container', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal">
<div>Content</div>
</Modal>,
)
})
const wrapper = document.querySelector('.overflow-y-auto')
expect(wrapper).toBeInTheDocument()
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
await act(async () => {
wrapper!.dispatchEvent(event)
})
expect(stopPropagationSpy).toHaveBeenCalled()
expect(preventDefaultSpy).toHaveBeenCalled()
})
it('should handle clickOutsideNotClose prop', async () => {
const handleClose = vi.fn()
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" clickOutsideNotClose={true} onClose={handleClose}>
<div>Content</div>
</Modal>,
)
})
await act(async () => {
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' })
})
expect(handleClose).not.toHaveBeenCalled()
})
})
describe('Props', () => {
it('should apply custom className to the panel', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" className="custom-panel-class">
<div>Content</div>
</Modal>,
)
})
const panel = screen.getByText('Test Modal').parentElement
expect(panel).toHaveClass('custom-panel-class')
})
it('should apply wrapperClassName and containerClassName', async () => {
await act(async () => {
render(
<Modal
isShow={true}
title="Test Modal"
wrapperClassName="custom-wrapper"
containerClassName="custom-container"
>
<div>Content</div>
</Modal>,
)
})
const dialog = document.querySelector('.custom-wrapper')
expect(dialog).toBeInTheDocument()
const container = document.querySelector('.custom-container')
expect(container).toBeInTheDocument()
})
it('should apply highPriority z-index when highPriority is true', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" highPriority={true}>
<div>Content</div>
</Modal>,
)
})
const dialog = document.querySelector('.z-\\[1100\\]')
expect(dialog).toBeInTheDocument()
})
it('should apply overlayOpacity background when overlayOpacity is true', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" overlayOpacity={true}>
<div>Content</div>
</Modal>,
)
})
const overlay = document.querySelector('.bg-workflow-canvas-canvas-overlay')
expect(overlay).toBeInTheDocument()
})
it('should toggle overflow-visible class based on overflowVisible prop', async () => {
const { rerender } = render(
<Modal isShow={true} title="Test Modal" overflowVisible={true}>
<div>Content</div>
</Modal>,
)
let panel = screen.getByText('Test Modal').parentElement
expect(panel).toHaveClass('overflow-visible')
await act(async () => {
rerender(
<Modal isShow={true} title="Test Modal" overflowVisible={false}>
<div>Content</div>
</Modal>,
)
})
panel = screen.getByText('Test Modal').parentElement
expect(panel).toHaveClass('overflow-hidden')
})
})
})

View File

@@ -1,5 +1,4 @@
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react' import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { Fragment } from 'react' import { Fragment } from 'react'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@@ -55,27 +54,28 @@ export default function Modal({
{!!title && ( {!!title && (
<DialogTitle <DialogTitle
as="h3" as="h3"
className="title-2xl-semi-bold text-text-primary" className="text-text-primary title-2xl-semi-bold"
> >
{title} {title}
</DialogTitle> </DialogTitle>
)} )}
{!!description && ( {!!description && (
<div className="body-md-regular mt-2 text-text-secondary"> <div className="mt-2 text-text-secondary body-md-regular">
{description} {description}
</div> </div>
)} )}
{closable {closable
&& ( && (
<div className="absolute right-6 top-6 z-10 flex h-5 w-5 items-center justify-center rounded-2xl hover:cursor-pointer hover:bg-state-base-hover"> <div className="absolute right-6 top-6 z-10 flex h-5 w-5 items-center justify-center rounded-2xl hover:cursor-pointer hover:bg-state-base-hover">
<RiCloseLine <span
className="h-4 w-4 text-text-tertiary" className="i-ri-close-line h-4 w-4 text-text-tertiary"
onClick={ onClick={
(e) => { (e) => {
e.stopPropagation() e.stopPropagation()
onClose() onClose()
} }
} }
data-testid="modal-close-button"
/> />
</div> </div>
)} )}

View File

@@ -0,0 +1,114 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Modal from './modal'
describe('Modal Component', () => {
const defaultProps = {
title: 'Test Modal',
onClose: vi.fn(),
onConfirm: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Render', () => {
it('renders correctly with title and children', () => {
render(
<Modal {...defaultProps}>
<div data-testid="modal-child">Child Content</div>
</Modal>,
)
expect(screen.getByText('Test Modal')).toBeInTheDocument()
expect(screen.getByTestId('modal-child')).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
expect(screen.getByText(/save/i)).toBeInTheDocument()
})
it('renders subTitle when provided', () => {
render(<Modal {...defaultProps} subTitle="Test Subtitle" />)
expect(screen.getByText('Test Subtitle')).toBeInTheDocument()
})
it('renders and handles extra button', () => {
const onExtraClick = vi.fn()
render(
<Modal
{...defaultProps}
showExtraButton={true}
extraButtonText="Extra Action"
onExtraButtonClick={onExtraClick}
/>,
)
const extraBtn = screen.getByText('Extra Action')
expect(extraBtn).toBeInTheDocument()
fireEvent.click(extraBtn)
expect(onExtraClick).toHaveBeenCalledTimes(1)
})
it('renders footerSlot and bottomSlot', () => {
render(
<Modal
{...defaultProps}
footerSlot={<div data-testid="footer-slot">Footer</div>}
bottomSlot={<div data-testid="bottom-slot">Bottom</div>}
/>,
)
expect(screen.getByTestId('footer-slot')).toBeInTheDocument()
expect(screen.getByTestId('bottom-slot')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls onClose when close icon is clicked', () => {
render(<Modal {...defaultProps} />)
const closeIcon = screen.getByTestId('close-icon').parentElement
fireEvent.click(closeIcon!)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('calls onConfirm when confirm button is clicked', () => {
render(<Modal {...defaultProps} confirmButtonText="Confirm Me" />)
fireEvent.click(screen.getByText(/confirm/i))
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onCancel when cancel button is clicked', () => {
render(<Modal {...defaultProps} cancelButtonText="Cancel Me" />)
fireEvent.click(screen.getByText('Cancel Me'))
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it('handles clickOutsideNotClose logic', () => {
const onClose = vi.fn()
const { rerender } = render(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={false} />)
fireEvent.click(screen.getByRole('tooltip'))
expect(onClose).toHaveBeenCalledTimes(1)
onClose.mockClear()
rerender(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={true} />)
fireEvent.click(screen.getByRole('tooltip'))
expect(onClose).not.toHaveBeenCalled()
})
it('prevents propagation on internal container click', () => {
const onClose = vi.fn()
render(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={false} />)
fireEvent.click(screen.getByText('Test Modal'))
expect(onClose).not.toHaveBeenCalled()
})
})
describe('Props', () => {
it('disables buttons when disabled prop is true', () => {
render(<Modal {...defaultProps} disabled={true} />)
expect(screen.getByText(/cancel/i).closest('button')).toBeDisabled()
expect(screen.getByText(/save/i).closest('button')).toBeDisabled()
})
})
})

View File

@@ -1,5 +1,4 @@
import type { ButtonProps } from '@/app/components/base/button' import type { ButtonProps } from '@/app/components/base/button'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { memo } from 'react' import { memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -69,11 +68,11 @@ const Modal = ({
)} )}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div className="title-2xl-semi-bold relative shrink-0 p-6 pb-3 pr-14 text-text-primary"> <div className="relative shrink-0 p-6 pb-3 pr-14 text-text-primary title-2xl-semi-bold">
{title} {title}
{ {
subTitle && ( subTitle && (
<div className="system-xs-regular mt-1 text-text-tertiary"> <div className="mt-1 text-text-tertiary system-xs-regular">
{subTitle} {subTitle}
</div> </div>
) )
@@ -82,7 +81,7 @@ const Modal = ({
className="absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" className="absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={onClose} onClick={onClose}
> >
<RiCloseLine className="h-5 w-5 text-text-tertiary" /> <span className="i-ri-close-line h-5 w-5 text-text-tertiary" data-testid="close-icon" />
</div> </div>
</div> </div>
{ {

View File

@@ -0,0 +1,238 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CustomPopover from '.'
const CloseButtonContent = ({ onClick }: { onClick?: () => void }) => (
<button data-testid="content" onClick={onClick}>Close Me</button>
)
describe('CustomPopover', () => {
const defaultProps = {
btnElement: <span data-testid="trigger">Trigger</span>,
htmlContent: <div data-testid="content">Popover Content</div>,
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
if (vi.isFakeTimers?.())
vi.clearAllTimers()
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render the trigger element', () => {
render(<CustomPopover {...defaultProps} />)
expect(screen.getByTestId('trigger')).toBeInTheDocument()
})
it('should render string as htmlContent', async () => {
render(<CustomPopover {...defaultProps} htmlContent="String Content" trigger="click" />)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(screen.getByText('String Content')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should toggle when clicking the button', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<CustomPopover {...defaultProps} trigger="click" />)
const trigger = screen.getByTestId('trigger')
await user.click(trigger)
expect(screen.getByTestId('content')).toBeInTheDocument()
await user.click(trigger)
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
})
it('should open on hover when trigger is "hover" (default)', async () => {
render(<CustomPopover {...defaultProps} />)
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
const triggerContainer = screen.getByTestId('trigger').closest('div')
if (!triggerContainer)
throw new Error('Trigger container not found')
await act(async () => {
fireEvent.mouseEnter(triggerContainer)
})
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should close after delay on mouse leave when trigger is "hover"', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<CustomPopover {...defaultProps} />)
const trigger = screen.getByTestId('trigger')
await user.hover(trigger)
expect(screen.getByTestId('content')).toBeInTheDocument()
await user.unhover(trigger)
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
}, { timeout: 2000 })
})
it('should stay open when hovering over the popover content', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<CustomPopover {...defaultProps} />)
const trigger = screen.getByTestId('trigger')
await user.hover(trigger)
expect(screen.getByTestId('content')).toBeInTheDocument()
// Leave trigger but enter content
await user.unhover(trigger)
const content = screen.getByTestId('content')
await user.hover(content)
// Wait for the timeout duration
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 200))
})
// Should still be open because we are hovering the content
expect(screen.getByTestId('content')).toBeInTheDocument()
// Now leave content
await user.unhover(content)
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
}, { timeout: 2000 })
})
it('should cancel close timeout when re-entering during hover delay', async () => {
render(<CustomPopover {...defaultProps} />)
const triggerContainer = screen.getByTestId('trigger').closest('div')
if (!triggerContainer)
throw new Error('Trigger container not found')
await act(async () => {
fireEvent.mouseEnter(triggerContainer)
})
await act(async () => {
fireEvent.mouseLeave(triggerContainer!)
})
await act(async () => {
vi.advanceTimersByTime(50) // Halfway through timeout
fireEvent.mouseEnter(triggerContainer!)
})
await act(async () => {
vi.advanceTimersByTime(1000) // Much longer than the original timeout
})
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should not open when disabled', async () => {
render(<CustomPopover {...defaultProps} disabled={true} trigger="click" />)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
it('should pass close function to htmlContent when manualClose is true', async () => {
vi.useRealTimers()
render(
<CustomPopover
{...defaultProps}
htmlContent={<CloseButtonContent />}
trigger="click"
manualClose={true}
/>,
)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(screen.getByTestId('content')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('content'))
})
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
})
it('should not close when mouse leaves while already closed', async () => {
render(<CustomPopover {...defaultProps} />)
const triggerContainer = screen.getByTestId('trigger').closest('div')
if (!triggerContainer)
throw new Error('Trigger container not found')
await act(async () => {
fireEvent.mouseLeave(triggerContainer)
})
await act(async () => {
vi.runAllTimers()
})
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom class names', async () => {
render(
<CustomPopover
{...defaultProps}
trigger="click"
className="wrapper-class"
popupClassName="popup-inner-class"
btnClassName="btn-class"
/>,
)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(document.querySelector('.wrapper-class')).toBeInTheDocument()
expect(document.querySelector('.popup-inner-class')).toBeInTheDocument()
const button = screen.getByTestId('trigger').parentElement
expect(button).toHaveClass('btn-class')
})
it('should handle btnClassName as a function', () => {
render(
<CustomPopover
{...defaultProps}
btnClassName={open => open ? 'btn-open' : 'btn-closed'}
/>,
)
const button = screen.getByTestId('trigger').parentElement
expect(button).toHaveClass('btn-closed')
})
})
})

View File

@@ -0,0 +1,89 @@
import { render } from '@testing-library/react'
import ProgressCircle from './progress-circle'
const extractLargeArcFlag = (pathData: string): string => {
const afterA = pathData.slice(pathData.indexOf('A') + 1)
const tokens = afterA.replace(/,/g, ' ').trim().split(/\s+/)
// Arc syntax: A rx ry x-axis-rotation large-arc-flag sweep-flag x y
return tokens[3]
}
describe('ProgressCircle', () => {
describe('Render', () => {
it('renders an SVG with default props', () => {
const { container } = render(<ProgressCircle />)
const svg = container.querySelector('svg')
const circle = container.querySelector('circle')
const path = container.querySelector('path')
expect(svg).toBeInTheDocument()
expect(circle).toBeInTheDocument()
expect(path).toBeInTheDocument()
})
})
describe('Props', () => {
it('applies correct size and viewBox when size is provided', () => {
const size = 24
const strokeWidth = 2
const { container } = render(
<ProgressCircle size={size} circleStrokeWidth={strokeWidth} />,
)
const svg = container.querySelector('svg') as SVGElement
expect(svg).toHaveAttribute('width', String(size + strokeWidth))
expect(svg).toHaveAttribute('height', String(size + strokeWidth))
expect(svg).toHaveAttribute(
'viewBox',
`0 0 ${size + strokeWidth} ${size + strokeWidth}`,
)
})
it('applies custom stroke and fill classes to the circle', () => {
const { container } = render(
<ProgressCircle
circleStrokeColor="stroke-red-500"
circleFillColor="fill-red-100"
/>,
)
const circle = container.querySelector('circle')!
expect(circle!).toHaveClass('stroke-red-500')
expect(circle!).toHaveClass('fill-red-100')
})
it('applies custom sector fill color to the path', () => {
const { container } = render(
<ProgressCircle sectorFillColor="fill-blue-500" />,
)
const path = container.querySelector('path')!
expect(path!).toHaveClass('fill-blue-500')
})
it('uses large arc flag when percentage is greater than 50', () => {
const { container } = render(<ProgressCircle percentage={75} />)
const path = container.querySelector('path')!
const d = path.getAttribute('d') || ''
expect(d).toContain('A')
expect(extractLargeArcFlag(d)).toBe('1')
})
it('uses small arc flag when percentage is 50 or less', () => {
const { container } = render(<ProgressCircle percentage={25} />)
const path = container.querySelector('path')!
const d = path.getAttribute('d') || ''
expect(d).toContain('A')
expect(extractLargeArcFlag(d)).toBe('0')
})
it('uses small arc flag when percentage is exactly 50', () => {
const { container } = render(<ProgressCircle percentage={50} />)
const path = container.querySelector('path')!
const d = path.getAttribute('d') || ''
expect(d).toContain('A')
expect(extractLargeArcFlag(d)).toBe('0')
})
})
})

View File

@@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react'
import Card from './card'
describe('PromptLogModal Card', () => {
it('renders single log entry correctly', () => {
const log = [{ role: 'user', text: 'Single entry text' }]
render(<Card log={log} />)
expect(screen.getByText('Single entry text')).toBeInTheDocument()
expect(screen.queryByText('USER')).not.toBeInTheDocument()
})
it('renders multiple log entries correctly', () => {
const log = [
{ role: 'user', text: 'Message 1' },
{ role: 'assistant', text: 'Message 2' },
]
render(<Card log={log} />)
expect(screen.getByText('USER')).toBeInTheDocument()
expect(screen.getByText('ASSISTANT')).toBeInTheDocument()
expect(screen.getByText('Message 1')).toBeInTheDocument()
expect(screen.getByText('Message 2')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,60 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PromptLogModal from '.'
describe('PromptLogModal', () => {
const defaultProps = {
width: 1000,
onCancel: vi.fn(),
currentLogItem: {
id: '1',
content: 'test',
log: [{ role: 'user', text: 'Hello' }],
} as Parameters<typeof PromptLogModal>[0]['currentLogItem'],
}
describe('Render', () => {
it('renders correctly when currentLogItem is provided', () => {
render(<PromptLogModal {...defaultProps} />)
expect(screen.getByText('PROMPT LOG')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
})
it('returns null when currentLogItem is missing', () => {
const { container } = render(<PromptLogModal {...defaultProps} currentLogItem={undefined} />)
expect(container.firstChild).toBeNull()
})
it('renders copy feedback when log length is 1', () => {
render(<PromptLogModal {...defaultProps} />)
expect(screen.getByTestId('close-btn-container')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls onCancel when close button is clicked', () => {
render(<PromptLogModal {...defaultProps} />)
const closeBtn = screen.getByTestId('close-btn')
expect(closeBtn).toBeInTheDocument()
fireEvent.click(closeBtn)
expect(defaultProps.onCancel).toHaveBeenCalled()
})
it('calls onCancel when clicking outside', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<div>
<div data-testid="outside">Outside</div>
<PromptLogModal {...defaultProps} onCancel={onCancel} />
</div>,
)
await waitFor(() => {
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
})
await user.click(screen.getByTestId('outside'))
})
})
})

View File

@@ -1,6 +1,5 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { RiCloseLine } from '@remixicon/react'
import { useClickAway } from 'ahooks' import { useClickAway } from 'ahooks'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { CopyFeedbackNew } from '@/app/components/base/copy-feedback' import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
@@ -57,8 +56,9 @@ const PromptLogModal: FC<PromptLogModalProps> = ({
<div <div
onClick={onCancel} onClick={onCancel}
className="flex h-6 w-6 cursor-pointer items-center justify-center" className="flex h-6 w-6 cursor-pointer items-center justify-center"
data-testid="close-btn-container"
> >
<RiCloseLine className="h-4 w-4 text-text-tertiary" /> <span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-btn" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { downloadUrl } from '@/utils/download'
import ShareQRCode from '.'
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
describe('ShareQRCode', () => {
const content = 'https://example.com'
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('renders correctly', () => {
render(<ShareQRCode content={content} />)
expect(screen.getByRole('button').firstElementChild).toBeInTheDocument()
})
})
describe('Interaction', () => {
it('toggles QR code panel when clicking the icon', async () => {
const user = userEvent.setup()
render(<ShareQRCode content={content} />)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
const trigger = screen.getByTestId('qrcode-container')
await user.click(trigger)
expect(screen.getByRole('img')).toBeInTheDocument()
await user.click(trigger)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('closes panel when clicking outside', async () => {
const user = userEvent.setup()
render(
<div>
<div data-testid="outside">Outside</div>
<ShareQRCode content={content} />
</div>,
)
const trigger = screen.getByTestId('qrcode-container')
await user.click(trigger)
expect(screen.getByRole('img')).toBeInTheDocument()
await user.click(screen.getByTestId('outside'))
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('does not close panel when clicking inside the panel', async () => {
const user = userEvent.setup()
render(<ShareQRCode content={content} />)
const trigger = screen.getByTestId('qrcode-container')
await user.click(trigger)
const canvas = screen.getByRole('img')
const panel = canvas.parentElement
await user.click(panel!)
expect(canvas).toBeInTheDocument()
})
it('calls downloadUrl when clicking download', async () => {
const user = userEvent.setup()
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL
HTMLCanvasElement.prototype.toDataURL = vi.fn(() => 'data:image/png;base64,test')
try {
render(<ShareQRCode content={content} />)
const trigger = screen.getByTestId('qrcode-container')
await user.click(trigger!)
const downloadBtn = screen.getByText('appOverview.overview.appInfo.qrcode.download')
await user.click(downloadBtn)
expect(downloadUrl).toHaveBeenCalledWith({
url: 'data:image/png;base64,test',
fileName: 'qrcode.png',
})
}
finally {
HTMLCanvasElement.prototype.toDataURL = originalToDataURL
}
})
})
})

View File

@@ -1,7 +1,4 @@
'use client' 'use client'
import {
RiQrCodeLine,
} from '@remixicon/react'
import { QRCodeCanvas as QRCode } from 'qrcode.react' import { QRCodeCanvas as QRCode } from 'qrcode.react'
import * as React from 'react' import * as React from 'react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@@ -55,9 +52,9 @@ const ShareQRCode = ({ content }: Props) => {
<Tooltip <Tooltip
popupContent={t(`${prefixEmbedded}`, { ns: 'appOverview' }) || ''} popupContent={t(`${prefixEmbedded}`, { ns: 'appOverview' }) || ''}
> >
<div className="relative h-6 w-6" onClick={toggleQRCode}> <div className="relative h-6 w-6" onClick={toggleQRCode} data-testid="qrcode-container">
<ActionButton> <ActionButton>
<RiQrCodeLine className="h-4 w-4" /> <span className="i-ri-qr-code-line h-4 w-4" />
</ActionButton> </ActionButton>
{isShow && ( {isShow && (
<div <div
@@ -66,7 +63,7 @@ const ShareQRCode = ({ content }: Props) => {
onClick={handlePanelClick} onClick={handlePanelClick}
> >
<QRCode size={160} value={content} className="mb-2" /> <QRCode size={160} value={content} className="mb-2" />
<div className="system-xs-regular flex items-center"> <div className="flex items-center system-xs-regular">
<div className="text-text-tertiary">{t('overview.appInfo.qrcode.scan', { ns: 'appOverview' })}</div> <div className="text-text-tertiary">{t('overview.appInfo.qrcode.scan', { ns: 'appOverview' })}</div>
<div className="text-text-tertiary">·</div> <div className="text-text-tertiary">·</div>
<div className="cursor-pointer text-text-accent-secondary" onClick={downloadQR}>{t('overview.appInfo.qrcode.download', { ns: 'appOverview' })}</div> <div className="cursor-pointer text-text-accent-secondary" onClick={downloadQR}>{t('overview.appInfo.qrcode.download', { ns: 'appOverview' })}</div>

View File

@@ -0,0 +1,44 @@
import { render } from '@testing-library/react'
import SimplePieChart from '.'
describe('SimplePieChart', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<SimplePieChart />)
const chart = container.querySelector('.echarts-for-react')
expect(chart).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<SimplePieChart className="custom-chart" />)
const chart = container.querySelector('.echarts-for-react')
expect(chart).toHaveClass('custom-chart')
})
it('should apply custom size via style', () => {
const { container } = render(<SimplePieChart size={24} />)
const chart = container.querySelector('.echarts-for-react') as HTMLElement
expect(chart).toHaveStyle({ width: '24px', height: '24px' })
})
it('should apply default size of 12', () => {
const { container } = render(<SimplePieChart />)
const chart = container.querySelector('.echarts-for-react') as HTMLElement
expect(chart).toHaveStyle({ width: '12px', height: '12px' })
})
it('should set custom fill color as CSS variable', () => {
const { container } = render(<SimplePieChart fill="red" />)
const chart = container.querySelector('.echarts-for-react') as HTMLElement
expect(chart.style.getPropertyValue('--simple-pie-chart-color')).toBe('red')
})
it('should set default fill color as CSS variable', () => {
const { container } = render(<SimplePieChart />)
const chart = container.querySelector('.echarts-for-react') as HTMLElement
expect(chart.style.getPropertyValue('--simple-pie-chart-color')).toBe('#fdb022')
})
})
})

View File

@@ -0,0 +1,137 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import SVGRenderer from '.'
const mockClick = vi.fn()
const mockSvg = vi.fn().mockReturnValue({
click: mockClick,
})
const mockViewbox = vi.fn()
const mockAddTo = vi.fn()
vi.mock('@svgdotjs/svg.js', () => ({
SVG: vi.fn().mockImplementation(() => ({
addTo: mockAddTo,
})),
}))
vi.mock('dompurify', () => ({
default: {
sanitize: vi.fn(content => content),
},
}))
describe('SVGRenderer', () => {
const validSvg = '<svg width="100" height="100"><circle cx="50" cy="50" r="40" /></svg>'
let parseFromStringSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
mockAddTo.mockReturnValue({
viewbox: mockViewbox,
svg: mockSvg,
})
mockSvg.mockReturnValue({
click: mockClick,
})
const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
mockSvgElement.setAttribute('width', '100')
mockSvgElement.setAttribute('height', '100')
parseFromStringSpy = vi.spyOn(DOMParser.prototype, 'parseFromString').mockReturnValue({
documentElement: mockSvgElement,
} as unknown as Document)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Rendering', () => {
it('renders correctly with content', async () => {
render(<SVGRenderer content={validSvg} />)
await waitFor(() => {
expect(mockViewbox).toHaveBeenCalledWith(0, 0, 100, 100)
})
expect(mockSvg).toHaveBeenCalledWith(validSvg)
})
it('shows error message on invalid SVG content', async () => {
parseFromStringSpy.mockReturnValue({
documentElement: document.createElement('div'),
} as unknown as Document)
render(<SVGRenderer content="invalid" />)
await waitFor(() => {
expect(screen.getByText(/Error rendering SVG/)).toBeInTheDocument()
})
})
it('re-renders on window resize', async () => {
render(<SVGRenderer content={validSvg} />)
await waitFor(() => {
expect(mockAddTo).toHaveBeenCalledTimes(1)
})
await act(async () => {
window.dispatchEvent(new Event('resize'))
})
await waitFor(() => {
expect(mockAddTo).toHaveBeenCalledTimes(2)
})
})
it('uses default values for width/height if not present', async () => {
const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
parseFromStringSpy.mockReturnValue({
documentElement: mockSvgElement,
} as unknown as Document)
render(<SVGRenderer content="<svg></svg>" />)
await waitFor(() => {
expect(mockViewbox).toHaveBeenCalledWith(0, 0, 400, 600)
})
})
})
describe('Image Preview Interactions', () => {
it('opens image preview on click', async () => {
render(<SVGRenderer content={validSvg} />)
await waitFor(() => {
expect(mockClick).toHaveBeenCalled()
})
const clickHandler = mockClick.mock.calls[0][0]
await act(async () => {
clickHandler()
})
const img = screen.getByAltText('Preview')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute(
'src',
expect.stringContaining('data:image/svg+xml;base64'),
)
})
it('closes image preview on cancel', async () => {
render(<SVGRenderer content={validSvg} />)
await waitFor(() => {
expect(mockClick).toHaveBeenCalled()
})
const clickHandler = mockClick.mock.calls[0][0]
await act(async () => {
clickHandler()
})
expect(screen.getByAltText('Preview')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByAltText('Preview')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,44 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SVGBtn from '.'
describe('SVGBtn', () => {
describe('Rendering', () => {
it('renders correctly', () => {
const setIsSVG = vi.fn()
render(<SVGBtn isSVG={false} setIsSVG={setIsSVG} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls setIsSVG with a toggle function when clicked', () => {
const setIsSVG = vi.fn()
render(<SVGBtn isSVG={false} setIsSVG={setIsSVG} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(setIsSVG).toHaveBeenCalledTimes(1)
const toggleFunc = setIsSVG.mock.calls[0][0]
expect(typeof toggleFunc).toBe('function')
expect(toggleFunc(false)).toBe(true)
expect(toggleFunc(true)).toBe(false)
})
})
describe('Props', () => {
it('applies correct class when isSVG is false', () => {
const setIsSVG = vi.fn()
render(<SVGBtn isSVG={false} setIsSVG={setIsSVG} />)
const icon = screen.getByRole('button').firstChild as HTMLElement
expect(icon?.className).toMatch(/_svgIcon_\w+/)
})
it('applies correct class when isSVG is true', () => {
const setIsSVG = vi.fn()
render(<SVGBtn isSVG={true} setIsSVG={setIsSVG} />)
const icon = screen.getByRole('button').firstChild as HTMLElement
expect(icon?.className).toMatch(/_svgIconed_\w+/)
})
})
})

View File

@@ -0,0 +1,131 @@
import type { LangGeniusVersionResponse } from '@/models/common'
import type { SystemFeatures } from '@/types/feature'
import { fireEvent, render, screen } from '@testing-library/react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import AccountAbout from './index'
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
let mockIsCEEdition = false
vi.mock('@/config', () => ({
get IS_CE_EDITION() { return mockIsCEEdition },
}))
type GlobalPublicStore = {
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
}
describe('AccountAbout', () => {
const mockVersionInfo: LangGeniusVersionResponse = {
current_version: '0.6.0',
latest_version: '0.6.0',
release_notes: 'https://github.com/langgenius/dify/releases/tag/0.6.0',
version: '0.6.0',
release_date: '2024-01-01',
can_auto_update: false,
current_env: 'production',
}
const mockOnCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockIsCEEdition = false
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: false } },
} as unknown as GlobalPublicStore))
})
describe('Rendering', () => {
it('should render correctly with version information', () => {
// Act
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
// Assert
expect(screen.getByText(/^Version/)).toBeInTheDocument()
expect(screen.getAllByText(/0.6.0/).length).toBeGreaterThan(0)
})
it('should render branding logo if enabled', () => {
// Arrange
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: true, workspace_logo: 'custom-logo.png' } },
} as unknown as GlobalPublicStore))
// Act
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
// Assert
const img = screen.getByAltText('logo')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'custom-logo.png')
})
})
describe('Version Logic', () => {
it('should show "Latest Available" when current version equals latest', () => {
// Act
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
// Assert
expect(screen.getByText(/about.latestAvailable/)).toBeInTheDocument()
})
it('should show "Now Available" when current version is behind', () => {
// Arrange
const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
// Act
render(<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />)
// Assert
expect(screen.getByText(/about.nowAvailable/)).toBeInTheDocument()
expect(screen.getByText(/about.updateNow/)).toBeInTheDocument()
})
})
describe('Community Edition', () => {
it('should render correctly in Community Edition', () => {
// Arrange
mockIsCEEdition = true
// Act
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
// Assert
expect(screen.getByText(/Open Source License/)).toBeInTheDocument()
})
it('should hide update button in Community Edition when behind version', () => {
// Arrange
mockIsCEEdition = true
const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
// Act
render(<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />)
// Assert
expect(screen.queryByText(/about.updateNow/)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => {
// Act
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
// Modal uses Headless UI Dialog which renders into a portal, so we need to use document
const closeButton = document.querySelector('div.absolute.cursor-pointer')
if (!closeButton)
throw new Error('Close button not found')
fireEvent.click(closeButton)
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,218 @@
import type { ModalContextState } from '@/context/modal-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
import { downloadUrl } from '@/utils/download'
import Toast from '../../base/toast'
import Compliance from './compliance'
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>()
return {
...actual,
useModalContext: vi.fn(),
}
})
vi.mock('@/service/common', () => ({
getDocDownloadUrl: vi.fn(),
}))
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
describe('Compliance', () => {
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
let queryClient: QueryClient
beforeEach(() => {
vi.clearAllMocks()
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
},
})
vi.mocked(useModalContext).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
})
const renderWithQueryClient = (ui: React.ReactElement) => {
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Wrapper for tests that need the menu open
const openMenuAndRender = () => {
renderWithQueryClient(<Compliance />)
fireEvent.click(screen.getByRole('button'))
}
describe('Rendering', () => {
it('should render compliance menu trigger', () => {
// Act
renderWithQueryClient(<Compliance />)
// Assert
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
})
it('should show SOC2, ISO, GDPR items when opened', () => {
// Act
openMenuAndRender()
// Assert
expect(screen.getByText('common.compliance.soc2Type1')).toBeInTheDocument()
expect(screen.getByText('common.compliance.soc2Type2')).toBeInTheDocument()
expect(screen.getByText('common.compliance.iso27001')).toBeInTheDocument()
expect(screen.getByText('common.compliance.gdpr')).toBeInTheDocument()
})
})
describe('Plan-based Content', () => {
it('should show Upgrade badge for sandbox plan on restricted docs', () => {
// Act
openMenuAndRender()
// Assert
// SOC2 Type I is restricted for sandbox
expect(screen.getAllByText('billing.upgradeBtn.encourageShort').length).toBeGreaterThan(0)
})
it('should show Download button for plan that allows it', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.team,
},
})
// Act
openMenuAndRender()
// Assert
expect(screen.getAllByText('common.operation.download').length).toBeGreaterThan(0)
})
})
describe('Actions', () => {
it('should trigger download mutation successfully', async () => {
// Arrange
const mockUrl = 'http://example.com/doc.pdf'
vi.mocked(getDocDownloadUrl).mockResolvedValue({ url: mockUrl })
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
await waitFor(() => {
expect(getDocDownloadUrl).toHaveBeenCalled()
expect(downloadUrl).toHaveBeenCalledWith({ url: mockUrl })
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.operation.downloadSuccess',
}))
})
})
it('should handle download mutation error', async () => {
// Arrange
vi.mocked(getDocDownloadUrl).mockRejectedValue(new Error('Download failed'))
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.team,
},
})
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
// Act
openMenuAndRender()
const downloadButtons = screen.getAllByText('common.operation.download')
fireEvent.click(downloadButtons[0])
// Assert
await waitFor(() => {
expect(getDocDownloadUrl).toHaveBeenCalled()
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'common.operation.downloadFailed',
}))
})
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('should handle upgrade click on badge for sandbox plan', () => {
// Act
openMenuAndRender()
const upgradeBadges = screen.getAllByText('billing.upgradeBtn.encourageShort')
fireEvent.click(upgradeBadges[0])
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalled()
})
it('should handle upgrade click on badge for non-sandbox plan', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.professional,
},
})
// Act
openMenuAndRender()
// SOC2 Type II is restricted for professional
const upgradeBadges = screen.getAllByText('billing.upgradeBtn.encourageShort')
fireEvent.click(upgradeBadges[0])
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.BILLING,
})
})
})
})

View File

@@ -0,0 +1,340 @@
import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'
import type { AppContextValue } from '@/context/app-context'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common'
import AppSelector from './index'
vi.mock('../account-setting', () => ({
default: () => <div data-testid="account-setting">AccountSetting</div>,
}))
vi.mock('../account-about', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="account-about">
Version
<button onClick={onCancel}>Close</button>
</div>
),
}))
vi.mock('@/app/components/header/github-star', () => ({
default: () => <div data-testid="github-star">GithubStar</div>,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useLogout: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
// Mock config and env
const { mockConfig, mockEnv } = vi.hoisted(() => ({
mockConfig: {
IS_CLOUD_EDITION: false,
},
mockEnv: {
env: {
NEXT_PUBLIC_SITE_ABOUT: 'show',
},
},
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
IS_DEV: false,
IS_CE_EDITION: false,
}))
vi.mock('@/env', () => mockEnv)
const baseAppContextValue: AppContextValue = {
userProfile: {
id: '1',
name: 'Test User',
email: 'test@example.com',
avatar: '',
avatar_url: 'avatar.png',
is_password_set: false,
},
mutateUserProfile: vi.fn(),
currentWorkspace: {
id: '1',
name: 'Workspace',
plan: '',
status: '',
created_at: 0,
role: 'owner',
providers: [],
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: {
current_env: 'testing',
current_version: '0.6.0',
latest_version: '0.6.0',
release_date: '',
release_notes: '',
version: '0.6.0',
can_auto_update: false,
},
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
describe('AccountDropdown', () => {
const mockPush = vi.fn()
const mockLogout = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const renderWithRouter = (ui: React.ReactElement) => {
const mockRouter = {
push: mockPush,
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
} as unknown as AppRouterInstance
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<AppRouterContext.Provider value={mockRouter}>
{ui}
</AppRouterContext.Provider>
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('localStorage', { removeItem: vi.fn() })
mockConfig.IS_CLOUD_EDITION = false
mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show'
vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
vi.mocked(useGlobalPublicStore).mockImplementation((selector?: unknown) => {
const fullState = { systemFeatures: { branding: { enabled: false } }, setSystemFeatures: vi.fn() }
return typeof selector === 'function' ? (selector as (state: typeof fullState) => unknown)(fullState) : fullState
})
vi.mocked(useProviderContext).mockReturnValue({
isEducationAccount: false,
plan: { type: Plan.sandbox },
} as unknown as ProviderContextState)
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
vi.mocked(useLogout).mockReturnValue({
mutateAsync: mockLogout,
} as unknown as ReturnType<typeof useLogout>)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Rendering', () => {
it('should render user profile correctly', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('Test User')).toBeInTheDocument()
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('should show EDU badge for education accounts', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
isEducationAccount: true,
plan: { type: Plan.sandbox },
} as unknown as ProviderContextState)
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('EDU')).toBeInTheDocument()
})
})
describe('Settings and Support', () => {
it('should trigger setShowAccountSettingModal when settings is clicked', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.settings'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
})
it('should show Compliance in Cloud Edition for workspace owner', () => {
// Arrange
mockConfig.IS_CLOUD_EDITION = true
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
userProfile: { ...baseAppContextValue.userProfile, name: 'User' },
isCurrentWorkspaceOwner: true,
langGeniusVersionInfo: { ...baseAppContextValue.langGeniusVersionInfo, current_version: '0.6.0', latest_version: '0.6.0' },
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
})
})
describe('Actions', () => {
it('should handle logout correctly', async () => {
// Arrange
mockLogout.mockResolvedValue({})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.logout'))
// Assert
await waitFor(() => {
expect(mockLogout).toHaveBeenCalled()
expect(localStorage.removeItem).toHaveBeenCalledWith('setup_status')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})
it('should show About section when about button is clicked and can close it', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.about'))
// Assert
expect(screen.getByTestId('account-about')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByText('Close'))
// Assert
expect(screen.queryByTestId('account-about')).not.toBeInTheDocument()
})
})
describe('Branding and Environment', () => {
it('should hide sections when branding is enabled', () => {
// Arrange
vi.mocked(useGlobalPublicStore).mockImplementation((selector?: unknown) => {
const fullState = { systemFeatures: { branding: { enabled: true } }, setSystemFeatures: vi.fn() }
return typeof selector === 'function' ? (selector as (state: typeof fullState) => unknown)(fullState) : fullState
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.queryByText('common.userProfile.helpCenter')).not.toBeInTheDocument()
expect(screen.queryByText('common.userProfile.roadmap')).not.toBeInTheDocument()
})
it('should hide About section when NEXT_PUBLIC_SITE_ABOUT is hide', () => {
// Arrange
mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'hide'
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.queryByText('common.userProfile.about')).not.toBeInTheDocument()
})
})
describe('Version Indicators', () => {
it('should show orange indicator when version is not latest', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
userProfile: { ...baseAppContextValue.userProfile, name: 'User' },
langGeniusVersionInfo: {
...baseAppContextValue.langGeniusVersionInfo,
current_version: '0.6.0',
latest_version: '0.7.0',
},
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-warning-bg')
})
it('should show green indicator when version is latest', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
userProfile: { ...baseAppContextValue.userProfile, name: 'User' },
langGeniusVersionInfo: {
...baseAppContextValue.langGeniusVersionInfo,
current_version: '0.7.0',
latest_version: '0.7.0',
},
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
})
})
})

View File

@@ -0,0 +1,183 @@
import type { AppContextValue } from '@/context/app-context'
import { fireEvent, render, screen } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import Support from './support'
const { mockZendeskKey } = vi.hoisted(() => ({
mockZendeskKey: { value: 'test-key' },
}))
vi.mock('@/context/app-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/app-context')>()
return {
...actual,
useAppContext: vi.fn(),
}
})
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CE_EDITION: false,
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value },
}
})
describe('Support', () => {
const mockCloseAccountDropdown = vi.fn()
const baseAppContextValue: AppContextValue = {
userProfile: {
id: '1',
name: 'Test User',
email: 'test@example.com',
avatar: '',
avatar_url: '',
is_password_set: false,
},
mutateUserProfile: vi.fn(),
currentWorkspace: {
id: '1',
name: 'Workspace',
plan: '',
status: '',
created_at: 0,
role: 'owner',
providers: [],
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: {
current_env: 'testing',
current_version: '0.6.0',
latest_version: '0.6.0',
release_date: '',
release_notes: '',
version: '0.6.0',
can_auto_update: false,
},
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
beforeEach(() => {
vi.clearAllMocks()
window.zE = vi.fn()
mockZendeskKey.value = 'test-key'
vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.professional,
},
})
})
describe('Rendering', () => {
it('should render support menu trigger', () => {
// Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
// Assert
expect(screen.getByText('common.userProfile.support')).toBeInTheDocument()
})
it('should show forum and community links when opened', () => {
// Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.community')).toBeInTheDocument()
})
})
describe('Plan-based Channels', () => {
it('should show "Contact Us" when ZENDESK_WIDGET_KEY is present', () => {
// Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
})
it('should hide dedicated support channels for Sandbox plan', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
},
})
// Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument()
})
it('should show "Email Support" when ZENDESK_WIDGET_KEY is absent', () => {
// Arrange
mockZendeskKey.value = ''
// Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
})
})
describe('Interactions and Links', () => {
it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => {
// Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.contactUs'))
// Assert
expect(window.zE).toHaveBeenCalledWith('messenger', 'open')
expect(mockCloseAccountDropdown).toHaveBeenCalled()
})
it('should have correct forum and community links', () => {
// Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
const forumLink = screen.getByText('common.userProfile.forum').closest('a')
const communityLink = screen.getByText('common.userProfile.community').closest('a')
expect(forumLink).toHaveAttribute('href', 'https://forum.dify.ai/')
expect(communityLink).toHaveAttribute('href', 'https://discord.gg/5AEfbxcd9k')
})
})
})

View File

@@ -0,0 +1,139 @@
import type { ProviderContextState } from '@/context/provider-context'
import type { IWorkspace } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import { switchWorkspace } from '@/service/common'
import WorkplaceSelector from './index'
vi.mock('@/context/workspace-context', () => ({
useWorkspacesContext: vi.fn(),
}))
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/service/common', () => ({
switchWorkspace: vi.fn(),
}))
describe('WorkplaceSelector', () => {
const mockWorkspaces: IWorkspace[] = [
{ id: '1', name: 'Workspace 1', current: true, plan: 'professional', status: 'normal', created_at: Date.now() },
{ id: '2', name: 'Workspace 2', current: false, plan: 'sandbox', status: 'normal', created_at: Date.now() },
]
const mockNotify = vi.fn()
const mockAssign = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: mockWorkspaces,
})
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
isFetchedPlan: true,
isEducationWorkspace: false,
} as ProviderContextState)
vi.stubGlobal('location', { ...window.location, assign: mockAssign })
})
const renderComponent = () => {
return render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<WorkplaceSelector />
</ToastContext.Provider>,
)
}
describe('Rendering', () => {
it('should render current workspace correctly', () => {
// Act
renderComponent()
// Assert
expect(screen.getByText('Workspace 1')).toBeInTheDocument()
expect(screen.getByText('W')).toBeInTheDocument() // First letter icon
})
it('should open menu and display all workspaces when clicked', () => {
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getAllByText('Workspace 1').length).toBeGreaterThan(0)
expect(screen.getByText('Workspace 2')).toBeInTheDocument()
// The real PlanBadge renders uppercase plan name or "pro"
expect(screen.getByText('pro')).toBeInTheDocument()
expect(screen.getByText('sandbox')).toBeInTheDocument()
})
})
describe('Workspace Switching', () => {
it('should switch workspace successfully', async () => {
// Arrange
vi.mocked(switchWorkspace).mockResolvedValue({
result: 'success',
new_tenant: mockWorkspaces[1],
})
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
const workspace2 = screen.getByText('Workspace 2')
fireEvent.click(workspace2)
// Assert
expect(switchWorkspace).toHaveBeenCalledWith({
url: '/workspaces/switch',
body: { tenant_id: '2' },
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
})
expect(mockAssign).toHaveBeenCalled()
})
})
it('should not switch to the already current workspace', () => {
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
const workspacesInMenu = screen.getAllByText('Workspace 1')
fireEvent.click(workspacesInMenu[workspacesInMenu.length - 1])
// Assert
expect(switchWorkspace).not.toHaveBeenCalled()
})
it('should handle switching error correctly', async () => {
// Arrange
vi.mocked(switchWorkspace).mockRejectedValue(new Error('Failed'))
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
const workspace2 = screen.getByText('Workspace 2')
fireEvent.click(workspace2)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.provider.saveFailed',
})
})
})
})
})

View File

@@ -0,0 +1,126 @@
import type { AccountIntegrate } from '@/models/common'
import { render, screen } from '@testing-library/react'
import { useAccountIntegrates } from '@/service/use-common'
import IntegrationsPage from './index'
vi.mock('@/service/use-common', () => ({
useAccountIntegrates: vi.fn(),
}))
describe('IntegrationsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering connected integrations', () => {
it('should render connected integrations when list is provided', () => {
// Arrange
const mockData: AccountIntegrate[] = [
{ provider: 'google', is_bound: true, link: '', created_at: 1678888888 },
{ provider: 'github', is_bound: true, link: '', created_at: 1678888888 },
]
vi.mocked(useAccountIntegrates).mockReturnValue({
data: {
data: mockData,
},
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.getByText('common.integrations.connected')).toBeInTheDocument()
expect(screen.getByText('common.integrations.google')).toBeInTheDocument()
expect(screen.getByText('common.integrations.github')).toBeInTheDocument()
// Connect link should not be present when bound
expect(screen.queryByText('common.integrations.connect')).not.toBeInTheDocument()
})
})
describe('Unbound integrations', () => {
it('should render connect link for unbound integrations', () => {
// Arrange
const mockData: AccountIntegrate[] = [
{ provider: 'google', is_bound: false, link: 'https://google.com', created_at: 1678888888 },
]
vi.mocked(useAccountIntegrates).mockReturnValue({
data: {
data: mockData,
},
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.getByText('common.integrations.google')).toBeInTheDocument()
const connectLink = screen.getByText('common.integrations.connect')
expect(connectLink).toBeInTheDocument()
expect(connectLink.closest('a')).toHaveAttribute('href', 'https://google.com')
})
})
describe('Edge cases', () => {
it('should render nothing when no integrations are provided', () => {
// Arrange
vi.mocked(useAccountIntegrates).mockReturnValue({
data: {
data: [],
},
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.getByText('common.integrations.connected')).toBeInTheDocument()
expect(screen.queryByText('common.integrations.google')).not.toBeInTheDocument()
expect(screen.queryByText('common.integrations.github')).not.toBeInTheDocument()
})
it('should handle unknown providers gracefully', () => {
// Arrange
const mockData = [
{ provider: 'unknown', is_bound: false, link: '', created_at: 1678888888 } as unknown as AccountIntegrate,
]
vi.mocked(useAccountIntegrates).mockReturnValue({
data: {
data: mockData,
},
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.queryByText('common.integrations.connect')).not.toBeInTheDocument()
})
it('should handle undefined data gracefully', () => {
// Arrange
vi.mocked(useAccountIntegrates).mockReturnValue({
data: undefined,
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.getByText('common.integrations.connected')).toBeInTheDocument()
expect(screen.queryByText('common.integrations.google')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import Empty from './empty'
describe('Empty State', () => {
describe('Rendering', () => {
it('should render title and documentation link', () => {
// Act
render(<Empty />)
// Assert
expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument()
const link = screen.getByText('common.apiBasedExtension.link')
expect(link).toBeInTheDocument()
// The real useDocLink includes the language prefix (defaulting to /en in tests)
expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension')
})
})
})

View File

@@ -0,0 +1,151 @@
import type { SetStateAction } from 'react'
import type { ModalContextState, ModalState } from '@/context/modal-context'
import type { ApiBasedExtension } from '@/models/common'
import { fireEvent, render, screen } from '@testing-library/react'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionPage from './index'
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
describe('ApiBasedExtensionPage', () => {
const mockRefetch = vi.fn<() => void>()
const mockSetShowApiBasedExtensionModal = vi.fn<(value: SetStateAction<ModalState<ApiBasedExtension> | null>) => void>()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
})
describe('Rendering', () => {
it('should render empty state when no data exists', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: [],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
// Assert
expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument()
})
it('should render list of extensions when data exists', () => {
// Arrange
const mockData = [
{ id: '1', name: 'Extension 1', api_endpoint: 'url1' },
{ id: '2', name: 'Extension 2', api_endpoint: 'url2' },
]
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: mockData,
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
// Assert
expect(screen.getByText('Extension 1')).toBeInTheDocument()
expect(screen.getByText('url1')).toBeInTheDocument()
expect(screen.getByText('Extension 2')).toBeInTheDocument()
expect(screen.getByText('url2')).toBeInTheDocument()
})
it('should handle loading state', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: null,
isPending: true,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
// Assert
expect(screen.queryByText('common.apiBasedExtension.title')).not.toBeInTheDocument()
expect(screen.getByText('common.apiBasedExtension.add')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should open modal when clicking add button', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: [],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
// Assert
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: {},
}))
})
it('should call refetch when onSaveCallback is executed from the modal', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: [],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
// Trigger callback manually from the mock call
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
if (callArgs.onSaveCallback) {
callArgs.onSaveCallback()
// Assert
expect(mockRefetch).toHaveBeenCalled()
}
}
})
it('should call refetch when an item is updated', () => {
// Arrange
const mockData = [{ id: '1', name: 'Extension 1', api_endpoint: 'url1' }]
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: mockData,
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
render(<ApiBasedExtensionPage />)
// Act - Click edit on the rendered item
fireEvent.click(screen.getByText('common.operation.edit'))
// Retrieve the onSaveCallback from the modal call and execute it
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
if (callArgs.onSaveCallback)
callArgs.onSaveCallback()
}
// Assert
expect(mockRefetch).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,190 @@
import type { TFunction } from 'i18next'
import type { ModalContextState } from '@/context/modal-context'
import type { ApiBasedExtension } from '@/models/common'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { deleteApiBasedExtension } from '@/service/common'
import Item from './item'
// Mock dependencies
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/common', () => ({
deleteApiBasedExtension: vi.fn(),
}))
describe('Item Component', () => {
const mockData: ApiBasedExtension = {
id: '1',
name: 'Test Extension',
api_endpoint: 'https://api.example.com',
api_key: 'test-api-key',
}
const mockOnUpdate = vi.fn()
const mockSetShowApiBasedExtensionModal = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
})
describe('Rendering', () => {
it('should render extension data correctly', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
// Assert
expect(screen.getByText('Test Extension')).toBeInTheDocument()
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
})
it('should render with minimal extension data', () => {
// Arrange
const minimalData: ApiBasedExtension = { id: '2' }
// Act
render(<Item data={minimalData} onUpdate={mockOnUpdate} />)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
})
})
describe('Modal Interactions', () => {
it('should open edit modal with correct payload when clicking edit button', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.edit'))
// Assert
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: mockData,
}))
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall)
expect(lastCall.onSaveCallback).toBeInstanceOf(Function)
})
it('should execute onUpdate callback when edit modal save callback is invoked', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.edit'))
// Assert
const modalCallArg = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof modalCallArg === 'object' && modalCallArg !== null && 'onSaveCallback' in modalCallArg) {
const onSaveCallback = modalCallArg.onSaveCallback
if (onSaveCallback) {
onSaveCallback()
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
}
}
})
})
describe('Deletion', () => {
it('should show delete confirmation dialog when clicking delete button', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
// Assert
expect(screen.getByText(/common\.operation\.delete.*Test Extension.*\?/i)).toBeInTheDocument()
})
it('should call delete API and triggers onUpdate when confirming deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
const dialog = screen.getByTestId('confirm-overlay')
const confirmButton = within(dialog).getByText('common.operation.delete')
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(deleteApiBasedExtension).toHaveBeenCalledWith('/api-based-extension/1')
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
})
})
it('should hide delete confirmation dialog after successful deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
const dialog = screen.getByTestId('confirm-overlay')
const confirmButton = within(dialog).getByText('common.operation.delete')
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument()
})
})
it('should close delete confirmation when clicking cancel button', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument()
})
it('should not call delete API when canceling deletion', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(deleteApiBasedExtension).not.toHaveBeenCalled()
expect(mockOnUpdate).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should still show confirmation modal when operation.delete translation is missing', () => {
// Arrange
const useTranslationSpy = vi.spyOn(reactI18next, 'useTranslation')
const originalValue = useTranslationSpy.getMockImplementation()?.() || {
t: (key: string) => key,
i18n: { language: 'en', changeLanguage: vi.fn() },
}
useTranslationSpy.mockReturnValue({
...originalValue,
t: vi.fn().mockImplementation((key: string) => {
if (key === 'operation.delete')
return ''
return key
}) as unknown as TFunction,
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
const allButtons = screen.getAllByRole('button')
const editBtn = screen.getByText('operation.edit')
const deleteBtn = allButtons.find(btn => btn !== editBtn)
if (deleteBtn)
fireEvent.click(deleteBtn)
// Assert
expect(screen.getByText(/.*Test Extension.*\?/i)).toBeInTheDocument()
useTranslationSpy.mockRestore()
})
})
})

View File

@@ -0,0 +1,223 @@
import type { TFunction } from 'i18next'
import type { IToastProps } from '@/app/components/base/toast'
import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { ToastContext } from '@/app/components/base/toast'
import { useDocLink } from '@/context/i18n'
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
import ApiBasedExtensionModal from './modal'
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(),
}))
vi.mock('@/service/common', () => ({
addApiBasedExtension: vi.fn(),
updateApiBasedExtension: vi.fn(),
}))
describe('ApiBasedExtensionModal', () => {
const mockOnCancel = vi.fn()
const mockOnSave = vi.fn()
const mockNotify = vi.fn()
const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`)
const render = (ui: React.ReactElement) => RTLRender(
<ToastContext.Provider value={{
notify: mockNotify as unknown as (props: IToastProps) => void,
close: vi.fn(),
}}
>
{ui}
</ToastContext.Provider>,
)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useDocLink).mockReturnValue(mockDocLink)
})
describe('Rendering', () => {
it('should render correctly for adding a new extension', () => {
// Act
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Assert
expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder')).toBeInTheDocument()
})
it('should render correctly for editing an existing extension', () => {
// Arrange
const data = { id: '1', name: 'Existing', api_endpoint: 'url', api_key: 'key' }
// Act
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Assert
expect(screen.getByText('common.apiBasedExtension.modal.editTitle')).toBeInTheDocument()
expect(screen.getByDisplayValue('Existing')).toBeInTheDocument()
expect(screen.getByDisplayValue('url')).toBeInTheDocument()
expect(screen.getByDisplayValue('key')).toBeInTheDocument()
})
})
describe('Form Submissions', () => {
it('should call addApiBasedExtension on save for new extension', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({ id: 'new-id' })
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(addApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension',
body: {
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
},
})
expect(mockOnSave).toHaveBeenCalledWith({ id: 'new-id' })
})
})
it('should call updateApiBasedExtension on save for existing extension', async () => {
// Arrange
const data = { id: '1', name: 'Existing', api_endpoint: 'url', api_key: 'long-secret-key' }
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, name: 'Updated' })
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByDisplayValue('Existing'), { target: { value: 'Updated' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
body: expect.objectContaining({
id: '1',
name: 'Updated',
api_endpoint: 'url',
api_key: '[__HIDDEN__]',
}),
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' })
expect(mockOnSave).toHaveBeenCalled()
})
})
it('should call updateApiBasedExtension with new api_key when key is changed', async () => {
// Arrange
const data = { id: '1', name: 'Existing', api_endpoint: 'url', api_key: 'old-key' }
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, api_key: 'new-longer-key' })
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByDisplayValue('old-key'), { target: { value: 'new-longer-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
body: expect.objectContaining({
api_key: 'new-longer-key',
}),
})
})
})
})
describe('Validation', () => {
it('should show error if api key is too short', async () => {
// Arrange
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'url' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: '123' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.apiBasedExtension.modal.apiKey.lengthError' })
expect(addApiBasedExtension).not.toHaveBeenCalled()
})
})
describe('Interactions', () => {
it('should work when onSave is not provided', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({ id: 'new-id' })
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(addApiBasedExtension).toHaveBeenCalled()
})
})
it('should call onCancel when clicking cancel button', () => {
// Arrange
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle missing translations for placeholders gracefully', () => {
// Arrange
const useTranslationSpy = vi.spyOn(reactI18next, 'useTranslation')
const originalValue = useTranslationSpy.getMockImplementation()?.() || {
t: (key: string) => key,
i18n: { language: 'en', changeLanguage: vi.fn() },
}
useTranslationSpy.mockReturnValue({
...originalValue,
t: vi.fn().mockImplementation((key: string) => {
const missingKeys = [
'apiBasedExtension.modal.name.placeholder',
'apiBasedExtension.modal.apiEndpoint.placeholder',
'apiBasedExtension.modal.apiKey.placeholder',
]
if (missingKeys.some(k => key.includes(k)))
return ''
return key
}) as unknown as TFunction,
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
const { container } = render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
// Assert
const inputs = container.querySelectorAll('input')
inputs.forEach((input) => {
expect(input.placeholder).toBe('')
})
useTranslationSpy.mockRestore()
})
})
})

View File

@@ -0,0 +1,123 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { ModalContextState } from '@/context/modal-context'
import type { ApiBasedExtension } from '@/models/common'
import { fireEvent, render, screen } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionSelector from './selector'
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetShowApiBasedExtensionModal = vi.fn()
const mockRefetch = vi.fn()
const mockData: ApiBasedExtension[] = [
{ id: '1', name: 'Extension 1', api_endpoint: 'https://api1.test' },
{ id: '2', name: 'Extension 2', api_endpoint: 'https://api2.test' },
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: mockData,
refetch: mockRefetch,
isPending: false,
isError: false,
} as unknown as UseQueryResult<ApiBasedExtension[], Error>)
})
describe('Rendering', () => {
it('should render placeholder when no value is selected', () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
// Assert
expect(screen.getByText('common.apiBasedExtension.selector.placeholder')).toBeInTheDocument()
})
it('should render selected item name', async () => {
// Act
render(<ApiBasedExtensionSelector value="1" onChange={mockOnChange} />)
// Assert
expect(screen.getByText('Extension 1')).toBeInTheDocument()
})
})
describe('Dropdown Interactions', () => {
it('should open dropdown when clicked', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
const trigger = screen.getByText('common.apiBasedExtension.selector.placeholder')
fireEvent.click(trigger)
// Assert
expect(await screen.findByText('common.apiBasedExtension.selector.title')).toBeInTheDocument()
})
it('should call onChange and closes dropdown when an extension is selected', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
const option = await screen.findByText('Extension 2')
fireEvent.click(option)
// Assert
expect(mockOnChange).toHaveBeenCalledWith('2')
})
})
describe('Manage and Add Extensions', () => {
it('should open account settings when clicking manage', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
const manageButton = await screen.findByText('common.apiBasedExtension.selector.manage')
fireEvent.click(manageButton)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
})
})
it('should open add modal when clicking add button and refetches on save', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
const addButton = await screen.findByText('common.operation.add')
fireEvent.click(addButton)
// Assert
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: {},
}))
// Trigger callback
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall) {
if (lastCall.onSaveCallback) {
lastCall.onSaveCallback()
expect(mockRefetch).toHaveBeenCalled()
}
}
})
})
})

View File

@@ -0,0 +1,121 @@
import type { IItem } from './index'
import { fireEvent, render, screen } from '@testing-library/react'
import Collapse from './index'
describe('Collapse', () => {
const mockItems: IItem[] = [
{ key: '1', name: 'Item 1' },
{ key: '2', name: 'Item 2' },
]
const mockRenderItem = (item: IItem) => (
<div data-testid={`item-${item.key}`}>
{item.name}
</div>
)
const mockOnSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render title and initially closed state', () => {
// Act
const { container } = render(
<Collapse
title="Test Title"
items={mockItems}
renderItem={mockRenderItem}
/>,
)
// Assert
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.queryByTestId('item-1')).not.toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should apply custom wrapperClassName', () => {
// Act
const { container } = render(
<Collapse
title="Test Title"
items={[]}
renderItem={mockRenderItem}
wrapperClassName="custom-class"
/>,
)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
})
describe('Interactions', () => {
it('should toggle content open and closed', () => {
// Act & Assert
render(
<Collapse
title="Test Title"
items={mockItems}
renderItem={mockRenderItem}
/>,
)
// Initially closed
expect(screen.queryByTestId('item-1')).not.toBeInTheDocument()
// Click to open
fireEvent.click(screen.getByText('Test Title'))
expect(screen.getByTestId('item-1')).toBeInTheDocument()
expect(screen.getByTestId('item-2')).toBeInTheDocument()
// Click to close
fireEvent.click(screen.getByText('Test Title'))
expect(screen.queryByTestId('item-1')).not.toBeInTheDocument()
})
it('should handle item selection', () => {
// Arrange
render(
<Collapse
title="Test Title"
items={mockItems}
renderItem={mockRenderItem}
onSelect={mockOnSelect}
/>,
)
// Act
fireEvent.click(screen.getByText('Test Title'))
const item1 = screen.getByTestId('item-1')
fireEvent.click(item1)
// Assert
expect(mockOnSelect).toHaveBeenCalledTimes(1)
expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0])
})
it('should not crash when onSelect is undefined and item is clicked', () => {
// Arrange
render(
<Collapse
title="Test Title"
items={mockItems}
renderItem={mockRenderItem}
/>,
)
// Act
fireEvent.click(screen.getByText('Test Title'))
const item1 = screen.getByTestId('item-1')
fireEvent.click(item1)
// Assert
// Should not throw
expect(screen.getByTestId('item-1')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,42 @@
import {
ACCOUNT_SETTING_MODAL_ACTION,
ACCOUNT_SETTING_TAB,
DEFAULT_ACCOUNT_SETTING_TAB,
isValidAccountSettingTab,
} from './constants'
describe('AccountSetting Constants', () => {
it('should have correct ACCOUNT_SETTING_MODAL_ACTION', () => {
expect(ACCOUNT_SETTING_MODAL_ACTION).toBe('showSettings')
})
it('should have correct ACCOUNT_SETTING_TAB values', () => {
expect(ACCOUNT_SETTING_TAB.PROVIDER).toBe('provider')
expect(ACCOUNT_SETTING_TAB.MEMBERS).toBe('members')
expect(ACCOUNT_SETTING_TAB.BILLING).toBe('billing')
expect(ACCOUNT_SETTING_TAB.DATA_SOURCE).toBe('data-source')
expect(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION).toBe('api-based-extension')
expect(ACCOUNT_SETTING_TAB.CUSTOM).toBe('custom')
expect(ACCOUNT_SETTING_TAB.LANGUAGE).toBe('language')
})
it('should have correct DEFAULT_ACCOUNT_SETTING_TAB', () => {
expect(DEFAULT_ACCOUNT_SETTING_TAB).toBe(ACCOUNT_SETTING_TAB.MEMBERS)
})
it('isValidAccountSettingTab should return true for valid tabs', () => {
expect(isValidAccountSettingTab('provider')).toBe(true)
expect(isValidAccountSettingTab('members')).toBe(true)
expect(isValidAccountSettingTab('billing')).toBe(true)
expect(isValidAccountSettingTab('data-source')).toBe(true)
expect(isValidAccountSettingTab('api-based-extension')).toBe(true)
expect(isValidAccountSettingTab('custom')).toBe(true)
expect(isValidAccountSettingTab('language')).toBe(true)
})
it('isValidAccountSettingTab should return false for invalid tabs', () => {
expect(isValidAccountSettingTab(null)).toBe(false)
expect(isValidAccountSettingTab('')).toBe(false)
expect(isValidAccountSettingTab('invalid')).toBe(false)
})
})

View File

@@ -0,0 +1,363 @@
import type { DataSourceAuth } from './types'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import { CollectionType } from '@/app/components/tools/types'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { openOAuthPopup } from '@/hooks/use-oauth'
import { useGetDataSourceOAuthUrl, useInvalidDataSourceAuth, useInvalidDataSourceListAuth, useInvalidDefaultDataSourceListAuth } from '@/service/use-datasource'
import { useInvalidDataSourceList } from '@/service/use-pipeline'
import Card from './card'
import { useDataSourceAuthUpdate } from './hooks'
vi.mock('@/app/components/plugins/plugin-auth', () => ({
ApiKeyModal: vi.fn(({ onClose, onUpdate, onRemove, disabled, editValues }: { onClose: () => void, onUpdate: () => void, onRemove: () => void, disabled: boolean, editValues: Record<string, unknown> }) => (
<div data-testid="mock-api-key-modal" data-disabled={disabled}>
<button data-testid="modal-close" onClick={onClose}>Close</button>
<button data-testid="modal-update" onClick={onUpdate}>Update</button>
<button data-testid="modal-remove" onClick={onRemove}>Remove</button>
<div data-testid="edit-values">{JSON.stringify(editValues)}</div>
</div>
)),
usePluginAuthAction: vi.fn(),
AuthCategory: {
datasource: 'datasource',
},
AddApiKeyButton: ({ onUpdate }: { onUpdate: () => void }) => <button onClick={onUpdate}>Add API Key</button>,
AddOAuthButton: ({ onUpdate }: { onUpdate: () => void }) => <button onClick={onUpdate}>Add OAuth</button>,
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: vi.fn(),
}))
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: vi.fn(),
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceOAuthUrl: vi.fn(),
useInvalidDataSourceAuth: vi.fn(() => vi.fn()),
useInvalidDataSourceListAuth: vi.fn(() => vi.fn()),
useInvalidDefaultDataSourceListAuth: vi.fn(() => vi.fn()),
}))
vi.mock('./hooks', () => ({
useDataSourceAuthUpdate: vi.fn(),
}))
vi.mock('@/service/use-pipeline', () => ({
useInvalidDataSourceList: vi.fn(() => vi.fn()),
}))
type UsePluginAuthActionReturn = ReturnType<typeof usePluginAuthAction>
type UseGetDataSourceOAuthUrlReturn = ReturnType<typeof useGetDataSourceOAuthUrl>
type UseRenderI18nObjectReturn = ReturnType<typeof useRenderI18nObject>
describe('Card Component', () => {
const mockGetPluginOAuthUrl = vi.fn()
const mockRenderI18nObjectResult = vi.fn((obj: Record<string, string>) => obj.en_US)
const mockInvalidateDataSourceListAuth = vi.fn()
const mockInvalidDefaultDataSourceListAuth = vi.fn()
const mockInvalidateDataSourceList = vi.fn()
const mockInvalidateDataSourceAuth = vi.fn()
const mockHandleAuthUpdate = vi.fn(() => {
mockInvalidateDataSourceListAuth()
mockInvalidDefaultDataSourceListAuth()
mockInvalidateDataSourceList()
mockInvalidateDataSourceAuth()
})
const createMockPluginAuthActionReturn = (overrides: Partial<UsePluginAuthActionReturn> = {}): UsePluginAuthActionReturn => ({
deleteCredentialId: null,
doingAction: false,
handleConfirm: vi.fn(),
handleEdit: vi.fn(),
handleRemove: vi.fn(),
handleRename: vi.fn(),
handleSetDefault: vi.fn(),
handleSetDoingAction: vi.fn(),
setDeleteCredentialId: vi.fn(),
editValues: null,
setEditValues: vi.fn(),
openConfirm: vi.fn(),
closeConfirm: vi.fn(),
pendingOperationCredentialId: { current: null },
...overrides,
})
const mockItem: DataSourceAuth = {
author: 'Test Author',
provider: 'test-provider',
plugin_id: 'test-plugin-id',
plugin_unique_identifier: 'test-unique-id',
icon: 'test-icon-url',
name: 'test-name',
label: {
en_US: 'Test Label',
zh_Hans: '',
},
description: {
en_US: 'Test Description',
zh_Hans: '',
},
credentials_list: [
{
id: 'c1',
name: 'Credential 1',
credential: { apiKey: 'key1' },
type: CredentialTypeEnum.API_KEY,
is_default: true,
avatar_url: 'avatar1',
},
],
}
let mockPluginAuthActionReturn: UsePluginAuthActionReturn
beforeEach(() => {
vi.clearAllMocks()
mockPluginAuthActionReturn = createMockPluginAuthActionReturn()
vi.mocked(useDataSourceAuthUpdate).mockReturnValue({ handleAuthUpdate: mockHandleAuthUpdate })
vi.mocked(useInvalidDataSourceListAuth).mockReturnValue(mockInvalidateDataSourceListAuth)
vi.mocked(useInvalidDefaultDataSourceListAuth).mockReturnValue(mockInvalidDefaultDataSourceListAuth)
vi.mocked(useInvalidDataSourceList).mockReturnValue(mockInvalidateDataSourceList)
vi.mocked(useInvalidDataSourceAuth).mockReturnValue(mockInvalidateDataSourceAuth)
vi.mocked(usePluginAuthAction).mockReturnValue(mockPluginAuthActionReturn)
vi.mocked(useRenderI18nObject).mockReturnValue(mockRenderI18nObjectResult as unknown as UseRenderI18nObjectReturn)
vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ mutateAsync: mockGetPluginOAuthUrl } as unknown as UseGetDataSourceOAuthUrlReturn)
})
const expectAuthUpdated = () => {
expect(mockInvalidateDataSourceListAuth).toHaveBeenCalled()
expect(mockInvalidDefaultDataSourceListAuth).toHaveBeenCalled()
expect(mockInvalidateDataSourceList).toHaveBeenCalled()
expect(mockInvalidateDataSourceAuth).toHaveBeenCalled()
}
describe('Rendering', () => {
it('should render the card with provided item data and initialize hooks correctly', () => {
// Act
render(<Card item={mockItem} />)
// Assert
expect(screen.getByText('Test Label')).toBeInTheDocument()
expect(screen.getByText(/Test Author/)).toBeInTheDocument()
expect(screen.getByText(/test-name/)).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('src', 'test-icon-url')
expect(screen.getByText('Credential 1')).toBeInTheDocument()
expect(usePluginAuthAction).toHaveBeenCalledWith(
expect.objectContaining({
category: 'datasource',
provider: 'test-plugin-id/test-name',
providerType: CollectionType.datasource,
}),
mockHandleAuthUpdate,
)
})
it('should render empty state when credentials_list is empty', () => {
// Arrange
const emptyItem = { ...mockItem, credentials_list: [] }
// Act
render(<Card item={emptyItem} />)
// Assert
expect(screen.getByText(/plugin.auth.emptyAuth/)).toBeInTheDocument()
})
})
describe('Actions', () => {
const openDropdown = (text: string) => {
const item = screen.getByText(text).closest('.flex')
const trigger = within(item as HTMLElement).getByRole('button')
fireEvent.click(trigger)
}
it('should handle "edit" action from Item component', async () => {
// Act
render(<Card item={mockItem} />)
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/operation.edit/))
// Assert
expect(mockPluginAuthActionReturn.handleEdit).toHaveBeenCalledWith('c1', {
apiKey: 'key1',
__name__: 'Credential 1',
__credential_id__: 'c1',
})
})
it('should handle "delete" action from Item component', async () => {
// Act
render(<Card item={mockItem} />)
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/operation.remove/))
// Assert
expect(mockPluginAuthActionReturn.openConfirm).toHaveBeenCalledWith('c1')
})
it('should handle "setDefault" action from Item component', async () => {
// Act
render(<Card item={mockItem} />)
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/auth.setDefault/))
// Assert
expect(mockPluginAuthActionReturn.handleSetDefault).toHaveBeenCalledWith('c1')
})
it('should handle "rename" action from Item component', async () => {
// Arrange
const oAuthItem = {
...mockItem,
credentials_list: [{
...mockItem.credentials_list[0],
type: CredentialTypeEnum.OAUTH2,
}],
}
render(<Card item={oAuthItem} />)
// Act
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/operation.rename/))
// Now it should show an input
const input = screen.getByPlaceholderText(/placeholder.input/)
fireEvent.change(input, { target: { value: 'New Name' } })
fireEvent.click(screen.getByText(/operation.save/))
// Assert
expect(mockPluginAuthActionReturn.handleRename).toHaveBeenCalledWith({
credential_id: 'c1',
name: 'New Name',
})
})
it('should handle "change" action and trigger OAuth flow', async () => {
// Arrange
const oAuthItem = {
...mockItem,
credentials_list: [{
...mockItem.credentials_list[0],
type: CredentialTypeEnum.OAUTH2,
}],
}
mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.url' })
render(<Card item={oAuthItem} />)
// Act
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/dataSource.notion.changeAuthorizedPages/))
// Assert
await waitFor(() => {
expect(mockGetPluginOAuthUrl).toHaveBeenCalledWith('c1')
expect(openOAuthPopup).toHaveBeenCalledWith('https://oauth.url', mockHandleAuthUpdate)
})
})
it('should not trigger OAuth flow if authorization_url is missing', async () => {
// Arrange
const oAuthItem = {
...mockItem,
credentials_list: [{
...mockItem.credentials_list[0],
type: CredentialTypeEnum.OAUTH2,
}],
}
mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: '' })
render(<Card item={oAuthItem} />)
// Act
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/dataSource.notion.changeAuthorizedPages/))
// Assert
await waitFor(() => {
expect(mockGetPluginOAuthUrl).toHaveBeenCalledWith('c1')
})
expect(openOAuthPopup).not.toHaveBeenCalled()
})
})
describe('Modals', () => {
it('should show Confirm dialog when deleteCredentialId is set and handle its actions', () => {
// Arrange
const mockReturn = createMockPluginAuthActionReturn({ deleteCredentialId: 'c1', doingAction: false })
vi.mocked(usePluginAuthAction).mockReturnValue(mockReturn)
// Act
render(<Card item={mockItem} />)
// Assert
expect(screen.getByText(/list.delete.title/)).toBeInTheDocument()
const confirmButton = screen.getByText(/operation.confirm/).closest('button')
expect(confirmButton).toBeEnabled()
// Act - Cancel
fireEvent.click(screen.getByText(/operation.cancel/))
expect(mockReturn.closeConfirm).toHaveBeenCalled()
// Act - Confirm (even if disabled in UI, fireEvent still works unless we check)
fireEvent.click(screen.getByText(/operation.confirm/))
expect(mockReturn.handleConfirm).toHaveBeenCalled()
})
it('should show ApiKeyModal when editValues is set and handle its actions', () => {
// Arrange
const mockReturn = createMockPluginAuthActionReturn({ editValues: { some: 'value' }, doingAction: false })
vi.mocked(usePluginAuthAction).mockReturnValue(mockReturn)
render(<Card item={mockItem} disabled={false} />)
// Assert
expect(screen.getByTestId('mock-api-key-modal')).toBeInTheDocument()
expect(screen.getByTestId('mock-api-key-modal')).toHaveAttribute('data-disabled', 'false')
// Act
fireEvent.click(screen.getByTestId('modal-close'))
expect(mockReturn.setEditValues).toHaveBeenCalledWith(null)
fireEvent.click(screen.getByTestId('modal-remove'))
expect(mockReturn.handleRemove).toHaveBeenCalled()
})
it('should disable ApiKeyModal when doingAction is true', () => {
// Arrange
const mockReturnDoing = createMockPluginAuthActionReturn({ editValues: { some: 'value' }, doingAction: true })
vi.mocked(usePluginAuthAction).mockReturnValue(mockReturnDoing)
// Act
render(<Card item={mockItem} disabled={false} />)
// Assert
expect(screen.getByTestId('mock-api-key-modal')).toHaveAttribute('data-disabled', 'true')
})
})
describe('Integration', () => {
it('should call handleAuthUpdate when Configure component triggers update', async () => {
// Arrange
const configurableItem: DataSourceAuth = {
...mockItem,
credential_schema: [{ name: 'api_key', type: FormTypeEnum.textInput, label: 'API Key', required: true }],
}
// Act
render(<Card item={configurableItem} />)
fireEvent.click(screen.getByText(/dataSource.configure/))
// Find the add API key button and click it
fireEvent.click(screen.getByText('Add API Key'))
// Assert
expectAuthUpdated()
})
})
})

View File

@@ -0,0 +1,256 @@
import type { DataSourceAuth } from './types'
import type { FormSchema } from '@/app/components/base/form/types'
import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
import Configure from './configure'
/**
* Configure Component Tests
* Using Unit approach to ensure 100% coverage and stable tests.
*/
// Mock plugin auth components to isolate the unit test for Configure.
vi.mock('@/app/components/plugins/plugin-auth', () => ({
AddApiKeyButton: vi.fn(({ onUpdate, disabled, buttonText }: AddApiKeyButtonProps & { onUpdate: () => void }) => (
<button data-testid="add-api-key" onClick={onUpdate} disabled={disabled}>{buttonText}</button>
)),
AddOAuthButton: vi.fn(({ onUpdate, disabled, buttonText }: AddOAuthButtonProps & { onUpdate: () => void }) => (
<button data-testid="add-oauth" onClick={onUpdate} disabled={disabled}>{buttonText}</button>
)),
}))
describe('Configure Component', () => {
const mockOnUpdate = vi.fn()
const mockPluginPayload: PluginPayload = {
category: AuthCategory.datasource,
provider: 'test-provider',
}
const mockItemBase: DataSourceAuth = {
author: 'Test Author',
provider: 'test-provider',
plugin_id: 'test-plugin-id',
plugin_unique_identifier: 'test-unique-id',
icon: 'test-icon-url',
name: 'test-name',
label: { en_US: 'Test Label', zh_Hans: 'zh_hans' },
description: { en_US: 'Test Description', zh_Hans: 'zh_hans' },
credentials_list: [],
}
const mockFormSchema: FormSchema = {
name: 'api_key',
label: { en_US: 'API Key', zh_Hans: 'zh_hans' },
type: FormTypeEnum.textInput,
required: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Open State Management', () => {
it('should toggle and manage the open state correctly', () => {
// Arrange
// Add a schema so we can detect if it's open by checking for button presence
const itemWithApiKey: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
}
render(<Configure item={itemWithApiKey} pluginPayload={mockPluginPayload} />)
const trigger = screen.getByRole('button', { name: /dataSource.configure/i })
// Assert: Initially closed (button from content should not be present)
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
// Act: Click to open
fireEvent.click(trigger)
// Assert: Now open
expect(screen.getByTestId('add-api-key')).toBeInTheDocument()
// Act: Click again to close
fireEvent.click(trigger)
// Assert: Now closed
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
})
})
describe('Conditional Rendering', () => {
it('should render AddApiKeyButton when credential_schema is non-empty', () => {
// Arrange
const itemWithApiKey: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
}
// Act
render(<Configure item={itemWithApiKey} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
// Assert
expect(screen.getByTestId('add-api-key')).toBeInTheDocument()
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
})
it('should render AddOAuthButton when oauth_schema with client_schema is non-empty', () => {
// Arrange
const itemWithOAuth: DataSourceAuth = {
...mockItemBase,
oauth_schema: {
client_schema: [mockFormSchema],
},
}
// Act
render(<Configure item={itemWithOAuth} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
// Assert
expect(screen.getByTestId('add-oauth')).toBeInTheDocument()
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
})
it('should render both buttons and the OR divider when both schemes are available', () => {
// Arrange
const itemWithBoth: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
oauth_schema: {
client_schema: [mockFormSchema],
},
}
// Act
render(<Configure item={itemWithBoth} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
// Assert
expect(screen.getByTestId('add-api-key')).toBeInTheDocument()
expect(screen.getByTestId('add-oauth')).toBeInTheDocument()
expect(screen.getByText('OR')).toBeInTheDocument()
})
})
describe('Update Handling', () => {
it('should call onUpdate and close the portal when an update is triggered', () => {
// Arrange
const itemWithApiKey: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
}
render(<Configure item={itemWithApiKey} pluginPayload={mockPluginPayload} onUpdate={mockOnUpdate} />)
// Act: Open and click update
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
fireEvent.click(screen.getByTestId('add-api-key'))
// Assert
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
})
it('should handle missing onUpdate callback gracefully', () => {
// Arrange
const itemWithBoth: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
oauth_schema: {
client_schema: [mockFormSchema],
},
}
render(<Configure item={itemWithBoth} pluginPayload={mockPluginPayload} />)
// Act & Assert
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
fireEvent.click(screen.getByTestId('add-api-key'))
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
fireEvent.click(screen.getByTestId('add-oauth'))
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
})
})
describe('Props and Edge Cases', () => {
it('should pass the disabled prop to both configuration buttons', () => {
// Arrange
const itemWithBoth: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
oauth_schema: {
client_schema: [mockFormSchema],
},
}
// Act: Open the configuration menu
render(<Configure item={itemWithBoth} pluginPayload={mockPluginPayload} disabled={true} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
// Assert
expect(screen.getByTestId('add-api-key')).toBeDisabled()
expect(screen.getByTestId('add-oauth')).toBeDisabled()
})
it('should handle edge cases for missing, empty, or partial item data', () => {
// Act & Assert (Missing schemas)
const { rerender } = render(<Configure item={mockItemBase} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
// Arrange (Empty schemas)
const itemEmpty: DataSourceAuth = {
...mockItemBase,
credential_schema: [],
oauth_schema: { client_schema: [] },
}
// Act
rerender(<Configure item={itemEmpty} pluginPayload={mockPluginPayload} />)
// Already open from previous click if rerender doesn't reset state
// But it's better to be sure
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
// Arrange (Partial OAuth schema)
const itemPartialOAuth: DataSourceAuth = {
...mockItemBase,
oauth_schema: {
is_oauth_custom_client_enabled: true,
},
}
// Act
rerender(<Configure item={itemPartialOAuth} pluginPayload={mockPluginPayload} />)
// Assert
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
})
it('should reach the unreachable branch on line 95 for 100% coverage', async () => {
// Specialized test to reach the '|| []' part: canOAuth must be truthy but client_schema falsy on second call
let count = 0
const itemWithGlitchedSchema = {
...mockItemBase,
oauth_schema: {
get client_schema() {
count++
if (count % 2 !== 0)
return [mockFormSchema]
return undefined
},
is_oauth_custom_client_enabled: false,
is_system_oauth_params_exists: false,
oauth_custom_client_params: {},
redirect_uri: '',
},
} as unknown as DataSourceAuth
render(<Configure item={itemWithGlitchedSchema} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
await waitFor(() => {
expect(screen.getByTestId('add-oauth')).toBeInTheDocument()
})
})
})
})

View File

@@ -0,0 +1,84 @@
import { act, renderHook } from '@testing-library/react'
import {
useInvalidDataSourceAuth,
useInvalidDataSourceListAuth,
useInvalidDefaultDataSourceListAuth,
} from '@/service/use-datasource'
import { useInvalidDataSourceList } from '@/service/use-pipeline'
import { useDataSourceAuthUpdate } from './use-data-source-auth-update'
/**
* useDataSourceAuthUpdate Hook Tests
* This hook manages the invalidation of various data source related queries.
*/
vi.mock('@/service/use-datasource', () => ({
useInvalidDataSourceAuth: vi.fn(),
useInvalidDataSourceListAuth: vi.fn(),
useInvalidDefaultDataSourceListAuth: vi.fn(),
}))
vi.mock('@/service/use-pipeline', () => ({
useInvalidDataSourceList: vi.fn(),
}))
describe('useDataSourceAuthUpdate', () => {
const mockInvalidateDataSourceAuth = vi.fn()
const mockInvalidateDataSourceListAuth = vi.fn()
const mockInvalidDefaultDataSourceListAuth = vi.fn()
const mockInvalidateDataSourceList = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useInvalidDataSourceAuth).mockReturnValue(mockInvalidateDataSourceAuth)
vi.mocked(useInvalidDataSourceListAuth).mockReturnValue(mockInvalidateDataSourceListAuth)
vi.mocked(useInvalidDefaultDataSourceListAuth).mockReturnValue(mockInvalidDefaultDataSourceListAuth)
vi.mocked(useInvalidDataSourceList).mockReturnValue(mockInvalidateDataSourceList)
})
describe('handleAuthUpdate', () => {
it('should call all invalidate functions when handleAuthUpdate is invoked', () => {
// Arrange
const pluginId = 'test-plugin-id'
const provider = 'test-provider'
const { result } = renderHook(() => useDataSourceAuthUpdate({
pluginId,
provider,
}))
// Assert Initialization
expect(useInvalidDataSourceAuth).toHaveBeenCalledWith({ pluginId, provider })
// Act
act(() => {
result.current.handleAuthUpdate()
})
// Assert Invalidation
expect(mockInvalidateDataSourceListAuth).toHaveBeenCalledTimes(1)
expect(mockInvalidDefaultDataSourceListAuth).toHaveBeenCalledTimes(1)
expect(mockInvalidateDataSourceList).toHaveBeenCalledTimes(1)
expect(mockInvalidateDataSourceAuth).toHaveBeenCalledTimes(1)
})
it('should maintain stable handleAuthUpdate reference if dependencies do not change', () => {
// Arrange
const props = {
pluginId: 'stable-plugin',
provider: 'stable-provider',
}
const { result, rerender } = renderHook(
({ pluginId, provider }) => useDataSourceAuthUpdate({ pluginId, provider }),
{ initialProps: props },
)
const firstHandleAuthUpdate = result.current.handleAuthUpdate
// Act
rerender(props)
// Assert
expect(result.current.handleAuthUpdate).toBe(firstHandleAuthUpdate)
})
})
})

View File

@@ -0,0 +1,181 @@
import type { Plugin } from '@/app/components/plugins/types'
import { renderHook } from '@testing-library/react'
import {
useMarketplacePlugins,
useMarketplacePluginsByCollectionId,
} from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useMarketplaceAllPlugins } from './use-marketplace-all-plugins'
/**
* useMarketplaceAllPlugins Hook Tests
* This hook combines search results and collection-specific plugins from the marketplace.
*/
type UseMarketplacePluginsReturn = ReturnType<typeof useMarketplacePlugins>
type UseMarketplacePluginsByCollectionIdReturn = ReturnType<typeof useMarketplacePluginsByCollectionId>
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: vi.fn(),
useMarketplacePluginsByCollectionId: vi.fn(),
}))
describe('useMarketplaceAllPlugins', () => {
const mockQueryPlugins = vi.fn()
const mockQueryPluginsWithDebounced = vi.fn()
const mockResetPlugins = vi.fn()
const mockCancelQueryPluginsWithDebounced = vi.fn()
const mockFetchNextPage = vi.fn()
const createBasePluginsMock = (overrides: Partial<UseMarketplacePluginsReturn> = {}): UseMarketplacePluginsReturn => ({
plugins: [],
total: 0,
resetPlugins: mockResetPlugins,
queryPlugins: mockQueryPlugins,
queryPluginsWithDebounced: mockQueryPluginsWithDebounced,
cancelQueryPluginsWithDebounced: mockCancelQueryPluginsWithDebounced,
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: mockFetchNextPage,
page: 1,
...overrides,
} as UseMarketplacePluginsReturn)
const createBaseCollectionMock = (overrides: Partial<UseMarketplacePluginsByCollectionIdReturn> = {}): UseMarketplacePluginsByCollectionIdReturn => ({
plugins: [],
isLoading: false,
isSuccess: true,
...overrides,
} as UseMarketplacePluginsByCollectionIdReturn)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useMarketplacePlugins).mockReturnValue(createBasePluginsMock())
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(createBaseCollectionMock())
})
describe('Search Interactions', () => {
it('should call queryPlugins when no searchText is provided', () => {
// Arrange
const providers = [{ plugin_id: 'p1' }]
const searchText = ''
// Act
renderHook(() => useMarketplaceAllPlugins(providers, searchText))
// Assert
expect(mockQueryPlugins).toHaveBeenCalledWith({
query: '',
category: PluginCategoryEnum.datasource,
type: 'plugin',
page_size: 1000,
exclude: ['p1'],
sort_by: 'install_count',
sort_order: 'DESC',
})
})
it('should call queryPluginsWithDebounced when searchText is provided', () => {
// Arrange
const providers = [{ plugin_id: 'p1' }]
const searchText = 'search term'
// Act
renderHook(() => useMarketplaceAllPlugins(providers, searchText))
// Assert
expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({
query: 'search term',
category: PluginCategoryEnum.datasource,
exclude: ['p1'],
type: 'plugin',
sort_by: 'install_count',
sort_order: 'DESC',
})
})
})
describe('Plugin Filtering and Combination', () => {
it('should combine collection plugins and search results, filtering duplicates and bundles', () => {
// Arrange
const providers = [{ plugin_id: 'p-excluded' }]
const searchText = ''
const p1 = { plugin_id: 'p1', type: 'plugin' } as Plugin
const pExcluded = { plugin_id: 'p-excluded', type: 'plugin' } as Plugin
const p2 = { plugin_id: 'p2', type: 'plugin' } as Plugin
const p3Bundle = { plugin_id: 'p3', type: 'bundle' } as Plugin
const collectionPlugins = [p1, pExcluded]
const searchPlugins = [p1, p2, p3Bundle]
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ plugins: collectionPlugins }),
)
vi.mocked(useMarketplacePlugins).mockReturnValue(
createBasePluginsMock({ plugins: searchPlugins }),
)
// Act
const { result } = renderHook(() => useMarketplaceAllPlugins(providers, searchText))
// Assert: pExcluded is removed, p1 is duplicated (so kept once), p2 is added, p3 is bundle (skipped)
expect(result.current.plugins).toHaveLength(2)
expect(result.current.plugins.map(p => p.plugin_id)).toEqual(['p1', 'p2'])
})
it('should handle undefined plugins gracefully', () => {
// Arrange
vi.mocked(useMarketplacePlugins).mockReturnValue(
createBasePluginsMock({ plugins: undefined as unknown as Plugin[] }),
)
// Act
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
// Assert
expect(result.current.plugins).toEqual([])
})
})
describe('Loading State Management', () => {
it('should return isLoading true if either hook is loading', () => {
// Case 1: Collection hook is loading
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ isLoading: true }),
)
vi.mocked(useMarketplacePlugins).mockReturnValue(createBasePluginsMock({ isLoading: false }))
const { result, rerender } = renderHook(
({ providers, searchText }) => useMarketplaceAllPlugins(providers, searchText),
{
initialProps: { providers: [] as { plugin_id: string }[], searchText: '' },
},
)
expect(result.current.isLoading).toBe(true)
// Case 2: Plugins hook is loading
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ isLoading: false }),
)
vi.mocked(useMarketplacePlugins).mockReturnValue(createBasePluginsMock({ isLoading: true }))
rerender({ providers: [], searchText: '' })
expect(result.current.isLoading).toBe(true)
// Case 3: Both hooks are loading
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ isLoading: true }),
)
rerender({ providers: [], searchText: '' })
expect(result.current.isLoading).toBe(true)
// Case 4: Neither hook is loading
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ isLoading: false }),
)
vi.mocked(useMarketplacePlugins).mockReturnValue(createBasePluginsMock({ isLoading: false }))
rerender({ providers: [], searchText: '' })
expect(result.current.isLoading).toBe(false)
})
})
})

View File

@@ -0,0 +1,219 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { DataSourceAuth } from './types'
import { render, screen } from '@testing-library/react'
import { useTheme } from 'next-themes'
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useGetDataSourceListAuth, useGetDataSourceOAuthUrl } from '@/service/use-datasource'
import { defaultSystemFeatures } from '@/types/feature'
import { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from './hooks'
import DataSourcePage from './index'
/**
* DataSourcePage Component Tests
* Using Unit approach to focus on page-level layout and conditional rendering.
*/
// Mock external dependencies
vi.mock('next-themes', () => ({
useTheme: vi.fn(),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(),
useGetDataSourceOAuthUrl: vi.fn(),
}))
vi.mock('./hooks', () => ({
useDataSourceAuthUpdate: vi.fn(),
useMarketplaceAllPlugins: vi.fn(),
}))
vi.mock('@/app/components/plugins/plugin-auth', () => ({
usePluginAuthAction: vi.fn(),
ApiKeyModal: () => <div data-testid="mock-api-key-modal" />,
AuthCategory: { datasource: 'datasource' },
}))
describe('DataSourcePage Component', () => {
const mockProviders: DataSourceAuth[] = [
{
author: 'Dify',
provider: 'dify',
plugin_id: 'plugin-1',
plugin_unique_identifier: 'unique-1',
icon: 'icon-1',
name: 'Dify Source',
label: { en_US: 'Dify Source', zh_Hans: 'zh_hans_dify_source' },
description: { en_US: 'Dify Description', zh_Hans: 'zh_hans_dify_description' },
credentials_list: [],
},
{
author: 'Partner',
provider: 'partner',
plugin_id: 'plugin-2',
plugin_unique_identifier: 'unique-2',
icon: 'icon-2',
name: 'Partner Source',
label: { en_US: 'Partner Source', zh_Hans: 'zh_hans_partner_source' },
description: { en_US: 'Partner Description', zh_Hans: 'zh_hans_partner_description' },
credentials_list: [],
},
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useTheme).mockReturnValue({ theme: 'light' } as unknown as ReturnType<typeof useTheme>)
vi.mocked(useRenderI18nObject).mockReturnValue((obj: Record<string, string>) => obj?.en_US || '')
vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ mutateAsync: vi.fn() } as unknown as ReturnType<typeof useGetDataSourceOAuthUrl>)
vi.mocked(useDataSourceAuthUpdate).mockReturnValue({ handleAuthUpdate: vi.fn() })
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({ plugins: [], isLoading: false })
vi.mocked(usePluginAuthAction).mockReturnValue({
deleteCredentialId: null,
doingAction: false,
handleConfirm: vi.fn(),
handleEdit: vi.fn(),
handleRemove: vi.fn(),
handleRename: vi.fn(),
handleSetDefault: vi.fn(),
editValues: null,
setEditValues: vi.fn(),
openConfirm: vi.fn(),
closeConfirm: vi.fn(),
pendingOperationCredentialId: { current: null },
} as unknown as ReturnType<typeof usePluginAuthAction>)
})
describe('Initial View Rendering', () => {
it('should render an empty view when no data is available and marketplace is disabled', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: false },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: undefined,
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.queryByText('Dify Source')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.installDataSourceProvider')).not.toBeInTheDocument()
})
})
describe('Data Source List Rendering', () => {
it('should render Card components for each data source returned from the API', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: false },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: mockProviders },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.getByText('Dify Source')).toBeInTheDocument()
expect(screen.getByText('Partner Source')).toBeInTheDocument()
})
})
describe('Marketplace Integration', () => {
it('should render the InstallFromMarketplace component when enable_marketplace feature is enabled', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: mockProviders },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.discoverMore')).toBeInTheDocument()
})
it('should pass an empty array to InstallFromMarketplace if data result is missing but marketplace is enabled', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: undefined,
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
})
it('should handle the case where data exists but result is an empty array', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: [] },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.queryByText('Dify Source')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
})
it('should handle the case where systemFeatures is missing (edge case for coverage)', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: {},
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: [] },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.queryByText('common.modelProvider.installDataSourceProvider')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,177 @@
import type { DataSourceAuth } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { useTheme } from 'next-themes'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useMarketplaceAllPlugins } from './hooks'
import InstallFromMarketplace from './install-from-marketplace'
/**
* InstallFromMarketplace Component Tests
* Using Unit approach to focus on the component's internal state and conditional rendering.
*/
// Mock external dependencies
vi.mock('next-themes', () => ({
useTheme: vi.fn(),
}))
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
<a href={href} data-testid="mock-link">{children}</a>
),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: vi.fn((path: string, { theme }: { theme: string }) => `https://marketplace.url${path}?theme=${theme}`),
}))
// Mock marketplace components
vi.mock('@/app/components/plugins/marketplace/list', () => ({
default: ({ plugins, cardRender, cardContainerClassName, emptyClassName }: {
plugins: Plugin[]
cardRender: (p: Plugin) => React.ReactNode
cardContainerClassName?: string
emptyClassName?: string
}) => (
<div data-testid="mock-list" className={cardContainerClassName}>
{plugins.length === 0 && <div className={emptyClassName} aria-label="empty-state" />}
{plugins.map(plugin => (
<div key={plugin.plugin_id} data-testid={`list-item-${plugin.plugin_id}`}>
{cardRender(plugin)}
</div>
))}
</div>
),
}))
vi.mock('@/app/components/plugins/provider-card', () => ({
default: ({ payload }: { payload: Plugin }) => (
<div data-testid={`mock-provider-card-${payload.plugin_id}`}>
{payload.name}
</div>
),
}))
vi.mock('./hooks', () => ({
useMarketplaceAllPlugins: vi.fn(),
}))
describe('InstallFromMarketplace Component', () => {
const mockProviders: DataSourceAuth[] = [
{
author: 'Author',
provider: 'provider',
plugin_id: 'p1',
plugin_unique_identifier: 'u1',
icon: 'icon',
name: 'name',
label: { en_US: 'Label', zh_Hans: '标签' },
description: { en_US: 'Desc', zh_Hans: '描述' },
credentials_list: [],
},
]
const mockPlugins: Plugin[] = [
{
type: 'plugin',
plugin_id: 'plugin-1',
name: 'Plugin 1',
category: PluginCategoryEnum.datasource,
// ...other minimal fields
} as Plugin,
{
type: 'bundle',
plugin_id: 'bundle-1',
name: 'Bundle 1',
category: PluginCategoryEnum.datasource,
} as Plugin,
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useTheme).mockReturnValue({
theme: 'light',
setTheme: vi.fn(),
themes: ['light', 'dark'],
systemTheme: 'light',
resolvedTheme: 'light',
} as unknown as ReturnType<typeof useTheme>)
})
describe('Rendering', () => {
it('should render correctly when not loading and not collapsed', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: mockPlugins,
isLoading: false,
})
// Act
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
// Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.discoverMore')).toBeInTheDocument()
expect(screen.getByTestId('mock-link')).toHaveAttribute('href', 'https://marketplace.url?theme=light')
expect(screen.getByTestId('mock-list')).toBeInTheDocument()
expect(screen.getByTestId('mock-provider-card-plugin-1')).toBeInTheDocument()
expect(screen.queryByTestId('mock-provider-card-bundle-1')).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('should show loading state when marketplace plugins are loading and component is not collapsed', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: [],
isLoading: true,
})
// Act
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
// Assert
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('mock-list')).not.toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should toggle collapse state when clicking the header', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: mockPlugins,
isLoading: false,
})
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
const toggleHeader = screen.getByText('common.modelProvider.installDataSourceProvider')
// Act (Collapse)
fireEvent.click(toggleHeader)
// Assert
expect(screen.queryByTestId('mock-list')).not.toBeInTheDocument()
// Act (Expand)
fireEvent.click(toggleHeader)
// Assert
expect(screen.getByTestId('mock-list')).toBeInTheDocument()
})
it('should not show loading state even if isLoading is true when component is collapsed', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: [],
isLoading: true,
})
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
const toggleHeader = screen.getByText('common.modelProvider.installDataSourceProvider')
// Act (Collapse)
fireEvent.click(toggleHeader)
// Assert
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,153 @@
import type { DataSourceCredential } from './types'
import { fireEvent, render, screen } from '@testing-library/react'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Item from './item'
/**
* Item Component Tests
* Using Unit approach to focus on the renaming logic and view state.
*/
// Helper to trigger rename via the real Operator component's dropdown
const triggerRename = async () => {
const dropdownTrigger = screen.getByRole('button')
fireEvent.click(dropdownTrigger)
const renameOption = await screen.findByText('common.operation.rename')
fireEvent.click(renameOption)
}
describe('Item Component', () => {
const mockOnAction = vi.fn()
const mockCredentialItem: DataSourceCredential = {
id: 'test-id',
name: 'Test Credential',
credential: {},
type: CredentialTypeEnum.OAUTH2,
is_default: false,
avatar_url: '',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Initial View Mode', () => {
it('should render the credential name and "connected" status', () => {
// Act
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
// Assert
expect(screen.getByText('Test Credential')).toBeInTheDocument()
expect(screen.getByText('connected')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument() // Dropdown trigger
})
})
describe('Rename Mode Interactions', () => {
it('should switch to rename mode when Trigger Rename is clicked', async () => {
// Arrange
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
// Act
await triggerRename()
expect(screen.getByPlaceholderText('common.placeholder.input')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
})
it('should update rename input value when changed', async () => {
// Arrange
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
await triggerRename()
const input = screen.getByPlaceholderText('common.placeholder.input')
// Act
fireEvent.change(input, { target: { value: 'Updated Name' } })
// Assert
expect(input).toHaveValue('Updated Name')
})
it('should call onAction with "rename" and correct payload when Save is clicked', async () => {
// Arrange
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
await triggerRename()
const input = screen.getByPlaceholderText('common.placeholder.input')
fireEvent.change(input, { target: { value: 'New Name' } })
// Act
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith(
'rename',
mockCredentialItem,
{
credential_id: 'test-id',
name: 'New Name',
},
)
// Should switch back to view mode
expect(screen.queryByPlaceholderText('common.placeholder.input')).not.toBeInTheDocument()
expect(screen.getByText('Test Credential')).toBeInTheDocument()
})
it('should exit rename mode without calling onAction when Cancel is clicked', async () => {
// Arrange
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
await triggerRename()
const input = screen.getByPlaceholderText('common.placeholder.input')
fireEvent.change(input, { target: { value: 'Cancelled Name' } })
// Act
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(mockOnAction).not.toHaveBeenCalled()
// Should switch back to view mode
expect(screen.queryByPlaceholderText('common.placeholder.input')).not.toBeInTheDocument()
expect(screen.getByText('Test Credential')).toBeInTheDocument()
})
})
describe('Event Bubbling', () => {
it('should stop event propagation when interacting with rename mode elements', async () => {
// Arrange
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />
</div>,
)
// Act & Assert
// We need to enter rename mode first
await triggerRename()
parentClick.mockClear()
fireEvent.click(screen.getByPlaceholderText('common.placeholder.input'))
expect(parentClick).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('common.operation.save'))
expect(parentClick).not.toHaveBeenCalled()
// Re-enter rename mode for cancel test
await triggerRename()
parentClick.mockClear()
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(parentClick).not.toHaveBeenCalled()
})
})
describe('Error Handling', () => {
it('should not throw if onAction is missing', async () => {
// Arrange & Act
// @ts-expect-error - Testing runtime tolerance for missing prop
render(<Item credentialItem={mockCredentialItem} onAction={undefined} />)
await triggerRename()
// Assert
expect(() => fireEvent.click(screen.getByText('common.operation.save'))).not.toThrow()
})
})
})

View File

@@ -0,0 +1,145 @@
import type { DataSourceCredential } from './types'
import { fireEvent, render, screen } from '@testing-library/react'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Operator from './operator'
/**
* Operator Component Tests
* Using Unit approach with mocked Dropdown to isolate item rendering logic.
*/
// Helper to open dropdown
const openDropdown = () => {
fireEvent.click(screen.getByRole('button'))
}
describe('Operator Component', () => {
const mockOnAction = vi.fn()
const mockOnRename = vi.fn()
const createMockCredential = (type: CredentialTypeEnum): DataSourceCredential => ({
id: 'test-id',
name: 'Test Credential',
credential: {},
type,
is_default: false,
avatar_url: '',
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('Conditional Action Rendering', () => {
it('should render correct actions for API_KEY type', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.API_KEY)
// Act
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
openDropdown()
// Assert
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
expect(screen.queryByText('common.operation.rename')).not.toBeInTheDocument()
expect(screen.queryByText('common.dataSource.notion.changeAuthorizedPages')).not.toBeInTheDocument()
})
it('should render correct actions for OAUTH2 type', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.OAUTH2)
// Act
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
openDropdown()
// Assert
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
expect(screen.getByText('common.operation.rename')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.changeAuthorizedPages')).toBeInTheDocument()
expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
})
})
describe('Action Callbacks', () => {
it('should call onRename when "rename" action is selected', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.OAUTH2)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('common.operation.rename'))
// Assert
expect(mockOnRename).toHaveBeenCalledTimes(1)
expect(mockOnAction).not.toHaveBeenCalled()
})
it('should handle missing onRename gracefully when "rename" action is selected', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.OAUTH2)
render(<Operator credentialItem={credential} onAction={mockOnAction} />)
// Act & Assert
openDropdown()
const renameBtn = await screen.findByText('common.operation.rename')
expect(() => fireEvent.click(renameBtn)).not.toThrow()
})
it('should call onAction for "setDefault" action', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.API_KEY)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('plugin.auth.setDefault'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential)
})
it('should call onAction for "edit" action', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.API_KEY)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('common.operation.edit'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('edit', credential)
})
it('should call onAction for "change" action', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.OAUTH2)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('common.dataSource.notion.changeAuthorizedPages'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('change', credential)
})
it('should call onAction for "delete" action', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.API_KEY)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('common.operation.remove'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('delete', credential)
})
})
})

View File

@@ -0,0 +1,466 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { AppContextValue } from '@/context/app-context'
import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { useDataSourceIntegrates, useInvalidDataSourceIntegrates, useNotionConnection } from '@/service/use-common'
import DataSourceNotion from './index'
/**
* DataSourceNotion Component Tests
* Using Unit approach with real Panel and sibling components to test Notion integration logic.
*/
type MockQueryResult<T> = UseQueryResult<T, Error>
// Mock dependencies
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/common', () => ({
syncDataSourceNotion: vi.fn(),
updateDataSourceNotionAction: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useDataSourceIntegrates: vi.fn(),
useNotionConnection: vi.fn(),
useInvalidDataSourceIntegrates: vi.fn(),
}))
describe('DataSourceNotion Component', () => {
const mockWorkspaces: TDataSourceNotion[] = [
{
id: 'ws-1',
provider: 'notion',
is_bound: true,
source_info: {
workspace_name: 'Workspace 1',
workspace_icon: 'https://example.com/icon-1.png',
workspace_id: 'notion-ws-1',
total: 10,
pages: [],
},
},
]
const baseAppContext: AppContextValue = {
userProfile: { id: 'test-user-id', name: 'test-user', email: 'test@example.com', avatar: '', avatar_url: '', is_password_set: true },
mutateUserProfile: vi.fn(),
currentWorkspace: { id: 'ws-id', name: 'Workspace', plan: 'basic', status: 'normal', created_at: 0, role: 'owner', providers: [], trial_credits: 0, trial_credits_used: 0, next_credit_reset_date: 0 },
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: { current_version: '0.1.0', latest_version: '0.1.1', version: '0.1.1', release_date: '', release_notes: '', can_auto_update: false, current_env: 'test' },
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
/* eslint-disable-next-line ts/no-explicit-any */
const mockQuerySuccess = <T,>(data: T): MockQueryResult<T> => ({ data, isSuccess: true, isError: false, isLoading: false, isPending: false, status: 'success', error: null, fetchStatus: 'idle' } as any)
/* eslint-disable-next-line ts/no-explicit-any */
const mockQueryPending = <T,>(): MockQueryResult<T> => ({ data: undefined, isSuccess: false, isError: false, isLoading: true, isPending: true, status: 'pending', error: null, fetchStatus: 'fetching' } as any)
const originalLocation = window.location
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue(baseAppContext)
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: [] }))
vi.mocked(useNotionConnection).mockReturnValue(mockQueryPending())
vi.mocked(useInvalidDataSourceIntegrates).mockReturnValue(vi.fn())
const locationMock = { href: '', assign: vi.fn() }
Object.defineProperty(window, 'location', { value: locationMock, writable: true, configurable: true })
// Clear document body to avoid toast leaks between tests
document.body.innerHTML = ''
})
afterEach(() => {
Object.defineProperty(window, 'location', { value: originalLocation, writable: true, configurable: true })
})
const getWorkspaceItem = (name: string) => {
const nameEl = screen.getByText(name)
return (nameEl.closest('div[class*="workspace-item"]') || nameEl.parentElement) as HTMLElement
}
describe('Rendering', () => {
it('should render with no workspaces initially and call integration hook', () => {
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('common.dataSource.notion.title')).toBeInTheDocument()
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: undefined })
})
it('should render with provided workspaces and pass initialData to hook', () => {
// Arrange
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
// Act
render(<DataSourceNotion workspaces={mockWorkspaces} />)
// Assert
expect(screen.getByText('common.dataSource.notion.connectedWorkspace')).toBeInTheDocument()
expect(screen.getByText('Workspace 1')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.connected')).toBeInTheDocument()
expect(screen.getByAltText('workspace icon')).toHaveAttribute('src', 'https://example.com/icon-1.png')
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: { data: mockWorkspaces } })
})
it('should handle workspaces prop being an empty array', () => {
// Act
render(<DataSourceNotion workspaces={[]} />)
// Assert
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: { data: [] } })
})
it('should handle optional workspaces configurations', () => {
// Branch: workspaces passed as undefined
const { rerender } = render(<DataSourceNotion workspaces={undefined} />)
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: undefined })
// Branch: workspaces passed as null
/* eslint-disable-next-line ts/no-explicit-any */
rerender(<DataSourceNotion workspaces={null as any} />)
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: undefined })
// Branch: workspaces passed as []
rerender(<DataSourceNotion workspaces={[]} />)
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: { data: [] } })
})
it('should handle cases where integrates data is loading or broken', () => {
// Act (Loading)
const { rerender } = render(<DataSourceNotion />)
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQueryPending())
rerender(<DataSourceNotion />)
// Assert
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
// Act (Broken)
const brokenData = {} as { data: TDataSourceNotion[] }
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess(brokenData))
rerender(<DataSourceNotion />)
// Assert
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
})
it('should handle integrates being nullish', () => {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: undefined, isSuccess: true } as any)
render(<DataSourceNotion />)
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
})
it('should handle integrates data being nullish', () => {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: { data: null }, isSuccess: true } as any)
render(<DataSourceNotion />)
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
})
it('should handle integrates data being valid', () => {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: { data: [{ id: '1', is_bound: true, source_info: { workspace_name: 'W', workspace_icon: 'https://example.com/i.png', total: 1, pages: [] } }] }, isSuccess: true } as any)
render(<DataSourceNotion />)
expect(screen.getByText('common.dataSource.notion.connectedWorkspace')).toBeInTheDocument()
})
it('should cover all possible falsy/nullish branches for integrates and workspaces', () => {
/* eslint-disable-next-line ts/no-explicit-any */
const { rerender } = render(<DataSourceNotion workspaces={null as any} />)
const integratesCases = [
undefined,
null,
{},
{ data: null },
{ data: undefined },
{ data: [] },
{ data: [mockWorkspaces[0]] },
{ data: false },
{ data: 0 },
{ data: '' },
123,
'string',
false,
]
integratesCases.forEach((val) => {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: val, isSuccess: true } as any)
/* eslint-disable-next-line ts/no-explicit-any */
rerender(<DataSourceNotion workspaces={null as any} />)
})
expect(useDataSourceIntegrates).toHaveBeenCalled()
})
})
describe('User Permissions', () => {
it('should pass readOnly as false when user is a manager', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({ ...baseAppContext, isCurrentWorkspaceManager: true })
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('common.dataSource.notion.title').closest('div')).not.toHaveClass('grayscale')
})
it('should pass readOnly as true when user is NOT a manager', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({ ...baseAppContext, isCurrentWorkspaceManager: false })
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('common.dataSource.connect')).toHaveClass('opacity-50', 'grayscale')
})
})
describe('Configure and Auth Actions', () => {
it('should handle configure action when user is workspace manager', () => {
// Arrange
render(<DataSourceNotion />)
// Act
fireEvent.click(screen.getByText('common.dataSource.connect'))
// Assert
expect(useNotionConnection).toHaveBeenCalledWith(true)
})
it('should block configure action when user is NOT workspace manager', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({ ...baseAppContext, isCurrentWorkspaceManager: false })
render(<DataSourceNotion />)
// Act
fireEvent.click(screen.getByText('common.dataSource.connect'))
// Assert
expect(useNotionConnection).toHaveBeenCalledWith(false)
})
it('should redirect if auth URL is available when "Auth Again" is clicked', async () => {
// Arrange
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'http://auth-url' }))
render(<DataSourceNotion />)
// Act
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)
// Assert
expect(window.location.href).toBe('http://auth-url')
})
it('should trigger connection flow if URL is missing when "Auth Again" is clicked', async () => {
// Arrange
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
render(<DataSourceNotion />)
// Act
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)
// Assert
expect(useNotionConnection).toHaveBeenCalledWith(true)
})
})
describe('Side Effects (Redirection and Toast)', () => {
it('should redirect automatically when connection data returns an http URL', async () => {
// Arrange
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'http://redirect-url' }))
// Act
render(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('http://redirect-url')
})
})
it('should show toast notification when connection data is "internal"', async () => {
// Arrange
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'internal' }))
// Act
render(<DataSourceNotion />)
// Assert
expect(await screen.findByText('common.dataSource.notion.integratedAlert')).toBeInTheDocument()
})
it('should handle various data types and missing properties in connection data correctly', async () => {
// Arrange & Act (Unknown string)
const { rerender } = render(<DataSourceNotion />)
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'unknown' }))
rerender(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
expect(screen.queryByText('common.dataSource.notion.integratedAlert')).not.toBeInTheDocument()
})
// Act (Broken object)
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({} as any))
rerender(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
})
// Act (Non-string)
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 123 } as any))
rerender(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
})
})
it('should redirect if data starts with "http" even if it is just "http"', async () => {
// Arrange
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'http' }))
// Act
render(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('http')
})
})
it('should skip side effect logic if connection data is an object but missing the "data" property', async () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({} as any)
// Act
render(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
})
})
it('should skip side effect logic if data.data is falsy', async () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({ data: { data: null } } as any)
// Act
render(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
})
})
})
describe('Additional Action Edge Cases', () => {
it('should cover all possible falsy/nullish branches for connection data in handleAuthAgain and useEffect', async () => {
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
render(<DataSourceNotion />)
const connectionCases = [
undefined,
null,
{},
{ data: undefined },
{ data: null },
{ data: '' },
{ data: 0 },
{ data: false },
{ data: 'http' },
{ data: 'internal' },
{ data: 'unknown' },
]
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())
})
})
describe('Edge Cases in Workspace Data', () => {
it('should render correctly with missing source_info optional fields', async () => {
// Arrange
const workspaceWithMissingInfo: TDataSourceNotion = {
id: 'ws-2',
provider: 'notion',
is_bound: false,
source_info: { workspace_name: 'Workspace 2', workspace_id: 'notion-ws-2', workspace_icon: null, pages: [] },
}
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: [workspaceWithMissingInfo] }))
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('Workspace 2')).toBeInTheDocument()
const workspaceItem = getWorkspaceItem('Workspace 2')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
expect(await screen.findByText('0 common.dataSource.notion.pagesAuthorized')).toBeInTheDocument()
})
it('should display inactive status correctly for unbound workspaces', () => {
// Arrange
const inactiveWS: TDataSourceNotion = {
id: 'ws-3',
provider: 'notion',
is_bound: false,
source_info: { workspace_name: 'Workspace 3', workspace_icon: 'https://example.com/icon-3.png', workspace_id: 'notion-ws-3', total: 5, pages: [] },
}
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: [inactiveWS] }))
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('common.dataSource.notion.disconnected')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,137 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { syncDataSourceNotion, updateDataSourceNotionAction } from '@/service/common'
import { useInvalidDataSourceIntegrates } from '@/service/use-common'
import Operate from './index'
/**
* Operate Component (Notion) Tests
* This component provides actions like Sync, Change Pages, and Remove for Notion data sources.
*/
// Mock services and toast
vi.mock('@/service/common', () => ({
syncDataSourceNotion: vi.fn(),
updateDataSourceNotionAction: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useInvalidDataSourceIntegrates: vi.fn(),
}))
describe('Operate Component (Notion)', () => {
const mockPayload = {
id: 'test-notion-id',
total: 5,
}
const mockOnAuthAgain = vi.fn()
const mockInvalidate = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useInvalidDataSourceIntegrates).mockReturnValue(mockInvalidate)
vi.mocked(syncDataSourceNotion).mockResolvedValue({ result: 'success' })
vi.mocked(updateDataSourceNotionAction).mockResolvedValue({ result: 'success' })
})
describe('Rendering', () => {
it('should render the menu button initially', () => {
// Act
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
// Assert
const menuButton = within(container).getByRole('button')
expect(menuButton).toBeInTheDocument()
expect(menuButton).not.toHaveClass('bg-state-base-hover')
})
it('should open the menu and show all options when clicked', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
const menuButton = within(container).getByRole('button')
// Act
fireEvent.click(menuButton)
// Assert
expect(await screen.findByText('common.dataSource.notion.changeAuthorizedPages')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.sync')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.remove')).toBeInTheDocument()
expect(screen.getByText(/5/)).toBeInTheDocument()
expect(screen.getByText(/common.dataSource.notion.pagesAuthorized/)).toBeInTheDocument()
expect(menuButton).toHaveClass('bg-state-base-hover')
})
})
describe('Menu Actions', () => {
it('should call onAuthAgain when Change Authorized Pages is clicked', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
fireEvent.click(within(container).getByRole('button'))
const option = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
// Act
fireEvent.click(option)
// Assert
expect(mockOnAuthAgain).toHaveBeenCalledTimes(1)
})
it('should call handleSync, show success toast, and invalidate cache when Sync is clicked', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
fireEvent.click(within(container).getByRole('button'))
const syncBtn = await screen.findByText('common.dataSource.notion.sync')
// Act
fireEvent.click(syncBtn)
// Assert
await waitFor(() => {
expect(syncDataSourceNotion).toHaveBeenCalledWith({
url: `/oauth/data-source/notion/${mockPayload.id}/sync`,
})
})
expect((await screen.findAllByText('common.api.success')).length).toBeGreaterThan(0)
expect(mockInvalidate).toHaveBeenCalledTimes(1)
})
it('should call handleRemove, show success toast, and invalidate cache when Remove is clicked', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
fireEvent.click(within(container).getByRole('button'))
const removeBtn = await screen.findByText('common.dataSource.notion.remove')
// Act
fireEvent.click(removeBtn)
// Assert
await waitFor(() => {
expect(updateDataSourceNotionAction).toHaveBeenCalledWith({
url: `/data-source/integrates/${mockPayload.id}/disable`,
})
})
expect((await screen.findAllByText('common.api.success')).length).toBeGreaterThan(0)
expect(mockInvalidate).toHaveBeenCalledTimes(1)
})
})
describe('State Transitions', () => {
it('should toggle the open class on the button based on menu visibility', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
const menuButton = within(container).getByRole('button')
// Act (Open)
fireEvent.click(menuButton)
// Assert
expect(menuButton).toHaveClass('bg-state-base-hover')
// Act (Close - click again)
fireEvent.click(menuButton)
// Assert
await waitFor(() => {
expect(menuButton).not.toHaveClass('bg-state-base-hover')
})
})
})
})

View File

@@ -0,0 +1,204 @@
import type { CommonResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createDataSourceApiKeyBinding } from '@/service/datasets'
import ConfigFirecrawlModal from './config-firecrawl-modal'
/**
* ConfigFirecrawlModal Component Tests
* Tests validation, save logic, and basic rendering for the Firecrawl configuration modal.
*/
vi.mock('@/service/datasets', () => ({
createDataSourceApiKeyBinding: vi.fn(),
}))
describe('ConfigFirecrawlModal Component', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Initial Rendering', () => {
it('should render the modal with all fields and buttons', () => {
// Act
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Assert
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
expect(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('https://api.firecrawl.dev')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.save/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /datasetCreation\.firecrawl\.getApiKeyLinkText/i })).toHaveAttribute('href', 'https://www.firecrawl.dev/account')
})
})
describe('Form Interactions', () => {
it('should update state when input fields change', async () => {
// Arrange
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
const baseUrlInput = screen.getByPlaceholderText('https://api.firecrawl.dev')
// Act
fireEvent.change(apiKeyInput, { target: { value: 'firecrawl-key' } })
fireEvent.change(baseUrlInput, { target: { value: 'https://custom.firecrawl.dev' } })
// Assert
expect(apiKeyInput).toHaveValue('firecrawl-key')
expect(baseUrlInput).toHaveValue('https://custom.firecrawl.dev')
})
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
describe('Validation', () => {
it('should show error when saving without API Key', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"API Key"}')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
it('should show error for invalid Base URL format', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const baseUrlInput = screen.getByPlaceholderText('https://api.firecrawl.dev')
// Act
await user.type(baseUrlInput, 'ftp://invalid-url.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.urlError')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
describe('Saving Logic', () => {
it('should save successfully with valid API Key and custom URL', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'valid-key')
await user.type(screen.getByPlaceholderText('https://api.firecrawl.dev'), 'http://my-firecrawl.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith({
category: 'website',
provider: 'firecrawl',
credentials: {
auth_type: 'bearer',
config: {
api_key: 'valid-key',
base_url: 'http://my-firecrawl.com',
},
},
})
})
await waitFor(() => {
expect(screen.getByText('common.api.success')).toBeInTheDocument()
expect(mockOnSaved).toHaveBeenCalled()
})
})
it('should use default Base URL if none is provided during save', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'test-key')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
credentials: expect.objectContaining({
config: expect.objectContaining({
base_url: 'https://api.firecrawl.dev',
}),
}),
}))
})
})
it('should ignore multiple save clicks while saving is in progress', async () => {
const user = userEvent.setup()
// Arrange
let resolveSave: (value: CommonResponse) => void
const savePromise = new Promise<CommonResponse>((resolve) => {
resolveSave = resolve
})
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(savePromise)
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'test-key')
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
// Act
await user.click(saveBtn)
await user.click(saveBtn)
// Assert
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Cleanup
resolveSave!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
})
it('should accept base_url starting with https://', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'test-key')
await user.type(screen.getByPlaceholderText('https://api.firecrawl.dev'), 'https://secure-firecrawl.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
credentials: expect.objectContaining({
config: expect.objectContaining({
base_url: 'https://secure-firecrawl.com',
}),
}),
}))
})
})
})
})

View File

@@ -0,0 +1,138 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DataSourceProvider } from '@/models/common'
import { createDataSourceApiKeyBinding } from '@/service/datasets'
import ConfigJinaReaderModal from './config-jina-reader-modal'
/**
* ConfigJinaReaderModal Component Tests
* Tests validation, save logic, and basic rendering for the Jina Reader configuration modal.
*/
vi.mock('@/service/datasets', () => ({
createDataSourceApiKeyBinding: vi.fn(),
}))
describe('ConfigJinaReaderModal Component', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Initial Rendering', () => {
it('should render the modal with API Key field and buttons', () => {
// Act
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Assert
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
expect(screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.save/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })).toHaveAttribute('href', 'https://jina.ai/reader/')
})
})
describe('Form Interactions', () => {
it('should update state when API Key field changes', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
// Act
await user.type(apiKeyInput, 'jina-test-key')
// Assert
expect(apiKeyInput).toHaveValue('jina-test-key')
})
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
describe('Validation', () => {
it('should show error when saving without API Key', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"API Key"}')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
describe('Saving Logic', () => {
it('should save successfully with valid API Key', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
// Act
await user.type(apiKeyInput, 'valid-jina-key')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith({
category: 'website',
provider: DataSourceProvider.jinaReader,
credentials: {
auth_type: 'bearer',
config: {
api_key: 'valid-jina-key',
},
},
})
})
await waitFor(() => {
expect(screen.getByText('common.api.success')).toBeInTheDocument()
expect(mockOnSaved).toHaveBeenCalled()
})
})
it('should ignore multiple save clicks while saving is in progress', async () => {
const user = userEvent.setup()
// Arrange
let resolveSave: (value: { result: 'success' }) => void
const savePromise = new Promise<{ result: 'success' }>((resolve) => {
resolveSave = resolve
})
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(savePromise)
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
await user.type(screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder'), 'test-key')
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
// Act
await user.click(saveBtn)
await user.click(saveBtn)
// Assert
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Cleanup
resolveSave!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
})
})
})

View File

@@ -0,0 +1,204 @@
import type { CommonResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createDataSourceApiKeyBinding } from '@/service/datasets'
import ConfigWatercrawlModal from './config-watercrawl-modal'
/**
* ConfigWatercrawlModal Component Tests
* Tests validation, save logic, and basic rendering for the Watercrawl configuration modal.
*/
vi.mock('@/service/datasets', () => ({
createDataSourceApiKeyBinding: vi.fn(),
}))
describe('ConfigWatercrawlModal Component', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Initial Rendering', () => {
it('should render the modal with all fields and buttons', () => {
// Act
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Assert
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
expect(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('https://app.watercrawl.dev')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.save/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /datasetCreation\.watercrawl\.getApiKeyLinkText/i })).toHaveAttribute('href', 'https://app.watercrawl.dev/')
})
})
describe('Form Interactions', () => {
it('should update state when input fields change', async () => {
// Arrange
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder')
const baseUrlInput = screen.getByPlaceholderText('https://app.watercrawl.dev')
// Act
fireEvent.change(apiKeyInput, { target: { value: 'water-key' } })
fireEvent.change(baseUrlInput, { target: { value: 'https://custom.watercrawl.dev' } })
// Assert
expect(apiKeyInput).toHaveValue('water-key')
expect(baseUrlInput).toHaveValue('https://custom.watercrawl.dev')
})
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
describe('Validation', () => {
it('should show error when saving without API Key', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"API Key"}')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
it('should show error for invalid Base URL format', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const baseUrlInput = screen.getByPlaceholderText('https://app.watercrawl.dev')
// Act
await user.type(baseUrlInput, 'ftp://invalid-url.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.urlError')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
describe('Saving Logic', () => {
it('should save successfully with valid API Key and custom URL', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'valid-key')
await user.type(screen.getByPlaceholderText('https://app.watercrawl.dev'), 'http://my-watercrawl.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith({
category: 'website',
provider: 'watercrawl',
credentials: {
auth_type: 'x-api-key',
config: {
api_key: 'valid-key',
base_url: 'http://my-watercrawl.com',
},
},
})
})
await waitFor(() => {
expect(screen.getByText('common.api.success')).toBeInTheDocument()
expect(mockOnSaved).toHaveBeenCalled()
})
})
it('should use default Base URL if none is provided during save', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'test-api-key')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
credentials: expect.objectContaining({
config: expect.objectContaining({
base_url: 'https://app.watercrawl.dev',
}),
}),
}))
})
})
it('should ignore multiple save clicks while saving is in progress', async () => {
const user = userEvent.setup()
// Arrange
let resolveSave: (value: CommonResponse) => void
const savePromise = new Promise<CommonResponse>((resolve) => {
resolveSave = resolve
})
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(savePromise)
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'test-api-key')
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
// Act
await user.click(saveBtn)
await user.click(saveBtn)
// Assert
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Cleanup
resolveSave!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
})
it('should accept base_url starting with https://', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'test-api-key')
await user.type(screen.getByPlaceholderText('https://app.watercrawl.dev'), 'https://secure-watercrawl.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
credentials: expect.objectContaining({
config: expect.objectContaining({
base_url: 'https://secure-watercrawl.com',
}),
}),
}))
})
})
})
})

View File

@@ -0,0 +1,198 @@
import type { AppContextValue } from '@/context/app-context'
import type { CommonResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { DataSourceProvider } from '@/models/common'
import { fetchDataSources, removeDataSourceApiKeyBinding } from '@/service/datasets'
import DataSourceWebsite from './index'
/**
* DataSourceWebsite Component Tests
* Tests integration of multiple website scraping providers (Firecrawl, WaterCrawl, Jina Reader).
*/
type DataSourcesResponse = CommonResponse & {
sources: Array<{ id: string, provider: DataSourceProvider }>
}
// Mock App Context
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
// Mock Service calls
vi.mock('@/service/datasets', () => ({
fetchDataSources: vi.fn(),
removeDataSourceApiKeyBinding: vi.fn(),
createDataSourceApiKeyBinding: vi.fn(),
}))
describe('DataSourceWebsite Component', () => {
const mockSources = [
{ id: '1', provider: DataSourceProvider.fireCrawl },
{ id: '2', provider: DataSourceProvider.waterCrawl },
{ id: '3', provider: DataSourceProvider.jinaReader },
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({ isCurrentWorkspaceManager: true } as unknown as AppContextValue)
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [] } as DataSourcesResponse)
})
// Helper to render and wait for initial fetch to complete
const renderAndWait = async (provider: DataSourceProvider) => {
const result = render(<DataSourceWebsite provider={provider} />)
await waitFor(() => expect(fetchDataSources).toHaveBeenCalled())
return result
}
describe('Data Initialization', () => {
it('should fetch data sources on mount and reflect configured status', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: mockSources } as DataSourcesResponse)
// Act
await renderAndWait(DataSourceProvider.fireCrawl)
// Assert
expect(screen.getByText('common.dataSource.website.configuredCrawlers')).toBeInTheDocument()
})
it('should pass readOnly status based on workspace manager permissions', async () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({ isCurrentWorkspaceManager: false } as unknown as AppContextValue)
// Act
await renderAndWait(DataSourceProvider.fireCrawl)
// Assert
expect(screen.getByText('common.dataSource.configure')).toHaveClass('cursor-default')
})
})
describe('Provider Specific Rendering', () => {
it('should render correct logo and name for Firecrawl', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[0]] } as DataSourcesResponse)
// Act
await renderAndWait(DataSourceProvider.fireCrawl)
// Assert
expect(await screen.findByText('Firecrawl')).toBeInTheDocument()
expect(screen.getByText('🔥')).toBeInTheDocument()
})
it('should render correct logo and name for WaterCrawl', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[1]] } as DataSourcesResponse)
// Act
await renderAndWait(DataSourceProvider.waterCrawl)
// Assert
const elements = await screen.findAllByText('WaterCrawl')
expect(elements.length).toBeGreaterThanOrEqual(1)
})
it('should render correct logo and name for Jina Reader', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[2]] } as DataSourcesResponse)
// Act
await renderAndWait(DataSourceProvider.jinaReader)
// Assert
const elements = await screen.findAllByText('Jina Reader')
expect(elements.length).toBeGreaterThanOrEqual(1)
})
})
describe('Modal Interactions', () => {
it('should manage opening and closing of configuration modals', async () => {
// Arrange
await renderAndWait(DataSourceProvider.fireCrawl)
// Act (Open)
fireEvent.click(screen.getByText('common.dataSource.configure'))
// Assert
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
// Act (Cancel)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
})
it('should re-fetch sources after saving configuration (Watercrawl)', async () => {
// Arrange
await renderAndWait(DataSourceProvider.waterCrawl)
fireEvent.click(screen.getByText('common.dataSource.configure'))
vi.mocked(fetchDataSources).mockClear()
// Act
fireEvent.change(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(fetchDataSources).toHaveBeenCalled()
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
})
})
it('should re-fetch sources after saving configuration (Jina Reader)', async () => {
// Arrange
await renderAndWait(DataSourceProvider.jinaReader)
fireEvent.click(screen.getByText('common.dataSource.configure'))
vi.mocked(fetchDataSources).mockClear()
// Act
fireEvent.change(screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder'), { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(fetchDataSources).toHaveBeenCalled()
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
})
})
})
describe('Management Actions', () => {
it('should handle successful data source removal with toast notification', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[0]] } as DataSourcesResponse)
vi.mocked(removeDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' } as CommonResponse)
await renderAndWait(DataSourceProvider.fireCrawl)
await waitFor(() => expect(screen.getByText('common.dataSource.website.configuredCrawlers')).toBeInTheDocument())
// Act
const removeBtn = screen.getByText('Firecrawl').parentElement?.querySelector('svg')?.parentElement
if (removeBtn)
fireEvent.click(removeBtn)
// Assert
await waitFor(() => {
expect(removeDataSourceApiKeyBinding).toHaveBeenCalledWith('1')
expect(screen.getByText('common.api.remove')).toBeInTheDocument()
})
expect(screen.queryByText('common.dataSource.website.configuredCrawlers')).not.toBeInTheDocument()
})
it('should skip removal API call if no data source ID is present', async () => {
// Arrange
await renderAndWait(DataSourceProvider.fireCrawl)
// Act
const removeBtn = screen.queryByText('Firecrawl')?.parentElement?.querySelector('svg')?.parentElement
if (removeBtn)
fireEvent.click(removeBtn)
// Assert
expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,213 @@
import type { ConfigItemType } from './config-item'
import { fireEvent, render, screen } from '@testing-library/react'
import ConfigItem from './config-item'
import { DataSourceType } from './types'
/**
* ConfigItem Component Tests
* Tests rendering of individual configuration items for Notion and Website data sources.
*/
// Mock Operate component to isolate ConfigItem unit tests.
vi.mock('../data-source-notion/operate', () => ({
default: ({ onAuthAgain, payload }: { onAuthAgain: () => void, payload: { id: string, total: number } }) => (
<div data-testid="mock-operate">
<button onClick={onAuthAgain} data-testid="operate-auth-btn">Auth Again</button>
<span data-testid="operate-payload">{JSON.stringify(payload)}</span>
</div>
),
}))
describe('ConfigItem Component', () => {
const mockOnRemove = vi.fn()
const mockOnChangeAuthorizedPage = vi.fn()
const MockLogo = (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="mock-logo" {...props} />
const baseNotionPayload: ConfigItemType = {
id: 'notion-1',
logo: MockLogo,
name: 'Notion Workspace',
isActive: true,
notionConfig: { total: 5 },
}
const baseWebsitePayload: ConfigItemType = {
id: 'website-1',
logo: MockLogo,
name: 'My Website',
isActive: true,
}
afterEach(() => {
vi.clearAllMocks()
})
describe('Notion Configuration', () => {
it('should render active Notion config item with connected status and operator', () => {
// Act
render(
<ConfigItem
type={DataSourceType.notion}
payload={baseNotionPayload}
onRemove={mockOnRemove}
notionActions={{ onChangeAuthorizedPage: mockOnChangeAuthorizedPage }}
readOnly={false}
/>,
)
// Assert
expect(screen.getByTestId('mock-logo')).toBeInTheDocument()
expect(screen.getByText('Notion Workspace')).toBeInTheDocument()
const statusText = screen.getByText('common.dataSource.notion.connected')
expect(statusText).toHaveClass('text-util-colors-green-green-600')
expect(screen.getByTestId('operate-payload')).toHaveTextContent(JSON.stringify({ id: 'notion-1', total: 5 }))
})
it('should render inactive Notion config item with disconnected status', () => {
// Arrange
const inactivePayload = { ...baseNotionPayload, isActive: false }
// Act
render(
<ConfigItem
type={DataSourceType.notion}
payload={inactivePayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Assert
const statusText = screen.getByText('common.dataSource.notion.disconnected')
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
})
it('should handle auth action through the Operate component', () => {
// Arrange
render(
<ConfigItem
type={DataSourceType.notion}
payload={baseNotionPayload}
onRemove={mockOnRemove}
notionActions={{ onChangeAuthorizedPage: mockOnChangeAuthorizedPage }}
readOnly={false}
/>,
)
// Act
fireEvent.click(screen.getByTestId('operate-auth-btn'))
// Assert
expect(mockOnChangeAuthorizedPage).toHaveBeenCalled()
})
it('should fallback to 0 total if notionConfig is missing', () => {
// Arrange
const payloadNoConfig = { ...baseNotionPayload, notionConfig: undefined }
// Act
render(
<ConfigItem
type={DataSourceType.notion}
payload={payloadNoConfig}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Assert
expect(screen.getByTestId('operate-payload')).toHaveTextContent(JSON.stringify({ id: 'notion-1', total: 0 }))
})
it('should handle missing notionActions safely without crashing', () => {
// Arrange
render(
<ConfigItem
type={DataSourceType.notion}
payload={baseNotionPayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Act & Assert
expect(() => fireEvent.click(screen.getByTestId('operate-auth-btn'))).not.toThrow()
})
})
describe('Website Configuration', () => {
it('should render active Website config item and hide operator', () => {
// Act
render(
<ConfigItem
type={DataSourceType.website}
payload={baseWebsitePayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Assert
expect(screen.getByText('common.dataSource.website.active')).toBeInTheDocument()
expect(screen.queryByTestId('mock-operate')).not.toBeInTheDocument()
})
it('should render inactive Website config item', () => {
// Arrange
const inactivePayload = { ...baseWebsitePayload, isActive: false }
// Act
render(
<ConfigItem
type={DataSourceType.website}
payload={inactivePayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Assert
const statusText = screen.getByText('common.dataSource.website.inactive')
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
})
it('should show remove button and trigger onRemove when clicked (not read-only)', () => {
// Arrange
const { container } = render(
<ConfigItem
type={DataSourceType.website}
payload={baseWebsitePayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Note: This selector is brittle but necessary since the delete button lacks
// accessible attributes (data-testid, aria-label). Ideally, the component should
// be updated to include proper accessibility attributes.
const deleteBtn = container.querySelector('div[class*="cursor-pointer"]') as HTMLElement
// Act
fireEvent.click(deleteBtn)
// Assert
expect(mockOnRemove).toHaveBeenCalled()
})
it('should hide remove button in read-only mode', () => {
// Arrange
const { container } = render(
<ConfigItem
type={DataSourceType.website}
payload={baseWebsitePayload}
onRemove={mockOnRemove}
readOnly={true}
/>,
)
// Assert
const deleteBtn = container.querySelector('div[class*="cursor-pointer"]')
expect(deleteBtn).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,226 @@
import type { ConfigItemType } from './config-item'
import { fireEvent, render, screen } from '@testing-library/react'
import { DataSourceProvider } from '@/models/common'
import Panel from './index'
import { DataSourceType } from './types'
/**
* Panel Component Tests
* Tests layout, conditional rendering, and interactions for data source panels (Notion and Website).
*/
vi.mock('../data-source-notion/operate', () => ({
default: () => <div data-testid="mock-operate" />,
}))
describe('Panel Component', () => {
const onConfigure = vi.fn()
const onRemove = vi.fn()
const mockConfiguredList: ConfigItemType[] = [
{ id: '1', name: 'Item 1', isActive: true, logo: () => null },
{ id: '2', name: 'Item 2', isActive: false, logo: () => null },
]
beforeEach(() => {
vi.clearAllMocks()
})
describe('Notion Panel Rendering', () => {
it('should render Notion panel when not configured and isSupportList is true', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
isSupportList={true}
/>,
)
// Assert
expect(screen.getByText('common.dataSource.notion.title')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.description')).toBeInTheDocument()
const connectBtn = screen.getByText('common.dataSource.connect')
expect(connectBtn).toBeInTheDocument()
// Act
fireEvent.click(connectBtn)
// Assert
expect(onConfigure).toHaveBeenCalled()
})
it('should render Notion panel in readOnly mode when not configured', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={false}
onConfigure={onConfigure}
readOnly={true}
configuredList={[]}
onRemove={onRemove}
isSupportList={true}
/>,
)
// Assert
const connectBtn = screen.getByText('common.dataSource.connect')
expect(connectBtn).toHaveClass('cursor-default opacity-50 grayscale')
})
it('should render Notion panel when configured with list of items', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={true}
onConfigure={onConfigure}
readOnly={false}
configuredList={mockConfiguredList}
onRemove={onRemove}
/>,
)
// Assert
expect(screen.getByRole('button', { name: 'common.dataSource.configure' })).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.connectedWorkspace')).toBeInTheDocument()
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
})
it('should hide connect button for Notion if isSupportList is false', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
isSupportList={false}
/>,
)
// Assert
expect(screen.queryByText('common.dataSource.connect')).not.toBeInTheDocument()
})
it('should disable Notion configure button in readOnly mode (configured state)', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={true}
onConfigure={onConfigure}
readOnly={true}
configuredList={mockConfiguredList}
onRemove={onRemove}
/>,
)
// Assert
const btn = screen.getByRole('button', { name: 'common.dataSource.configure' })
expect(btn).toBeDisabled()
})
})
describe('Website Panel Rendering', () => {
it('should show correct provider names and handle configuration when not configured', () => {
// Arrange
const { rerender } = render(
<Panel
type={DataSourceType.website}
provider={DataSourceProvider.fireCrawl}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
/>,
)
// Assert Firecrawl
expect(screen.getByText('🔥 Firecrawl')).toBeInTheDocument()
// Rerender for WaterCrawl
rerender(
<Panel
type={DataSourceType.website}
provider={DataSourceProvider.waterCrawl}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
/>,
)
expect(screen.getByText('WaterCrawl')).toBeInTheDocument()
// Rerender for Jina Reader
rerender(
<Panel
type={DataSourceType.website}
provider={DataSourceProvider.jinaReader}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
/>,
)
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
// Act
const configBtn = screen.getByText('common.dataSource.configure')
fireEvent.click(configBtn)
// Assert
expect(onConfigure).toHaveBeenCalled()
})
it('should handle readOnly mode for Website configuration button', () => {
// Act
render(
<Panel
type={DataSourceType.website}
isConfigured={false}
onConfigure={onConfigure}
readOnly={true}
configuredList={[]}
onRemove={onRemove}
/>,
)
// Assert
const configBtn = screen.getByText('common.dataSource.configure')
expect(configBtn).toHaveClass('cursor-default opacity-50 grayscale')
// Act
fireEvent.click(configBtn)
// Assert
expect(onConfigure).not.toHaveBeenCalled()
})
it('should render Website panel correctly when configured with crawlers', () => {
// Act
render(
<Panel
type={DataSourceType.website}
isConfigured={true}
onConfigure={onConfigure}
readOnly={false}
configuredList={mockConfiguredList}
onRemove={onRemove}
/>,
)
// Assert
expect(screen.getByText('common.dataSource.website.configuredCrawlers')).toBeInTheDocument()
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,334 @@
import type { AppContextValue } from '@/context/app-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ACCOUNT_SETTING_TAB } from './constants'
import AccountSetting from './index'
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/context/app-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/app-context')>()
return {
...actual,
useAppContext: vi.fn(),
}
})
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
})),
usePathname: vi.fn(() => '/'),
useParams: vi.fn(() => ({})),
useSearchParams: vi.fn(() => ({ get: vi.fn() })),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
default: vi.fn(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
useUpdateModelList: vi.fn(() => vi.fn()),
useModelList: vi.fn(() => ({ data: [], isLoading: false })),
useSystemDefaultModelAndModelList: vi.fn(() => [null, vi.fn()]),
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
}))
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(() => ({ data: [], isPending: false })),
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
useProviderContext: vi.fn(),
}))
const baseAppContextValue: AppContextValue = {
userProfile: {
id: '1',
name: 'Test User',
email: 'test@example.com',
avatar: '',
avatar_url: '',
is_password_set: false,
},
mutateUserProfile: vi.fn(),
currentWorkspace: {
id: '1',
name: 'Workspace',
plan: '',
status: '',
created_at: 0,
role: 'owner',
providers: [],
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: {
current_env: 'testing',
current_version: '0.1.0',
latest_version: '0.1.0',
release_date: '',
release_notes: '',
version: '0.1.0',
can_auto_update: false,
},
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
describe('AccountSetting', () => {
const mockOnCancel = vi.fn()
const mockOnTabChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
enableBilling: true,
enableReplaceWebAppLogo: true,
})
vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
})
describe('Rendering', () => {
it('should render the sidebar with correct menu items', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
expect(screen.getByText('common.settings.provider')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
expect(screen.getByText('common.settings.billing')).toBeInTheDocument()
expect(screen.getByText('common.settings.dataSource')).toBeInTheDocument()
expect(screen.getByText('common.settings.apiBasedExtension')).toBeInTheDocument()
expect(screen.getByText('custom.custom')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(0)
})
it('should respect the activeTab prop', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} activeTab={ACCOUNT_SETTING_TAB.DATA_SOURCE} />
</QueryClientProvider>,
)
// Assert
// Check that the active item title is Data Source
const titles = screen.getAllByText('common.settings.dataSource')
// One in sidebar, one in header.
expect(titles.length).toBeGreaterThan(1)
})
it('should hide sidebar labels on mobile', () => {
// Arrange
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
// On mobile, the labels should not be rendered as per the implementation
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
})
it('should filter items for dataset operator', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
isCurrentWorkspaceDatasetOperator: true,
})
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
expect(screen.queryByText('common.settings.members')).not.toBeInTheDocument()
expect(screen.getByText('common.settings.language')).toBeInTheDocument()
})
it('should hide billing and custom tabs when disabled', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
enableBilling: false,
enableReplaceWebAppLogo: false,
})
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
expect(screen.queryByText('common.settings.billing')).not.toBeInTheDocument()
expect(screen.queryByText('custom.custom')).not.toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should change active tab when clicking on menu item', () => {
// Arrange
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} onTabChange={mockOnTabChange} />
</QueryClientProvider>,
)
// Act
fireEvent.click(screen.getByText('common.settings.provider'))
// Assert
expect(mockOnTabChange).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.PROVIDER)
// Check for content from ModelProviderPage
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
})
it('should navigate through various tabs and show correct details', () => {
// Act & Assert
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Billing
fireEvent.click(screen.getByText('common.settings.billing'))
// Billing Page renders plansCommon.plan if data is loaded, or generic text.
// Checking for title in header which is always there
expect(screen.getAllByText('common.settings.billing').length).toBeGreaterThan(1)
// Data Source
fireEvent.click(screen.getByText('common.settings.dataSource'))
expect(screen.getAllByText('common.settings.dataSource').length).toBeGreaterThan(1)
// API Based Extension
fireEvent.click(screen.getByText('common.settings.apiBasedExtension'))
expect(screen.getAllByText('common.settings.apiBasedExtension').length).toBeGreaterThan(1)
// Custom
fireEvent.click(screen.getByText('custom.custom'))
// Custom Page uses 'custom.custom' key as well.
expect(screen.getAllByText('custom.custom').length).toBeGreaterThan(1)
// Language
fireEvent.click(screen.getAllByText('common.settings.language')[0])
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(1)
// Members
fireEvent.click(screen.getAllByText('common.settings.members')[0])
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(1)
})
})
describe('Interactions', () => {
it('should call onCancel when clicking close button', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when pressing Escape key', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should update search value in provider tab', () => {
// Arrange
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
fireEvent.click(screen.getByText('common.settings.provider'))
// Act
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test-search' } })
// Assert
expect(input).toHaveValue('test-search')
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
})
it('should handle scroll event in panel', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
// Assert
expect(scrollContainer).toBeInTheDocument()
if (scrollContainer) {
// Scroll down
fireEvent.scroll(scrollContainer, { target: { scrollTop: 100 } })
expect(scrollContainer).toHaveClass('overflow-y-auto')
// Scroll back up
fireEvent.scroll(scrollContainer, { target: { scrollTop: 0 } })
}
})
})
})

View File

@@ -0,0 +1,106 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { ValidatedStatus } from './declarations'
import KeyInput from './KeyInput'
type Props = ComponentProps<typeof KeyInput>
const createProps = (overrides: Partial<Props> = {}): Props => ({
name: 'API key',
placeholder: 'Enter API key',
value: 'initial-value',
onChange: vi.fn(),
onFocus: undefined,
validating: false,
validatedStatusState: {},
...overrides,
})
describe('KeyInput', () => {
it('shows the label and placeholder value', () => {
const props = createProps()
render(<KeyInput {...props} />)
expect(screen.getByText('API key')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter API key')).toHaveValue('initial-value')
})
it('updates the visible input value when user types', () => {
const ControlledKeyInput = () => {
const [value, setValue] = useState('initial-value')
return (
<KeyInput
{...createProps({
value,
onChange: setValue,
})}
/>
)
}
render(<ControlledKeyInput />)
fireEvent.change(screen.getByPlaceholderText('Enter API key'), { target: { value: 'updated' } })
expect(screen.getByPlaceholderText('Enter API key')).toHaveValue('updated')
})
it('cycles through validating and error messaging', () => {
const props = createProps()
const { rerender } = render(
<KeyInput {...props} validating validatedStatusState={{}} />,
)
expect(screen.getByText('common.provider.validating')).toBeInTheDocument()
rerender(
<KeyInput
{...props}
validating={false}
validatedStatusState={{ status: ValidatedStatus.Error, message: 'bad-request' }}
/>,
)
expect(screen.getByText('common.provider.validatedErrorbad-request')).toBeInTheDocument()
})
it('does not show an error tip for exceed status', () => {
render(
<KeyInput
{...createProps({
validating: false,
validatedStatusState: { status: ValidatedStatus.Exceed, message: 'quota' },
})}
/>,
)
expect(screen.queryByText(/common\.provider\.validatedError/i)).toBeNull()
})
it('does not show validating or error text for success status', () => {
render(
<KeyInput
{...createProps({
validating: false,
validatedStatusState: { status: ValidatedStatus.Success },
})}
/>,
)
expect(screen.queryByText('common.provider.validating')).toBeNull()
expect(screen.queryByText(/common\.provider\.validatedError/i)).toBeNull()
})
it('shows fallback error text when error message is missing', () => {
render(
<KeyInput
{...createProps({
validating: false,
validatedStatusState: { status: ValidatedStatus.Error },
})}
/>,
)
expect(screen.getByText('common.provider.validatedError')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react'
import Operate from './Operate'
describe('Operate', () => {
it('renders cancel and save when editing', () => {
render(
<Operate
isOpen
status="add"
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
})
it('shows add key prompt when closed', () => {
render(
<Operate
isOpen={false}
status="add"
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
})
it('shows invalid state indicator and edit prompt when status is fail', () => {
render(
<Operate
isOpen={false}
status="fail"
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.getByText('common.provider.invalidApiKey')).toBeInTheDocument()
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
})
it('shows edit prompt without error text when status is success', () => {
render(
<Operate
isOpen={false}
status="success"
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull()
})
it('shows no actions for unsupported status', () => {
render(
<Operate
isOpen={false}
status={'unknown' as never}
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.queryByText('common.provider.addKey')).toBeNull()
expect(screen.queryByText('common.provider.editKey')).toBeNull()
})
})

View File

@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import {
ValidatedErrorIcon,
ValidatedErrorMessage,
ValidatedSuccessIcon,
ValidatingTip,
} from './ValidateStatus'
describe('ValidateStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show validating text while validation is running', () => {
render(<ValidatingTip />)
expect(screen.getByText('common.provider.validating')).toBeInTheDocument()
})
it('should show translated error text with the backend message', () => {
render(<ValidatedErrorMessage errorMessage="invalid-token" />)
expect(screen.getByText('common.provider.validatedErrorinvalid-token')).toBeInTheDocument()
})
it('should render decorative icon for success and error states', () => {
const { container, rerender } = render(<ValidatedSuccessIcon />)
expect(container.firstElementChild).toBeTruthy()
rerender(<ValidatedErrorIcon />)
expect(container.firstElementChild).toBeTruthy()
})
})

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { ValidatedStatus } from './declarations'
describe('declarations', () => {
describe('ValidatedStatus', () => {
it('should expose expected status values', () => {
expect(ValidatedStatus.Success).toBe('success')
expect(ValidatedStatus.Error).toBe('error')
expect(ValidatedStatus.Exceed).toBe('exceed')
})
})
})

View File

@@ -0,0 +1,82 @@
import { act, renderHook } from '@testing-library/react'
import { ValidatedStatus } from './declarations'
import { useValidate } from './hooks'
describe('useValidate', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should clear validation state when before returns false', async () => {
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
act(() => {
result.current[0]({ before: () => false })
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current[1]).toBe(false)
expect(result.current[2]).toEqual({})
})
it('should expose success status after a successful validation', async () => {
const run = vi.fn().mockResolvedValue({ status: ValidatedStatus.Success })
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
act(() => {
result.current[0]({
before: () => true,
run,
})
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current[1]).toBe(false)
expect(result.current[2]).toEqual({ status: ValidatedStatus.Success })
})
it('should expose error status and message when validation fails', async () => {
const run = vi.fn().mockResolvedValue({ status: ValidatedStatus.Error, message: 'bad-key' })
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
act(() => {
result.current[0]({
before: () => true,
run,
})
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current[1]).toBe(false)
expect(result.current[2]).toEqual({ status: ValidatedStatus.Error, message: 'bad-key' })
})
it('should keep validating state true when run is not provided', async () => {
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
act(() => {
result.current[0]({ before: () => true })
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current[1]).toBe(true)
expect(result.current[2]).toEqual({})
})
})

View File

@@ -0,0 +1,162 @@
import type { ComponentProps } from 'react'
import type { Form } from './declarations'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import KeyValidator from './index'
let subscriptionCallback: ((value: string) => void) | null = null
const mockEmit = vi.fn((value: string) => {
subscriptionCallback?.(value)
})
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
useSubscription: (cb: (value: string) => void) => {
subscriptionCallback = cb
},
},
}),
}))
const mockValidate = vi.fn()
const mockUseValidate = vi.fn()
vi.mock('./hooks', () => ({
useValidate: (...args: unknown[]) => mockUseValidate(...args),
}))
describe('KeyValidator', () => {
const formValidate = {
before: () => true,
}
const forms: Form[] = [
{
key: 'apiKey',
title: 'API key',
placeholder: 'Enter API key',
value: 'initial-key',
validate: formValidate,
handleFocus: (_value, setValue) => {
setValue(prev => ({ ...prev, apiKey: 'focused-key' }))
},
},
]
const createProps = (overrides: Partial<ComponentProps<typeof KeyValidator>> = {}) => ({
type: 'test-provider',
title: <div>Provider key</div>,
status: 'add' as const,
forms,
keyFrom: {
text: 'Get key',
link: 'https://example.com/key',
},
onSave: vi.fn().mockResolvedValue(true),
disabled: false,
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
subscriptionCallback = null
mockValidate.mockImplementation((config?: { before?: () => boolean }) => config?.before?.())
mockUseValidate.mockReturnValue([mockValidate, false, {}])
})
it('should open and close the editor from add and cancel actions', () => {
render(<KeyValidator {...createProps()} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Get key' })).toBeInTheDocument()
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
})
it('should submit the updated value when save is clicked', async () => {
render(<KeyValidator {...createProps()} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
const input = screen.getByPlaceholderText('Enter API key')
fireEvent.focus(input)
expect(input).toHaveValue('focused-key')
fireEvent.change(input, {
target: { value: 'updated-key' },
})
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
})
})
it('should keep the editor open when save does not succeed', async () => {
const formsWithoutValidation: Form[] = [
{
key: 'apiKey',
title: 'API key',
placeholder: 'Enter API key',
},
]
const props = createProps({
forms: formsWithoutValidation,
onSave: vi.fn().mockResolvedValue(false),
})
render(<KeyValidator {...props} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
const input = screen.getByPlaceholderText('Enter API key')
expect(input).toHaveValue('')
fireEvent.focus(input)
fireEvent.change(input, {
target: { value: 'typed-without-validator' },
})
fireEvent.click(screen.getByText('common.operation.save'))
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
})
it('should close and reset edited values when another validator emits a trigger', () => {
render(<KeyValidator {...createProps()} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
fireEvent.change(screen.getByPlaceholderText('Enter API key'), {
target: { value: 'changed' },
})
act(() => {
subscriptionCallback?.('plugins/another-provider')
})
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
fireEvent.click(screen.getByText('common.provider.addKey'))
expect(screen.getByPlaceholderText('Enter API key')).toHaveValue('initial-key')
})
it('should prevent opening key editor when disabled', () => {
render(<KeyValidator {...createProps()} disabled />)
fireEvent.click(screen.getByText('common.provider.addKey'))
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
})
it('should open the editor from edit action when validator is in success state', () => {
render(<KeyValidator {...createProps({ status: 'success' })} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,221 @@
import type { UserProfileResponse } from '@/models/common'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { ToastProvider } from '@/app/components/base/toast'
import { languages } from '@/i18n-config/language'
import { updateUserProfile } from '@/service/common'
import { timezones } from '@/utils/timezone'
import LanguagePage from './index'
const mockRefresh = vi.fn()
const mockMutateUserProfile = vi.fn()
let mockLocale: string | undefined = 'en-US'
let mockUserProfile: UserProfileResponse
vi.mock('@/app/components/base/select', async () => {
const React = await import('react')
return {
SimpleSelect: ({
items = [],
defaultValue,
onSelect,
disabled,
}: {
items?: Array<{ value: string | number, name: string }>
defaultValue?: string | number
onSelect: (item: { value: string | number, name: string }) => void
disabled?: boolean
}) => {
const [open, setOpen] = React.useState(false)
const [selectedValue, setSelectedValue] = React.useState<string | number | undefined>(defaultValue)
const selected = items.find(item => item.value === selectedValue)
?? items.find(item => item.value === defaultValue)
?? null
return (
<div>
<button type="button" disabled={disabled} onClick={() => setOpen(prev => !prev)}>
{selected?.name ?? ''}
</button>
{open && (
<div>
{items.map(item => (
<button
key={item.value}
type="button"
role="option"
onClick={() => {
setSelectedValue(item.value)
onSelect(item)
setOpen(false)
}}
>
{item.name}
</button>
))}
</div>
)}
</div>
)
},
}
})
vi.mock('next/navigation', () => ({
useRouter: () => ({ refresh: mockRefresh }),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: mockUserProfile,
mutateUserProfile: mockMutateUserProfile,
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
vi.mock('@/service/common', () => ({
updateUserProfile: vi.fn(),
}))
vi.mock('@/i18n-config', () => ({
setLocaleOnClient: vi.fn(),
}))
const updateUserProfileMock = vi.mocked(updateUserProfile)
const createUserProfile = (overrides: Partial<UserProfileResponse> = {}): UserProfileResponse => ({
id: 'user-id',
name: 'Test User',
email: 'test@example.com',
avatar: '',
avatar_url: null,
is_password_set: false,
interface_language: 'en-US',
timezone: 'Pacific/Niue',
...overrides,
})
const renderPage = () => {
render(
<ToastProvider>
<LanguagePage />
</ToastProvider>,
)
}
const getSectionByLabel = (sectionLabel: string) => {
const label = screen.getByText(sectionLabel)
const section = label.closest('div')?.parentElement
if (!section)
throw new Error(`Missing select section: ${sectionLabel}`)
return section
}
const selectOption = async (sectionLabel: string, optionName: string) => {
const section = getSectionByLabel(sectionLabel)
await act(async () => {
fireEvent.click(within(section).getByRole('button'))
})
await act(async () => {
fireEvent.click(await within(section).findByRole('option', { name: optionName }))
})
}
const getLanguageOption = (value: string) => {
const option = languages.find(item => item.value === value)
if (!option)
throw new Error(`Missing language option: ${value}`)
return option
}
const getTimezoneOption = (value: string) => {
const option = timezones.find(item => item.value === value)
if (!option)
throw new Error(`Missing timezone option: ${value}`)
return option
}
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
mockLocale = 'en-US'
mockUserProfile = createUserProfile()
})
// Rendering
describe('LanguagePage - Rendering', () => {
it('should render default language and timezone labels', () => {
const english = getLanguageOption('en-US')
const niueTimezone = getTimezoneOption('Pacific/Niue')
mockLocale = undefined
mockUserProfile = createUserProfile({
interface_language: english.value.toString(),
timezone: niueTimezone.value.toString(),
})
renderPage()
expect(screen.getByText('common.language.displayLanguage')).toBeInTheDocument()
expect(screen.getByText('common.language.timezone')).toBeInTheDocument()
expect(screen.getByRole('button', { name: english.name })).toBeInTheDocument()
expect(screen.getByRole('button', { name: niueTimezone.name })).toBeInTheDocument()
})
})
// Interactions
describe('LanguagePage - Interactions', () => {
it('should show success toast when language updates', async () => {
const chinese = getLanguageOption('zh-Hans')
mockUserProfile = createUserProfile({ interface_language: 'en-US' })
updateUserProfileMock.mockResolvedValueOnce({ result: 'success' })
renderPage()
await selectOption('common.language.displayLanguage', chinese.name)
expect(await screen.findByText('common.actionMsg.modifiedSuccessfully')).toBeInTheDocument()
await waitFor(() => {
expect(updateUserProfileMock).toHaveBeenCalledWith({
url: '/account/interface-language',
body: { interface_language: chinese.value },
})
})
})
it('should show error toast when language update fails', async () => {
const chinese = getLanguageOption('zh-Hans')
updateUserProfileMock.mockRejectedValueOnce(new Error('Update failed'))
renderPage()
await selectOption('common.language.displayLanguage', chinese.name)
expect(await screen.findByText('Update failed')).toBeInTheDocument()
})
it('should show success toast when timezone updates', async () => {
const midwayTimezone = getTimezoneOption('Pacific/Midway')
updateUserProfileMock.mockResolvedValueOnce({ result: 'success' })
renderPage()
await selectOption('common.language.timezone', midwayTimezone.name)
expect(await screen.findByText('common.actionMsg.modifiedSuccessfully')).toBeInTheDocument()
expect(screen.getByRole('button', { name: midwayTimezone.name })).toBeInTheDocument()
}, 15000)
it('should show error toast when timezone update fails', async () => {
const midwayTimezone = getTimezoneOption('Pacific/Midway')
updateUserProfileMock.mockRejectedValueOnce(new Error('Timezone failed'))
renderPage()
await selectOption('common.language.timezone', midwayTimezone.name)
expect(await screen.findByText('Timezone failed')).toBeInTheDocument()
}, 15000)
})

View File

@@ -0,0 +1,94 @@
import { fireEvent, render, screen } from '@testing-library/react'
import MenuDialog from './menu-dialog'
describe('MenuDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render children when show is true', () => {
// Act
render(
<MenuDialog show={true} onClose={vi.fn()}>
<div data-testid="dialog-content">Content</div>
</MenuDialog>,
)
// Assert
expect(screen.getByTestId('dialog-content')).toBeInTheDocument()
})
it('should not render children when show is false', () => {
// Act
render(
<MenuDialog show={false} onClose={vi.fn()}>
<div data-testid="dialog-content">Content</div>
</MenuDialog>,
)
// Assert
expect(screen.queryByTestId('dialog-content')).not.toBeInTheDocument()
})
it('should apply custom className', () => {
// Act
render(
<MenuDialog show={true} onClose={vi.fn()} className="custom-class">
<div data-testid="dialog-content">Content</div>
</MenuDialog>,
)
// Assert
const panel = screen.getByRole('dialog').querySelector('.custom-class')
expect(panel).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onClose when Escape key is pressed', () => {
// Arrange
const onClose = vi.fn()
render(
<MenuDialog show={true} onClose={onClose}>
<div>Content</div>
</MenuDialog>,
)
// Act
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
expect(onClose).toHaveBeenCalled()
})
it('should not call onClose when a key other than Escape is pressed', () => {
// Arrange
const onClose = vi.fn()
render(
<MenuDialog show={true} onClose={onClose}>
<div>Content</div>
</MenuDialog>,
)
// Act
fireEvent.keyDown(document, { key: 'Enter' })
// Assert
expect(onClose).not.toHaveBeenCalled()
})
it('should not crash when Escape is pressed and onClose is not provided', () => {
// Arrange
render(
<MenuDialog show={true}>
<div data-testid="dialog-content">Content</div>
</MenuDialog>,
)
// Act & Assert
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByTestId('dialog-content')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,99 @@
import type { CustomModel, ModelCredential, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import AddCredentialInLoadBalancing from './add-credential-in-load-balancing'
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
Authorized: ({
renderTrigger,
authParams,
items,
onItemClick,
}: {
renderTrigger: (open?: boolean) => React.ReactNode
authParams?: { onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void }
items: Array<{ credentials: Array<{ credential_id: string, credential_name: string }> }>
onItemClick?: (credential: { credential_id: string, credential_name: string }) => void
}) => (
<div>
{renderTrigger(false)}
<button onClick={() => authParams?.onUpdate?.({ provider: 'x' }, { key: 'value' })}>Run update</button>
<button onClick={() => onItemClick?.(items[0].credentials[0])}>Select first</button>
</div>
),
}))
describe('AddCredentialInLoadBalancing', () => {
const provider = {
provider: 'openai',
allow_custom_token: true,
} as ModelProvider
const model = {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
} as CustomModel
const modelCredential = {
available_credentials: [
{ credential_id: 'cred-1', credential_name: 'Key 1' },
],
credentials: {},
load_balancing: { enabled: false, configs: [] },
} as ModelCredential
beforeEach(() => {
vi.clearAllMocks()
})
it('should render add credential label', () => {
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
})
it('should forward update payload when update action happens', () => {
const onUpdate = vi.fn()
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
onUpdate={onUpdate}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Run update' }))
expect(onUpdate).toHaveBeenCalledWith({ provider: 'x' }, { key: 'value' })
})
it('should call onSelectCredential when user picks a credential', () => {
const onSelectCredential = vi.fn()
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.customizableModel}
modelCredential={modelCredential}
onSelectCredential={onSelectCredential}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Select first' }))
expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0])
})
})

View File

@@ -0,0 +1,165 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import AddCustomModel from './add-custom-model'
// Mock hooks
const mockHandleOpenModalForAddNewCustomModel = vi.fn()
const mockHandleOpenModalForAddCustomModelToModelList = vi.fn()
vi.mock('./hooks/use-auth', () => ({
useAuth: (_provider: unknown, _configMethod: unknown, _fixedFields: unknown, options: { mode: string }) => {
if (options.mode === 'config-custom-model') {
return { handleOpenModal: mockHandleOpenModalForAddNewCustomModel }
}
if (options.mode === 'add-custom-model-to-model-list') {
return { handleOpenModal: mockHandleOpenModalForAddCustomModelToModelList }
}
return { handleOpenModal: vi.fn() }
},
}))
let mockCanAddedModels: { model: string, model_type: string }[] = []
vi.mock('./hooks/use-custom-models', () => ({
useCanAddedModels: () => mockCanAddedModels,
}))
// Mock components
vi.mock('../model-icon', () => ({
default: () => <div data-testid="model-icon" />,
}))
vi.mock('@remixicon/react', () => ({
RiAddCircleFill: () => <div data-testid="add-circle-icon" />,
RiAddLine: () => <div data-testid="add-line-icon" />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip-mock">
{children}
<div>{popupContent}</div>
</div>
),
}))
// Mock portal components to avoid async/jsdom issues (consistent with sibling tests)
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean, onOpenChange: (open: boolean) => void }) => (
<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 }) => {
// In many tests, we need to find elements inside the content even if "closed" in state
// but not yet "removed" from DOM. However, to avoid multiple elements issues,
// we should be careful.
// For AddCustomModel, we need the content to be present when we click a model.
return <div data-testid="portal-content" style={{ display: 'block' }}>{children}</div>
},
}))
describe('AddCustomModel', () => {
const mockProvider = {
provider: 'openai',
allow_custom_token: true,
} as unknown as ModelProvider
beforeEach(() => {
vi.clearAllMocks()
mockCanAddedModels = []
})
it('should render the add model button', () => {
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
expect(screen.getByText(/modelProvider.addModel/)).toBeInTheDocument()
expect(screen.getByTestId('add-circle-icon')).toBeInTheDocument()
})
it('should call handleOpenModal directly when no models available and allowed', () => {
mockCanAddedModels = []
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
})
it('should show models list when models are available', () => {
mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }]
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
// The portal should be "open"
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
it('should call handleOpenModalForAddCustomModelToModelList when clicking a model', () => {
const model = { model: 'gpt-4', model_type: 'llm' }
mockCanAddedModels = [model]
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('gpt-4'))
expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model)
})
it('should call handleOpenModalForAddNewCustomModel when clicking "Add New Model" in list', () => {
mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }]
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
})
it('should show tooltip when no models and custom tokens not allowed', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
mockCanAddedModels = []
render(
<AddCustomModel
provider={restrictedProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
expect(screen.getByTestId('tooltip-mock')).toBeInTheDocument()
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModalForAddNewCustomModel).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,164 @@
import type { Credential, CustomModelCredential, ModelProvider } from '../../declarations'
import { render, screen } from '@testing-library/react'
import { ModelTypeEnum } from '../../declarations'
import { AuthorizedItem } from './authorized-item'
vi.mock('../../model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <div data-testid="model-icon">{modelName}</div>,
}))
vi.mock('./credential-item', () => ({
default: ({ credential, onEdit, onDelete, onItemClick }: {
credential: Credential
onEdit?: (credential: Credential) => void
onDelete?: (credential: Credential) => void
onItemClick?: (credential: Credential) => void
}) => (
<div data-testid={`credential-item-${credential.credential_id}`}>
{credential.credential_name}
<button onClick={() => onEdit?.(credential)}>Edit</button>
<button onClick={() => onDelete?.(credential)}>Delete</button>
<button onClick={() => onItemClick?.(credential)}>Click</button>
</div>
),
}))
describe('AuthorizedItem', () => {
const mockProvider: ModelProvider = {
provider: 'openai',
} as ModelProvider
const mockCredentials: Credential[] = [
{ credential_id: 'cred-1', credential_name: 'API Key 1' },
{ credential_id: 'cred-2', credential_name: 'API Key 2' },
]
const mockModel: CustomModelCredential = {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render credentials list', () => {
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
/>,
)
expect(screen.getByTestId('credential-item-cred-1')).toBeInTheDocument()
expect(screen.getByTestId('credential-item-cred-2')).toBeInTheDocument()
expect(screen.getByText('API Key 1')).toBeInTheDocument()
expect(screen.getByText('API Key 2')).toBeInTheDocument()
})
it('should render model title when showModelTitle is true', () => {
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
showModelTitle
/>,
)
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
expect(screen.getAllByText('gpt-4')).toHaveLength(2)
})
it('should not render model title when showModelTitle is false', () => {
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
/>,
)
expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument()
})
it('should render custom title instead of model name', () => {
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
title="Custom Title"
showModelTitle
/>,
)
expect(screen.getByText('Custom Title')).toBeInTheDocument()
})
it('should handle empty credentials array', () => {
const { container } = render(
<AuthorizedItem
provider={mockProvider}
credentials={[]}
/>,
)
expect(container.querySelector('[data-testid^="credential-item-"]')).not.toBeInTheDocument()
})
})
describe('Callback Propagation', () => {
it('should pass onEdit callback to credential items', () => {
const onEdit = vi.fn()
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
onEdit={onEdit}
/>,
)
screen.getAllByText('Edit')[0].click()
expect(onEdit).toHaveBeenCalledWith(mockCredentials[0], mockModel)
})
it('should pass onDelete callback to credential items', () => {
const onDelete = vi.fn()
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
onDelete={onDelete}
/>,
)
screen.getAllByText('Delete')[0].click()
expect(onDelete).toHaveBeenCalledWith(mockCredentials[0], mockModel)
})
it('should pass onItemClick callback to credential items', () => {
const onItemClick = vi.fn()
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
onItemClick={onItemClick}
/>,
)
screen.getAllByText('Click')[0].click()
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockModel)
})
})
})

View File

@@ -0,0 +1,88 @@
import type { Credential } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import CredentialItem from './credential-item'
vi.mock('@remixicon/react', () => ({
RiCheckLine: () => <div data-testid="check-icon" />,
RiDeleteBinLine: () => <div data-testid="delete-icon" />,
RiEqualizer2Line: () => <div data-testid="edit-icon" />,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <div data-testid="indicator" />,
}))
describe('CredentialItem', () => {
const credential: Credential = {
credential_id: 'cred-1',
credential_name: 'Test API Key',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render credential text and indicator', () => {
render(<CredentialItem credential={credential} />)
expect(screen.getByText('Test API Key')).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toBeInTheDocument()
})
it('should render enterprise badge for enterprise credential', () => {
render(<CredentialItem credential={{ ...credential, from_enterprise: true }} />)
expect(screen.getByText('Enterprise')).toBeInTheDocument()
})
it('should call onItemClick when list item is clicked', () => {
const onItemClick = vi.fn()
render(<CredentialItem credential={credential} onItemClick={onItemClick} />)
fireEvent.click(screen.getByText('Test API Key'))
expect(onItemClick).toHaveBeenCalledWith(credential)
})
it('should not call onItemClick when credential is unavailable', () => {
const onItemClick = vi.fn()
render(<CredentialItem credential={{ ...credential, not_allowed_to_use: true }} onItemClick={onItemClick} />)
fireEvent.click(screen.getByText('Test API Key'))
expect(onItemClick).not.toHaveBeenCalled()
})
it('should call onEdit and onDelete from action buttons', () => {
const onEdit = vi.fn()
const onDelete = vi.fn()
render(<CredentialItem credential={credential} onEdit={onEdit} onDelete={onDelete} />)
fireEvent.click(screen.getByTestId('edit-icon').closest('button') as HTMLButtonElement)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
expect(onEdit).toHaveBeenCalledWith(credential)
expect(onDelete).toHaveBeenCalledWith(credential)
})
it('should block delete action for the currently selected credential when delete is disabled', () => {
const onDelete = vi.fn()
render(
<CredentialItem
credential={credential}
onDelete={onDelete}
disableDeleteButShowAction
selectedCredentialId="cred-1"
disableDeleteTip="Cannot remove selected"
/>,
)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
expect(onDelete).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,486 @@
import type { Credential, CustomModel, ModelProvider } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelTypeEnum } from '../../declarations'
import Authorized from './index'
const mockHandleOpenModal = vi.fn()
const mockHandleActiveCredential = vi.fn()
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
let mockDeleteCredentialId: string | null = null
let mockDoingAction = false
vi.mock('../hooks', () => ({
useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete,
closeConfirmDelete: mockCloseConfirmDelete,
doingAction: mockDoingAction,
handleActiveCredential: mockHandleActiveCredential,
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: mockDeleteCredentialId,
handleOpenModal: mockHandleOpenModal,
}),
}))
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[]
model?: CustomModel
onEdit?: (credential: Credential, model?: CustomModel) => void
onDelete?: (credential: Credential, model?: CustomModel) => void
onItemClick?: (credential: Credential, model?: CustomModel) => void
}) => (
<div data-testid="authorized-item">
{credentials.map((cred: Credential) => (
<div key={cred.credential_id}>
<span>{cred.credential_name}</span>
<button onClick={() => onEdit?.(cred, model)}>Edit</button>
<button onClick={() => onDelete?.(cred, model)}>Delete</button>
<button onClick={() => onItemClick?.(cred, model)}>Select</button>
</div>
))}
</div>
),
}))
describe('Authorized', () => {
const mockProvider: ModelProvider = {
provider: 'openai',
allow_custom_token: true,
} as ModelProvider
const mockCredentials: Credential[] = [
{ credential_id: 'cred-1', credential_name: 'API Key 1' },
{ credential_id: 'cred-2', credential_name: 'API Key 2' },
]
const mockItems = [
{
model: {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
},
credentials: mockCredentials,
},
]
const mockRenderTrigger = (open?: boolean) => (
<button>
Trigger
{open ? 'Open' : 'Closed'}
</button>
)
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
mockDeleteCredentialId = null
mockDoingAction = false
})
describe('Rendering', () => {
it('should render trigger button', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.getByText(/Trigger/)).toBeInTheDocument()
})
it('should render portal content when open', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
})
it('should not render portal content when closed', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should render Add API Key button when not model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.getByText(/addApiKey/)).toBeInTheDocument()
})
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(screen.getByText(/addModelCredential/)).toBeInTheDocument()
})
it('should not render add action when hideAddAction is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
hideAddAction
isOpen
/>,
)
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
})
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()
})
})
describe('User Interactions', () => {
it('should call onOpenChange when trigger is clicked in controlled mode', () => {
const onOpenChange = vi.fn()
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()
})
})
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}
/>,
)
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

@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ConfigModel from './config-model'
// Mock icons
vi.mock('@remixicon/react', () => ({
RiEqualizer2Line: () => <div data-testid="config-icon" />,
RiScales3Line: () => <div data-testid="scales-icon" />,
}))
// Mock Indicator
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
}))
describe('ConfigModel', () => {
it('should render authorization error when loadBalancingInvalid is true', () => {
const onClick = vi.fn()
render(<ConfigModel loadBalancingInvalid onClick={onClick} />)
expect(screen.getByText(/modelProvider.auth.authorizationError/)).toBeInTheDocument()
expect(screen.getByTestId('scales-icon')).toBeInTheDocument()
expect(screen.getByTestId('indicator-orange')).toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider.auth.authorizationError/))
expect(onClick).toHaveBeenCalled()
})
it('should render credential removed message when credentialRemoved is true', () => {
render(<ConfigModel credentialRemoved />)
expect(screen.getByText(/modelProvider.auth.credentialRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
})
it('should render standard config message when no flags enabled', () => {
render(<ConfigModel />)
expect(screen.getByText(/operation.config/)).toBeInTheDocument()
expect(screen.getByTestId('config-icon')).toBeInTheDocument()
})
it('should render config load balancing when loadBalancingEnabled is true', () => {
render(<ConfigModel loadBalancingEnabled />)
expect(screen.getByText(/modelProvider.auth.configLoadBalancing/)).toBeInTheDocument()
expect(screen.getByTestId('scales-icon')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,70 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import ConfigProvider from './config-provider'
const mockUseCredentialStatus = vi.fn()
vi.mock('./hooks', () => ({
useCredentialStatus: () => mockUseCredentialStatus(),
}))
vi.mock('./authorized', () => ({
default: ({ renderTrigger }: { renderTrigger: () => React.ReactNode }) => (
<div>
{renderTrigger()}
</div>
),
}))
describe('ConfigProvider', () => {
const baseProvider = {
provider: 'openai',
allow_custom_token: true,
} as ModelProvider
beforeEach(() => {
vi.clearAllMocks()
})
it('should show setup label when no credential exists', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: true,
current_credential_id: '',
current_credential_name: '',
available_credentials: [],
})
render(<ConfigProvider provider={baseProvider} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
})
it('should show config label when credential exists', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: true,
authorized: true,
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 still render setup label when custom credentials are not allowed', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: false,
current_credential_id: '',
current_credential_name: '',
available_credentials: [],
})
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,130 @@
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 }) => (
<div data-testid="credential-item" onClick={() => onItemClick(credential)}>
{credential.credential_name}
</div>
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <div data-testid="indicator" />,
}))
vi.mock('@remixicon/react', () => ({
RiAddLine: () => <div data-testid="add-icon" />,
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' },
{ credential_id: 'cred-2', credential_name: 'Key 2' },
]
const mockOnSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render selected credential name', () => {
render(
<CredentialSelector
selectedCredential={mockCredentials[0]}
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
// 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 no credential selected', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument()
})
it('should open portal on click', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
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 credential data when clicking add button', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
credential_id: '__add_new_credential',
addNewCredential: true,
}))
})
it('should not open portal when disabled', () => {
render(
<CredentialSelector
disabled
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
})
})

View File

@@ -0,0 +1,94 @@
import type { CustomModel } from '../../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { ModelTypeEnum } from '../../declarations'
import { useAuthService, useGetCredential } from './use-auth-service'
vi.mock('@/service/use-models', () => ({
useGetProviderCredential: vi.fn(),
useGetModelCredential: vi.fn(),
useAddProviderCredential: vi.fn(),
useEditProviderCredential: vi.fn(),
useDeleteProviderCredential: vi.fn(),
useActiveProviderCredential: vi.fn(),
useAddModelCredential: vi.fn(),
useEditModelCredential: vi.fn(),
useDeleteModelCredential: vi.fn(),
useActiveModelCredential: vi.fn(),
}))
const {
useGetProviderCredential,
useGetModelCredential,
useAddProviderCredential,
useEditProviderCredential,
useDeleteProviderCredential,
useActiveProviderCredential,
useAddModelCredential,
useEditModelCredential,
useDeleteModelCredential,
useActiveModelCredential,
} = await import('@/service/use-models')
describe('useAuthService hooks', () => {
let queryClient: QueryClient
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
beforeEach(() => {
vi.clearAllMocks()
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const mockMutationReturn = { mutateAsync: vi.fn() }
vi.mocked(useAddProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useAddProviderCredential>)
vi.mocked(useEditProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useEditProviderCredential>)
vi.mocked(useDeleteProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useDeleteProviderCredential>)
vi.mocked(useActiveProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useActiveProviderCredential>)
vi.mocked(useAddModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useAddModelCredential>)
vi.mocked(useEditModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useEditModelCredential>)
vi.mocked(useDeleteModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useDeleteModelCredential>)
vi.mocked(useActiveModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useActiveModelCredential>)
})
it('useGetCredential selects correct source and params', () => {
const mockData = { data: 'test' }
vi.mocked(useGetProviderCredential).mockReturnValue(mockData as unknown as ReturnType<typeof useGetProviderCredential>)
vi.mocked(useGetModelCredential).mockReturnValue(mockData as unknown as ReturnType<typeof useGetModelCredential>)
// Provider case
const { result: providerRes } = renderHook(() => useGetCredential('openai', false, 'cred-123'), { wrapper })
expect(useGetProviderCredential).toHaveBeenCalledWith(true, 'openai', 'cred-123')
expect(providerRes.current).toBe(mockData)
// Model case
const mockModel = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } as CustomModel
const { result: modelRes } = renderHook(() => useGetCredential('openai', true, 'cred-123', mockModel, 'src'), { wrapper })
expect(useGetModelCredential).toHaveBeenCalledWith(true, 'openai', 'cred-123', 'gpt-4', ModelTypeEnum.textGeneration, 'src')
expect(modelRes.current).toBe(mockData)
// Early return cases
renderHook(() => useGetCredential('openai', false), { wrapper })
expect(useGetProviderCredential).toHaveBeenCalledWith(false, 'openai', undefined)
// Branch: isModelCredential true but no id/model
renderHook(() => useGetCredential('openai', true), { wrapper })
expect(useGetModelCredential).toHaveBeenCalledWith(false, 'openai', undefined, undefined, undefined, undefined)
})
it('useAuthService provides correct services for provider and model', () => {
const { result } = renderHook(() => useAuthService('openai'), { wrapper })
// Provider services
expect(result.current.getAddCredentialService(false)).toBe(vi.mocked(useAddProviderCredential).mock.results[0].value.mutateAsync)
expect(result.current.getEditCredentialService(false)).toBe(vi.mocked(useEditProviderCredential).mock.results[0].value.mutateAsync)
expect(result.current.getDeleteCredentialService(false)).toBe(vi.mocked(useDeleteProviderCredential).mock.results[0].value.mutateAsync)
expect(result.current.getActiveCredentialService(false)).toBe(vi.mocked(useActiveProviderCredential).mock.results[0].value.mutateAsync)
// Model services
expect(result.current.getAddCredentialService(true)).toBe(vi.mocked(useAddModelCredential).mock.results[0].value.mutateAsync)
expect(result.current.getEditCredentialService(true)).toBe(vi.mocked(useEditModelCredential).mock.results[0].value.mutateAsync)
expect(result.current.getDeleteCredentialService(true)).toBe(vi.mocked(useDeleteModelCredential).mock.results[0].value.mutateAsync)
expect(result.current.getActiveCredentialService(true)).toBe(vi.mocked(useActiveModelCredential).mock.results[0].value.mutateAsync)
})
})

View File

@@ -0,0 +1,247 @@
import type {
Credential,
CustomModel,
ModelProvider,
} from '../../declarations'
import { act, renderHook } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations'
import { useAuth } from './use-auth'
const mockNotify = vi.fn()
const mockHandleRefreshModel = vi.fn()
const mockOpenModelModal = vi.fn()
const mockDeleteModelService = vi.fn()
const mockDeleteProviderCredential = vi.fn()
const mockDeleteModelCredential = vi.fn()
const mockActiveProviderCredential = vi.fn()
const mockActiveModelCredential = vi.fn()
const mockAddProviderCredential = vi.fn()
const mockAddModelCredential = vi.fn()
const mockEditProviderCredential = vi.fn()
const mockEditModelCredential = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelModalHandler: () => mockOpenModelModal,
useRefreshModel: () => ({ handleRefreshModel: mockHandleRefreshModel }),
}))
vi.mock('@/service/use-models', () => ({
useDeleteModel: () => ({ mutateAsync: mockDeleteModelService }),
}))
vi.mock('./use-auth-service', () => ({
useAuthService: () => ({
getDeleteCredentialService: (isModel: boolean) => (isModel ? mockDeleteModelCredential : mockDeleteProviderCredential),
getActiveCredentialService: (isModel: boolean) => (isModel ? mockActiveModelCredential : mockActiveProviderCredential),
getEditCredentialService: (isModel: boolean) => (isModel ? mockEditModelCredential : mockEditProviderCredential),
getAddCredentialService: (isModel: boolean) => (isModel ? mockAddModelCredential : mockAddProviderCredential),
}),
}))
const createDeferred = <T,>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('useAuth', () => {
const provider = {
provider: 'openai',
allow_custom_token: true,
} as ModelProvider
const credential: Credential = {
credential_id: 'cred-1',
credential_name: 'Primary key',
}
const model: CustomModel = {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
}
beforeEach(() => {
vi.clearAllMocks()
mockDeleteModelService.mockResolvedValue({ result: 'success' })
mockDeleteProviderCredential.mockResolvedValue({ result: 'success' })
mockDeleteModelCredential.mockResolvedValue({ result: 'success' })
mockActiveProviderCredential.mockResolvedValue({ result: 'success' })
mockActiveModelCredential.mockResolvedValue({ result: 'success' })
mockAddProviderCredential.mockResolvedValue({ result: 'success' })
mockAddModelCredential.mockResolvedValue({ result: 'success' })
mockEditProviderCredential.mockResolvedValue({ result: 'success' })
mockEditModelCredential.mockResolvedValue({ result: 'success' })
})
it('should open and close delete confirmation state', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
act(() => {
result.current.openConfirmDelete(credential, model)
})
expect(result.current.deleteCredentialId).toBe('cred-1')
expect(result.current.deleteModel).toEqual(model)
expect(result.current.pendingOperationCredentialId.current).toBe('cred-1')
expect(result.current.pendingOperationModel.current).toEqual(model)
act(() => {
result.current.closeConfirmDelete()
})
expect(result.current.deleteCredentialId).toBeNull()
expect(result.current.deleteModel).toBeNull()
})
it('should activate credential, notify success, and refresh models', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel))
await act(async () => {
await result.current.handleActiveCredential(credential, model)
})
expect(mockActiveModelCredential).toHaveBeenCalledWith({
credential_id: 'cred-1',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.api.actionSuccess',
}))
expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, true)
expect(result.current.doingAction).toBe(false)
})
it('should close delete dialog without calling services when nothing is pending', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
await act(async () => {
await result.current.handleConfirmDelete()
})
expect(mockDeleteProviderCredential).not.toHaveBeenCalled()
expect(mockDeleteModelService).not.toHaveBeenCalled()
expect(result.current.deleteCredentialId).toBeNull()
expect(result.current.deleteModel).toBeNull()
})
it('should delete credential and call onRemove callback', async () => {
const onRemove = vi.fn()
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, {
isModelCredential: false,
onRemove,
}))
act(() => {
result.current.openConfirmDelete(credential, model)
})
await act(async () => {
await result.current.handleConfirmDelete()
})
expect(mockDeleteProviderCredential).toHaveBeenCalledWith({
credential_id: 'cred-1',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
expect(mockDeleteModelService).not.toHaveBeenCalled()
expect(onRemove).toHaveBeenCalledWith('cred-1')
expect(result.current.deleteCredentialId).toBeNull()
})
it('should delete model when pending operation has no credential id', async () => {
const onRemove = vi.fn()
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, {
onRemove,
}))
act(() => {
result.current.openConfirmDelete(undefined, model)
})
await act(async () => {
await result.current.handleConfirmDelete()
})
expect(mockDeleteModelService).toHaveBeenCalledWith({
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
expect(onRemove).toHaveBeenCalledWith('')
})
it('should add or edit credentials and refresh on successful save', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
await act(async () => {
await result.current.handleSaveCredential({ api_key: 'new-key' })
})
expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'new-key' })
expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, true)
await act(async () => {
await result.current.handleSaveCredential({ credential_id: 'cred-1', api_key: 'updated-key' })
})
expect(mockEditProviderCredential).toHaveBeenCalledWith({ credential_id: 'cred-1', api_key: 'updated-key' })
expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, false)
})
it('should ignore duplicate save requests while an action is in progress', async () => {
const deferred = createDeferred<{ result: string }>()
mockAddProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
let first!: Promise<void>
let second!: Promise<void>
await act(async () => {
first = result.current.handleSaveCredential({ api_key: 'first' })
second = result.current.handleSaveCredential({ api_key: 'second' })
deferred.resolve({ result: 'success' })
await Promise.all([first, second])
})
expect(mockAddProviderCredential).toHaveBeenCalledTimes(1)
expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'first' })
})
it('should forward modal open arguments', () => {
const onUpdate = vi.fn()
const fixedFields = {
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, fixedFields, {
isModelCredential: true,
onUpdate,
mode: ModelModalModeEnum.configModelCredential,
}))
act(() => {
result.current.handleOpenModal(credential, model)
})
expect(mockOpenModelModal).toHaveBeenCalledWith(
provider,
ConfigurationMethodEnum.customizableModel,
fixedFields,
expect.objectContaining({
isModelCredential: true,
credential,
model,
onUpdate,
}),
)
})
})

View File

@@ -0,0 +1,60 @@
import type { Credential, CustomModelCredential, ModelProvider } from '../../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { useCredentialData } from './use-credential-data'
vi.mock('./use-auth-service', () => ({
useGetCredential: vi.fn(),
}))
const { useGetCredential } = await import('./use-auth-service')
describe('useCredentialData', () => {
let queryClient: QueryClient
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
beforeEach(() => {
vi.clearAllMocks()
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
})
it('determines correct config source and parameters', () => {
vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: {} } as unknown as ReturnType<typeof useGetCredential>)
const mockProvider = { provider: 'openai' } as unknown as ModelProvider
// Predefined source
renderHook(() => useCredentialData(mockProvider, true), { wrapper })
expect(useGetCredential).toHaveBeenCalledWith('openai', undefined, undefined, undefined, 'predefined-model')
// Custom source
renderHook(() => useCredentialData(mockProvider, false), { wrapper })
expect(useGetCredential).toHaveBeenCalledWith('openai', undefined, undefined, undefined, 'custom-model')
})
it('returns appropriate loading and data states', () => {
const mockData = { api_key: 'test' }
vi.mocked(useGetCredential).mockReturnValue({ isLoading: true, data: undefined } as unknown as ReturnType<typeof useGetCredential>)
const mockProvider = { provider: 'openai' } as unknown as ModelProvider
const { result: loadingRes } = renderHook(() => useCredentialData(mockProvider, true), { wrapper })
expect(loadingRes.current.isLoading).toBe(true)
expect(loadingRes.current.credentialData).toEqual({})
vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: mockData } as unknown as ReturnType<typeof useGetCredential>)
const { result: dataRes } = renderHook(() => useCredentialData(mockProvider, true), { wrapper })
expect(dataRes.current.isLoading).toBe(false)
expect(dataRes.current.credentialData).toBe(mockData)
})
it('passes credential and model identifier correctly', () => {
vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: {} } as unknown as ReturnType<typeof useGetCredential>)
const mockProvider = { provider: 'openai' } as unknown as ModelProvider
const mockCredential = { credential_id: 'cred-123' } as unknown as Credential
const mockModel = { model: 'gpt-4' } as unknown as CustomModelCredential
renderHook(() => useCredentialData(mockProvider, true, true, mockCredential, mockModel), { wrapper })
expect(useGetCredential).toHaveBeenCalledWith('openai', true, 'cred-123', mockModel, 'predefined-model')
})
})

View File

@@ -0,0 +1,56 @@
import type { ModelProvider } from '../../declarations'
import { renderHook } from '@testing-library/react'
import { useCredentialStatus } from './use-credential-status'
describe('useCredentialStatus', () => {
it('computes authorized and authRemoved status correctly', () => {
// Authorized case
const authProvider = {
custom_configuration: {
current_credential_id: '123',
current_credential_name: 'Key',
available_credentials: [{ credential_id: '123', credential_name: 'Key' }],
},
} as unknown as ModelProvider
const { result: authRes } = renderHook(() => useCredentialStatus(authProvider))
expect(authRes.current.authorized).toBeTruthy()
expect(authRes.current.authRemoved).toBe(false)
// AuthRemoved case (found but not selected)
const removedProvider = {
custom_configuration: {
current_credential_id: '',
current_credential_name: '',
available_credentials: [{ credential_id: '123' }],
},
} as unknown as ModelProvider
const { result: removedRes } = renderHook(() => useCredentialStatus(removedProvider))
expect(removedRes.current.authRemoved).toBe(true)
expect(removedRes.current.authorized).toBeFalsy()
})
it('handles empty or restricted credentials', () => {
// Empty case
const emptyProvider = {
custom_configuration: { available_credentials: [] },
} as unknown as ModelProvider
const { result: emptyRes } = renderHook(() => useCredentialStatus(emptyProvider))
expect(emptyRes.current.hasCredential).toBe(false)
// Restricted case
const restrictedProvider = {
custom_configuration: {
current_credential_id: '123',
available_credentials: [{ credential_id: '123', not_allowed_to_use: true }],
},
} as unknown as ModelProvider
const { result: restrictedRes } = renderHook(() => useCredentialStatus(restrictedProvider))
expect(restrictedRes.current.notAllowedToUse).toBe(true)
})
it('handles undefined custom configuration gracefully', () => {
const { result } = renderHook(() => useCredentialStatus({ custom_configuration: {} } as ModelProvider))
expect(result.current.hasCredential).toBe(false)
expect(result.current.available_credentials).toBeUndefined()
})
})

View File

@@ -0,0 +1,38 @@
import type { ModelProvider } from '../../declarations'
import { renderHook } from '@testing-library/react'
import { useCanAddedModels, useCustomModels } from './use-custom-models'
describe('useCustomModels and useCanAddedModels', () => {
it('extracts custom models from provider correctly', () => {
const mockProvider = {
custom_configuration: {
custom_models: [
{ model: 'gpt-4', model_type: 'text-generation' },
{ model: 'gpt-3.5', model_type: 'text-generation' },
],
},
} as unknown as ModelProvider
const { result } = renderHook(() => useCustomModels(mockProvider))
expect(result.current).toHaveLength(2)
expect(result.current[0].model).toBe('gpt-4')
const { result: emptyRes } = renderHook(() => useCustomModels({ custom_configuration: {} } as unknown as ModelProvider))
expect(emptyRes.current).toEqual([])
})
it('extracts can_added_models from provider correctly', () => {
const mockProvider = {
custom_configuration: {
can_added_models: [{ model: 'gpt-4-turbo', model_type: 'text-generation' }],
},
} as unknown as ModelProvider
const { result } = renderHook(() => useCanAddedModels(mockProvider))
expect(result.current).toHaveLength(1)
expect(result.current[0].model).toBe('gpt-4-turbo')
const { result: emptyRes } = renderHook(() => useCanAddedModels({ custom_configuration: {} } as unknown as ModelProvider))
expect(emptyRes.current).toEqual([])
})
})

View File

@@ -0,0 +1,78 @@
import type {
Credential,
CustomModelCredential,
ModelProvider,
} from '../../declarations'
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { useModelFormSchemas } from './use-model-form-schemas'
vi.mock('../../utils', () => ({
genModelNameFormSchema: vi.fn(() => ({
type: FormTypeEnum.textInput,
variable: '__model_name',
label: 'Model Name',
required: true,
})),
genModelTypeFormSchema: vi.fn(() => ({
type: FormTypeEnum.select,
variable: '__model_type',
label: 'Model Type',
required: true,
})),
}))
describe('useModelFormSchemas', () => {
const mockProvider = {
provider: 'openai',
provider_credential_schema: {
credential_form_schemas: [
{ type: FormTypeEnum.textInput, variable: 'api_key', label: 'API Key', required: true },
],
},
model_credential_schema: {
credential_form_schemas: [
{ type: FormTypeEnum.textInput, variable: 'model_key', label: 'Model Key', required: true },
],
},
supported_model_types: ['text-generation'],
} as unknown as ModelProvider
it('selects correct form schemas based on providerFormSchemaPredefined', () => {
const { result: providerResult } = renderHook(() => useModelFormSchemas(mockProvider, true))
expect(providerResult.current.formSchemas.some(s => s.variable === 'api_key')).toBe(true)
const { result: modelResult } = renderHook(() => useModelFormSchemas(mockProvider, false))
expect(modelResult.current.formSchemas.some(s => s.variable === 'model_key')).toBe(true)
const { result: emptyResult } = renderHook(() => useModelFormSchemas({} as unknown as ModelProvider, true))
expect(emptyResult.current.formSchemas).toHaveLength(1) // only __authorization_name__
})
it('computes form values correctly for credentials and models', () => {
const mockCredential = { credential_name: 'Test' } as unknown as Credential
const mockModel = { model: 'gpt-4', model_type: 'text-generation' } as unknown as CustomModelCredential
const { result } = renderHook(() => useModelFormSchemas(mockProvider, true, { api_key: 'val' }, mockCredential, mockModel))
expect((result.current.formValues as Record<string, unknown>).api_key).toBe('val')
expect((result.current.formValues as Record<string, unknown>).__authorization_name__).toBe('Test')
expect((result.current.formValues as Record<string, unknown>).__model_name).toBe('gpt-4')
// Branch: credential present but credentials (param) missing
const { result: emptyCredsRes } = renderHook(() => useModelFormSchemas(mockProvider, true, undefined, mockCredential))
expect((emptyCredsRes.current.formValues as Record<string, unknown>).__authorization_name__).toBe('Test')
})
it('handles model name and type schemas for custom models', () => {
const { result: predefined } = renderHook(() => useModelFormSchemas(mockProvider, true))
expect(predefined.current.modelNameAndTypeFormSchemas).toHaveLength(0)
const { result: custom } = renderHook(() => useModelFormSchemas(mockProvider, false))
expect(custom.current.modelNameAndTypeFormSchemas).toHaveLength(2)
expect(custom.current.modelNameAndTypeFormSchemas[0].variable).toBe('__model_name')
const mockModel = { model: 'custom', model_type: 'text' } as unknown as CustomModelCredential
const { result: customWithVal } = renderHook(() => useModelFormSchemas(mockProvider, false, undefined, undefined, mockModel))
expect((customWithVal.current.modelNameAndTypeFormValues as Record<string, unknown>).__model_name).toBe('custom')
})
})

View File

@@ -0,0 +1,62 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import ManageCustomModelCredentials from './manage-custom-model-credentials'
// Mock hooks
const mockUseCustomModels = vi.fn()
vi.mock('./hooks', () => ({
useCustomModels: () => mockUseCustomModels(),
useAuth: () => ({
handleOpenModal: vi.fn(),
}),
}))
// Mock Authorized
vi.mock('./authorized', () => ({
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: { length: number }, popupTitle: string }) => (
<div data-testid="authorized-mock">
<div data-testid="trigger-container">{renderTrigger()}</div>
<div data-testid="popup-title">{popupTitle}</div>
<div data-testid="items-count">{items.length}</div>
</div>
),
}))
describe('ManageCustomModelCredentials', () => {
const mockProvider = {
provider: 'openai',
} as unknown as ModelProvider
beforeEach(() => {
vi.clearAllMocks()
})
it('should return null when no custom models exist', () => {
mockUseCustomModels.mockReturnValue([])
const { container } = render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(container.firstChild).toBeNull()
})
it('should render authorized component when custom models exist', () => {
const mockModels = [
{
model: 'gpt-4',
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
{
model: 'gpt-3.5',
// testing undefined credentials branch
},
]
mockUseCustomModels.mockReturnValue(mockModels)
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
expect(screen.getByText(/modelProvider.auth.manageCredentials/)).toBeInTheDocument()
expect(screen.getByTestId('items-count')).toHaveTextContent('2')
expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials')
})
})

View File

@@ -0,0 +1,130 @@
import type { CustomModel, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import SwitchCredentialInLoadBalancing from './switch-credential-in-load-balancing'
// Mock components
vi.mock('./authorized', () => ({
default: ({ renderTrigger, onItemClick, items }: { renderTrigger: () => React.ReactNode, onItemClick: (c: unknown) => void, items: { credentials: unknown[] }[] }) => (
<div data-testid="authorized-mock">
<div data-testid="trigger-container" onClick={() => onItemClick(items[0].credentials[0])}>
{renderTrigger()}
</div>
</div>
),
}))
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" />,
}))
describe('SwitchCredentialInLoadBalancing', () => {
const mockProvider = {
provider: 'openai',
allow_custom_token: true,
} as unknown as ModelProvider
const mockModel = {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
} as unknown as CustomModel
const mockCredentials = [
{ credential_id: 'cred-1', credential_name: 'Key 1' },
{ credential_id: 'cred-2', credential_name: 'Key 2' },
]
const mockSetCustomModelCredential = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render selected credential name correctly', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText('Key 1')).toBeInTheDocument()
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
})
it('should render auth removed status when selected credential is not in list', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={mockCredentials}
customModelCredential={{ credential_id: 'dead-cred', credential_name: 'Dead Key' }}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText(/modelProvider.auth.authRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
})
it('should render unavailable status when credentials list is empty', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[]}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument()
})
it('should call setCustomModelCredential when an item is selected in Authorized', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
fireEvent.click(screen.getByTestId('trigger-container'))
expect(mockSetCustomModelCredential).toHaveBeenCalledWith(mockCredentials[0])
})
it('should show tooltip when empty and custom credentials not allowed', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<SwitchCredentialInLoadBalancing
provider={restrictedProvider}
model={mockModel}
credentials={[]}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,206 @@
import type { PluginProvider } from '@/models/common'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import SerpapiPlugin from './SerpapiPlugin'
import { updatePluginKey, validatePluginKey } from './utils'
const mockEventEmitter = vi.hoisted(() => {
let subscriber: ((value: string) => void) | undefined
return {
useSubscription: vi.fn((callback: (value: string) => void) => {
subscriber = callback
}),
emit: vi.fn((value: string) => {
subscriber?.(value)
}),
reset: () => {
subscriber = undefined
},
}
})
vi.mock('@/app/components/base/toast', () => ({
useToastContext: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('./utils', () => ({
updatePluginKey: vi.fn(),
validatePluginKey: vi.fn(),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: vi.fn(() => ({
eventEmitter: mockEventEmitter,
})),
}))
describe('SerpapiPlugin', () => {
const mockOnUpdate = vi.fn()
const mockNotify = vi.fn()
const mockUpdatePluginKey = updatePluginKey as ReturnType<typeof vi.fn>
const mockValidatePluginKey = validatePluginKey as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockEventEmitter.reset()
const mockUseAppContext = useAppContext as ReturnType<typeof vi.fn>
const mockUseToastContext = useToastContext as ReturnType<typeof vi.fn>
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceManager: true,
})
mockUseToastContext.mockReturnValue({
notify: mockNotify,
})
mockValidatePluginKey.mockResolvedValue({ status: 'success' })
mockUpdatePluginKey.mockResolvedValue({ status: 'success' })
})
it('should show key input when manager clicks edit key', () => {
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument()
})
it('should clear existing key on focus and show validation error for invalid key', async () => {
vi.useFakeTimers()
try {
mockValidatePluginKey.mockResolvedValue({ status: 'error', message: 'Invalid API key' })
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
const input = screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')
expect(input).toHaveValue('existing-key')
fireEvent.focus(input)
expect(input).toHaveValue('')
fireEvent.change(input, {
target: { value: 'invalid-key' },
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(screen.getByText(/Invalid API key/)).toBeInTheDocument()
fireEvent.focus(input)
expect(input).toHaveValue('invalid-key')
fireEvent.change(input, {
target: { value: '' },
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(screen.queryByText(/Invalid API key/)).toBeNull()
}
finally {
vi.useRealTimers()
}
})
it('should not open key input when user is not workspace manager', () => {
const mockUseAppContext = useAppContext as ReturnType<typeof vi.fn>
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceManager: false,
})
const mockPlugin = {
tool_name: 'serpapi',
is_enabled: true,
credentials: null,
} satisfies PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
expect(screen.queryByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeNull()
})
it('should save changed key and trigger success feedback', async () => {
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
fireEvent.change(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder'), {
target: { value: 'new-key' },
})
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.queryByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeNull()
})
})
it('should keep editor open when save request fails', async () => {
mockUpdatePluginKey.mockResolvedValue({ status: 'error', message: 'update failed' })
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
fireEvent.change(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder'), {
target: { value: 'new-key' },
})
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument()
})
})
it('should keep editor open when key value is unchanged', async () => {
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,118 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { useAppContext } from '@/context/app-context'
import PluginPage from './index'
import { updatePluginKey, validatePluginKey } from './utils'
const mockUsePluginProviders = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-common', () => ({
usePluginProviders: mockUsePluginProviders,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: vi.fn(),
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: vi.fn(),
useSubscription: vi.fn(),
},
}),
}))
vi.mock('./utils', () => ({
updatePluginKey: vi.fn(),
validatePluginKey: vi.fn(),
}))
describe('PluginPage', () => {
const mockUpdatePluginKey = updatePluginKey as ReturnType<typeof vi.fn>
const mockValidatePluginKey = validatePluginKey as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
const mockUseAppContext = useAppContext as ReturnType<typeof vi.fn>
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceManager: true,
})
mockValidatePluginKey.mockResolvedValue({ status: 'success' })
mockUpdatePluginKey.mockResolvedValue({ status: 'success' })
})
it('should render plugin settings with edit action when serpapi key exists', () => {
mockUsePluginProviders.mockReturnValue({
data: [
{ tool_name: 'serpapi', credentials: { api_key: 'test-key' } },
],
refetch: vi.fn(),
})
render(<PluginPage />)
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
})
it('should render plugin settings with add action when serpapi key is missing', () => {
mockUsePluginProviders.mockReturnValue({
data: [
{ tool_name: 'serpapi', credentials: null },
],
refetch: vi.fn(),
})
render(<PluginPage />)
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
})
it('should display encryption notice with PKCS1_OAEP link', () => {
mockUsePluginProviders.mockReturnValue({
data: [],
refetch: vi.fn(),
})
render(<PluginPage />)
expect(screen.getByText(/common\.provider\.encrypted\.front/)).toBeInTheDocument()
expect(screen.getByText(/common\.provider\.encrypted\.back/)).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
})
it('should show reload state after saving key', async () => {
let showReloadedState = () => {}
const Wrapper = () => {
const [reloaded, setReloaded] = useState(false)
showReloadedState = () => setReloaded(true)
return (
<>
<PluginPage />
{reloaded && <div>providers-reloaded</div>}
</>
)
}
mockUsePluginProviders.mockImplementation(() => ({
data: [{ tool_name: 'serpapi', credentials: { api_key: 'existing-key' } }],
refetch: () => showReloadedState(),
}))
render(<Wrapper />)
fireEvent.click(screen.getByText('common.provider.editKey'))
fireEvent.change(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder'), {
target: { value: 'new-key' },
})
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.getByText('providers-reloaded')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,73 @@
import { updatePluginProviderAIKey, validatePluginProviderKey } from '@/service/common'
import { ValidatedStatus } from '../key-validator/declarations'
import { updatePluginKey, validatePluginKey } from './utils'
vi.mock('@/service/common', () => ({
validatePluginProviderKey: vi.fn(),
updatePluginProviderAIKey: vi.fn(),
}))
const mockValidatePluginProviderKey = validatePluginProviderKey as ReturnType<typeof vi.fn>
const mockUpdatePluginProviderAIKey = updatePluginProviderAIKey as ReturnType<typeof vi.fn>
describe('Plugin Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe.each([
{
name: 'validatePluginKey',
utilFn: validatePluginKey,
serviceMock: mockValidatePluginProviderKey,
successBody: { credentials: { api_key: 'test-key' } },
failureBody: { credentials: { api_key: 'invalid' } },
exceptionBody: { credentials: { api_key: 'test' } },
serviceErrorMessage: 'Invalid API key',
thrownErrorMessage: 'Network error',
},
{
name: 'updatePluginKey',
utilFn: updatePluginKey,
serviceMock: mockUpdatePluginProviderAIKey,
successBody: { credentials: { api_key: 'new-key' } },
failureBody: { credentials: { api_key: 'test' } },
exceptionBody: { credentials: { api_key: 'test' } },
serviceErrorMessage: 'Update failed',
thrownErrorMessage: 'Request failed',
},
])('$name', ({ utilFn, serviceMock, successBody, failureBody, exceptionBody, serviceErrorMessage, thrownErrorMessage }) => {
it('should return success status when service succeeds', async () => {
serviceMock.mockResolvedValue({ result: 'success' })
const result = await utilFn('serpapi', successBody)
expect(result.status).toBe(ValidatedStatus.Success)
})
it('should return error status with message when service returns an error', async () => {
serviceMock.mockResolvedValue({
result: 'error',
error: serviceErrorMessage,
})
const result = await utilFn('serpapi', failureBody)
expect(result).toMatchObject({
status: ValidatedStatus.Error,
message: serviceErrorMessage,
})
})
it('should return error status when service throws exception', async () => {
serviceMock.mockRejectedValue(new Error(thrownErrorMessage))
const result = await utilFn('serpapi', exceptionBody)
expect(result).toMatchObject({
status: ValidatedStatus.Error,
message: thrownErrorMessage,
})
})
})
})

View File

@@ -1,4 +1,5 @@
import type { import type {
CommonNodeType,
Node, Node,
OnSelectBlock, OnSelectBlock,
} from '@/app/components/workflow/types' } from '@/app/components/workflow/types'
@@ -16,6 +17,7 @@ import {
useNodesInteractions, useNodesInteractions,
} from '@/app/components/workflow/hooks' } from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types'
import { FlowType } from '@/types/common' import { FlowType } from '@/types/common'
@@ -38,12 +40,17 @@ const ChangeBlock = ({
} = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop) } = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop)
const isChatMode = useIsChatMode() const isChatMode = useIsChatMode()
const flowType = useHooksStore(s => s.configsMap?.flowType) const flowType = useHooksStore(s => s.configsMap?.flowType)
const showStartTab = flowType !== FlowType.ragPipeline && !isChatMode const nodes = useNodes()
const hasStartNode = useMemo(() => {
return nodes.some(n => (n.data as CommonNodeType | undefined)?.type === BlockEnum.Start)
}, [nodes])
const showStartTab = flowType !== FlowType.ragPipeline && (!isChatMode || nodeData.type === BlockEnum.Start || !hasStartNode)
const ignoreNodeIds = useMemo(() => { const ignoreNodeIds = useMemo(() => {
if (isTriggerNode(nodeData.type as BlockEnum)) if (isTriggerNode(nodeData.type as BlockEnum) || nodeData.type === BlockEnum.Start)
return [nodeId] return [nodeId]
return undefined return undefined
}, [nodeData.type, nodeId]) }, [nodeData.type, nodeId])
const allowStartNodeSelection = !hasStartNode
const availableNodes = useMemo(() => { const availableNodes = useMemo(() => {
if (availablePrevBlocks.length && availableNextBlocks.length) if (availablePrevBlocks.length && availableNextBlocks.length)
@@ -80,6 +87,7 @@ const ChangeBlock = ({
showStartTab={showStartTab} showStartTab={showStartTab}
ignoreNodeIds={ignoreNodeIds} ignoreNodeIds={ignoreNodeIds}
forceEnableStartTab={nodeData.type === BlockEnum.Start} forceEnableStartTab={nodeData.type === BlockEnum.Start}
allowUserInputSelection={allowStartNodeSelection}
/> />
) )
} }

View File

@@ -2351,9 +2351,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": { "react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1 "count": 1
}, },
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1
} }
@@ -2363,21 +2360,11 @@
"count": 3 "count": 3
} }
}, },
"app/components/base/modal-like-wrap/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/modal/index.stories.tsx": { "app/components/base/modal/index.stories.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": { "react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1 "count": 1
} }
}, },
"app/components/base/modal/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/modal/modal.stories.tsx": { "app/components/base/modal/modal.stories.tsx": {
"no-console": { "no-console": {
"count": 4 "count": 4
@@ -2386,11 +2373,6 @@
"count": 1 "count": 1
} }
}, },
"app/components/base/modal/modal.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/new-audio-button/index.tsx": { "app/components/base/new-audio-button/index.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1
@@ -2626,11 +2608,6 @@
"count": 1 "count": 1
} }
}, },
"app/components/base/qrcode/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/radio-card/index.stories.tsx": { "app/components/base/radio-card/index.stories.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1