mirror of
https://github.com/langgenius/dify.git
synced 2026-03-24 01:07:08 +00:00
Compare commits
3 Commits
test/use-c
...
scdeng/mai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f8e1f9cc0 | ||
|
|
dd03e2fe2a | ||
|
|
8b082c13d3 |
@@ -4,7 +4,7 @@ from typing import Literal
|
||||
from flask import request
|
||||
from flask_restx import Resource, fields, marshal_with
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy import exists, select
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
@@ -244,25 +244,27 @@ class ChatMessageListApi(Resource):
|
||||
def get(self, app_model):
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
|
||||
conversation = db.session.scalar(
|
||||
select(Conversation)
|
||||
conversation = (
|
||||
db.session.query(Conversation)
|
||||
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
if args.first_id:
|
||||
first_message = db.session.scalar(
|
||||
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
|
||||
first_message = (
|
||||
db.session.query(Message)
|
||||
.where(Message.conversation_id == conversation.id, Message.id == args.first_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not first_message:
|
||||
raise NotFound("First message not found")
|
||||
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
history_messages = (
|
||||
db.session.query(Message)
|
||||
.where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < first_message.created_at,
|
||||
@@ -270,14 +272,16 @@ class ChatMessageListApi(Resource):
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
history_messages = (
|
||||
db.session.query(Message)
|
||||
.where(Message.conversation_id == conversation.id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
.all()
|
||||
)
|
||||
|
||||
# Initialize has_more based on whether we have a full page
|
||||
if len(history_messages) == args.limit:
|
||||
@@ -322,9 +326,7 @@ class MessageFeedbackApi(Resource):
|
||||
|
||||
message_id = str(args.message_id)
|
||||
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
message = db.session.query(Message).where(Message.id == message_id, Message.app_id == app_model.id).first()
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
@@ -373,9 +375,7 @@ class MessageAnnotationCountApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model):
|
||||
count = db.session.scalar(
|
||||
select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id)
|
||||
)
|
||||
count = db.session.query(MessageAnnotation).where(MessageAnnotation.app_id == app_model.id).count()
|
||||
|
||||
return {"count": count}
|
||||
|
||||
@@ -479,9 +479,7 @@ class MessageApi(Resource):
|
||||
def get(self, app_model, message_id: str):
|
||||
message_id = str(message_id)
|
||||
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
message = db.session.query(Message).where(Message.id == message_id, Message.app_id == app_model.id).first()
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import httpx
|
||||
from flask import current_app, redirect, request
|
||||
@@ -113,9 +112,6 @@ class OAuthCallback(Resource):
|
||||
error_text = e.response.text
|
||||
logger.exception("An error occurred during the OAuth process with %s: %s", provider, error_text)
|
||||
return {"error": "OAuth process failed"}, 400
|
||||
except ValueError as e:
|
||||
logger.warning("OAuth error with %s", provider, exc_info=True)
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={urllib.parse.quote(str(e))}")
|
||||
|
||||
if invite_token and RegisterService.is_valid_invite_token(invite_token):
|
||||
invitation = RegisterService.get_invitation_by_token(token=invite_token)
|
||||
|
||||
@@ -181,6 +181,10 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
arize_phoenix_config: ArizeConfig | PhoenixConfig,
|
||||
):
|
||||
super().__init__(arize_phoenix_config)
|
||||
import logging
|
||||
|
||||
logging.basicConfig()
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
self.arize_phoenix_config = arize_phoenix_config
|
||||
self.tracer, self.processor = setup_tracer(arize_phoenix_config)
|
||||
self.project = arize_phoenix_config.project
|
||||
|
||||
@@ -50,7 +50,7 @@ class BuiltinTool(Tool):
|
||||
return ModelInvocationUtils.invoke(
|
||||
user_id=user_id,
|
||||
tenant_id=self.runtime.tenant_id or "",
|
||||
tool_type=ToolProviderType.BUILT_IN,
|
||||
tool_type="builtin",
|
||||
tool_name=self.entity.identity.name,
|
||||
prompt_messages=prompt_messages,
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ class ToolLabelManager:
|
||||
db.session.add(
|
||||
ToolLabelBinding(
|
||||
tool_id=provider_id,
|
||||
tool_type=controller.provider_type,
|
||||
tool_type=controller.provider_type.value,
|
||||
label_name=label,
|
||||
)
|
||||
)
|
||||
@@ -58,7 +58,7 @@ class ToolLabelManager:
|
||||
raise ValueError("Unsupported tool type")
|
||||
stmt = select(ToolLabelBinding.label_name).where(
|
||||
ToolLabelBinding.tool_id == provider_id,
|
||||
ToolLabelBinding.tool_type == controller.provider_type,
|
||||
ToolLabelBinding.tool_type == controller.provider_type.value,
|
||||
)
|
||||
labels = db.session.scalars(stmt).all()
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from decimal import Decimal
|
||||
from typing import cast
|
||||
|
||||
from core.model_manager import ModelManager
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from dify_graph.model_runtime.entities.llm_entities import LLMResult
|
||||
from dify_graph.model_runtime.entities.message_entities import PromptMessage
|
||||
from dify_graph.model_runtime.entities.model_entities import ModelPropertyKey, ModelType
|
||||
@@ -79,7 +78,7 @@ class ModelInvocationUtils:
|
||||
|
||||
@staticmethod
|
||||
def invoke(
|
||||
user_id: str, tenant_id: str, tool_type: ToolProviderType, tool_name: str, prompt_messages: list[PromptMessage]
|
||||
user_id: str, tenant_id: str, tool_type: str, tool_name: str, prompt_messages: list[PromptMessage]
|
||||
) -> LLMResult:
|
||||
"""
|
||||
invoke model with parameters in user's own context
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from core.model_manager import ModelInstance
|
||||
@@ -36,6 +39,11 @@ from .exc import (
|
||||
)
|
||||
from .protocols import TemplateRenderer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VARIABLE_PATTERN = re.compile(r"\{\{#[^#]+#\}\}")
|
||||
MAX_RESOLVED_VALUE_LENGTH = 1024
|
||||
|
||||
|
||||
def fetch_model_schema(*, model_instance: ModelInstance) -> AIModelEntity:
|
||||
model_schema = cast(LargeLanguageModel, model_instance.model_type_instance).get_model_schema(
|
||||
@@ -475,3 +483,61 @@ def _append_file_prompts(
|
||||
prompt_messages[-1] = UserPromptMessage(content=file_prompts + existing_contents)
|
||||
else:
|
||||
prompt_messages.append(UserPromptMessage(content=file_prompts))
|
||||
|
||||
|
||||
def _coerce_resolved_value(raw: str) -> int | float | bool | str:
|
||||
"""Try to restore the original type from a resolved template string.
|
||||
|
||||
Variable references are always resolved to text, but completion params may
|
||||
expect numeric or boolean values (e.g. a variable that holds "0.7" mapped to
|
||||
the ``temperature`` parameter). This helper attempts a JSON parse so that
|
||||
``"0.7"`` → ``0.7``, ``"true"`` → ``True``, etc. Plain strings that are not
|
||||
valid JSON literals are returned as-is.
|
||||
"""
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
return raw
|
||||
|
||||
try:
|
||||
parsed: object = json.loads(stripped)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return raw
|
||||
|
||||
if isinstance(parsed, (int, float, bool)):
|
||||
return parsed
|
||||
return raw
|
||||
|
||||
|
||||
def resolve_completion_params_variables(
|
||||
completion_params: Mapping[str, Any],
|
||||
variable_pool: VariablePool,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve variable references (``{{#node_id.var#}}``) in string-typed completion params.
|
||||
|
||||
Security notes:
|
||||
- Resolved values are length-capped to ``MAX_RESOLVED_VALUE_LENGTH`` to
|
||||
prevent denial-of-service through excessively large variable payloads.
|
||||
- This follows the same ``VariablePool.convert_template`` pattern used across
|
||||
Dify (Answer Node, HTTP Request Node, Agent Node, etc.). The downstream
|
||||
model plugin receives these values as structured JSON key-value pairs — they
|
||||
are never concatenated into raw HTTP headers or SQL queries.
|
||||
- Numeric/boolean coercion is applied so that variables holding ``"0.7"`` are
|
||||
restored to their native type rather than sent as a bare string.
|
||||
"""
|
||||
resolved: dict[str, Any] = {}
|
||||
for key, value in completion_params.items():
|
||||
if isinstance(value, str) and VARIABLE_PATTERN.search(value):
|
||||
segment_group = variable_pool.convert_template(value)
|
||||
text = segment_group.text
|
||||
if len(text) > MAX_RESOLVED_VALUE_LENGTH:
|
||||
logger.warning(
|
||||
"Resolved value for param '%s' truncated from %d to %d chars",
|
||||
key,
|
||||
len(text),
|
||||
MAX_RESOLVED_VALUE_LENGTH,
|
||||
)
|
||||
text = text[:MAX_RESOLVED_VALUE_LENGTH]
|
||||
resolved[key] = _coerce_resolved_value(text)
|
||||
else:
|
||||
resolved[key] = value
|
||||
return resolved
|
||||
|
||||
@@ -202,6 +202,10 @@ class LLMNode(Node[LLMNodeData]):
|
||||
|
||||
# fetch model config
|
||||
model_instance = self._model_instance
|
||||
# Resolve variable references in string-typed completion params
|
||||
model_instance.parameters = llm_utils.resolve_completion_params_variables(
|
||||
model_instance.parameters, variable_pool
|
||||
)
|
||||
model_name = model_instance.model_name
|
||||
model_provider = model_instance.provider
|
||||
model_stop = model_instance.stop
|
||||
|
||||
@@ -164,6 +164,10 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
|
||||
)
|
||||
|
||||
model_instance = self._model_instance
|
||||
# Resolve variable references in string-typed completion params
|
||||
model_instance.parameters = llm_utils.resolve_completion_params_variables(
|
||||
model_instance.parameters, variable_pool
|
||||
)
|
||||
if not isinstance(model_instance.model_type_instance, LargeLanguageModel):
|
||||
raise InvalidModelTypeError("Model is not a Large Language Model")
|
||||
|
||||
|
||||
@@ -114,6 +114,10 @@ class QuestionClassifierNode(Node[QuestionClassifierNodeData]):
|
||||
variables = {"query": query}
|
||||
# fetch model instance
|
||||
model_instance = self._model_instance
|
||||
# Resolve variable references in string-typed completion params
|
||||
model_instance.parameters = llm_utils.resolve_completion_params_variables(
|
||||
model_instance.parameters, variable_pool
|
||||
)
|
||||
memory = self._memory
|
||||
# fetch instruction
|
||||
node_data.instruction = node_data.instruction or ""
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import logging
|
||||
import sys
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from typing import NotRequired
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import TypedDict
|
||||
else:
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JsonObject = dict[str, object]
|
||||
JsonObjectList = list[JsonObject]
|
||||
|
||||
@@ -33,8 +30,8 @@ class GitHubEmailRecord(TypedDict, total=False):
|
||||
class GitHubRawUserInfo(TypedDict):
|
||||
id: int | str
|
||||
login: str
|
||||
name: NotRequired[str | None]
|
||||
email: NotRequired[str | None]
|
||||
name: NotRequired[str]
|
||||
email: NotRequired[str]
|
||||
|
||||
|
||||
class GoogleRawUserInfo(TypedDict):
|
||||
@@ -130,14 +127,9 @@ class GitHubOAuth(OAuth):
|
||||
response.raise_for_status()
|
||||
user_info = GITHUB_RAW_USER_INFO_ADAPTER.validate_python(_json_object(response))
|
||||
|
||||
try:
|
||||
email_response = httpx.get(self._EMAIL_INFO_URL, headers=headers)
|
||||
email_response.raise_for_status()
|
||||
email_info = GITHUB_EMAIL_RECORDS_ADAPTER.validate_python(_json_list(email_response))
|
||||
primary_email = next((email for email in email_info if email.get("primary") is True), None)
|
||||
except (httpx.HTTPStatusError, ValidationError):
|
||||
logger.warning("Failed to retrieve email from GitHub /user/emails endpoint", exc_info=True)
|
||||
primary_email = None
|
||||
email_response = httpx.get(self._EMAIL_INFO_URL, headers=headers)
|
||||
email_info = GITHUB_EMAIL_RECORDS_ADAPTER.validate_python(_json_list(email_response))
|
||||
primary_email = next((email for email in email_info if email.get("primary") is True), None)
|
||||
|
||||
return {**user_info, "email": primary_email.get("email", "") if primary_email else ""}
|
||||
|
||||
@@ -145,11 +137,8 @@ class GitHubOAuth(OAuth):
|
||||
payload = GITHUB_RAW_USER_INFO_ADAPTER.validate_python(raw_info)
|
||||
email = payload.get("email")
|
||||
if not email:
|
||||
raise ValueError(
|
||||
'Dify currently not supports the "Keep my email addresses private" feature,'
|
||||
" please disable it and login again"
|
||||
)
|
||||
return OAuthUserInfo(id=str(payload["id"]), name=str(payload.get("name") or ""), email=email)
|
||||
email = f"{payload['id']}+{payload['login']}@users.noreply.github.com"
|
||||
return OAuthUserInfo(id=str(payload["id"]), name=str(payload.get("name", "")), email=email)
|
||||
|
||||
|
||||
class GoogleOAuth(OAuth):
|
||||
|
||||
@@ -66,8 +66,8 @@ class HumanInputContent(ExecutionExtraContent):
|
||||
form_id: Mapped[str] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
@classmethod
|
||||
def new(cls, *, workflow_run_id: str, form_id: str, message_id: str | None) -> "HumanInputContent":
|
||||
return cls(workflow_run_id=workflow_run_id, form_id=form_id, message_id=message_id)
|
||||
def new(cls, form_id: str, message_id: str | None) -> "HumanInputContent":
|
||||
return cls(form_id=form_id, message_id=message_id)
|
||||
|
||||
form: Mapped["HumanInputForm"] = relationship(
|
||||
"HumanInputForm",
|
||||
|
||||
@@ -43,7 +43,6 @@ from .enums import (
|
||||
MessageChainType,
|
||||
MessageFileBelongsTo,
|
||||
MessageStatus,
|
||||
TagType,
|
||||
)
|
||||
from .provider_ids import GenericProviderID
|
||||
from .types import EnumText, LongText, StringUUID
|
||||
@@ -2405,7 +2404,7 @@ class Tag(TypeBase):
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
tenant_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
type: Mapped[TagType] = mapped_column(EnumText(TagType, length=16), nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -13,16 +13,12 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_bundle import ApiToolBundle
|
||||
from core.tools.entities.tool_entities import (
|
||||
ApiProviderSchemaType,
|
||||
ToolProviderType,
|
||||
WorkflowToolParameterConfiguration,
|
||||
)
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
|
||||
|
||||
from .base import TypeBase
|
||||
from .engine import db
|
||||
from .model import Account, App, Tenant
|
||||
from .types import EnumText, LongText, StringUUID
|
||||
from .types import LongText, StringUUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.entities.mcp_provider import MCPProviderEntity
|
||||
@@ -212,7 +208,7 @@ class ToolLabelBinding(TypeBase):
|
||||
# tool id
|
||||
tool_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# tool type
|
||||
tool_type: Mapped[ToolProviderType] = mapped_column(EnumText(ToolProviderType, length=40), nullable=False)
|
||||
tool_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
# label name
|
||||
label_name: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
|
||||
@@ -390,7 +386,7 @@ class ToolModelInvoke(TypeBase):
|
||||
# provider
|
||||
provider: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# type
|
||||
tool_type: Mapped[ToolProviderType] = mapped_column(EnumText(ToolProviderType, length=40), nullable=False)
|
||||
tool_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
# tool name
|
||||
tool_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# invoke parameters
|
||||
|
||||
@@ -8,7 +8,7 @@ dependencies = [
|
||||
"arize-phoenix-otel~=0.15.0",
|
||||
"azure-identity==1.25.3",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"boto3==1.42.73",
|
||||
"boto3==1.42.68",
|
||||
"bs4~=0.0.1",
|
||||
"cachetools~=5.3.0",
|
||||
"celery~=5.6.2",
|
||||
@@ -23,7 +23,7 @@ dependencies = [
|
||||
"gevent~=25.9.1",
|
||||
"gmpy2~=2.3.0",
|
||||
"google-api-core>=2.19.1",
|
||||
"google-api-python-client==2.193.0",
|
||||
"google-api-python-client==2.192.0",
|
||||
"google-auth>=2.47.0",
|
||||
"google-auth-httplib2==0.3.0",
|
||||
"google-cloud-aiplatform>=1.123.0",
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"numpy~=1.26.4",
|
||||
"openpyxl~=3.1.5",
|
||||
"opik~=1.10.37",
|
||||
"litellm==1.82.6", # Pinned to avoid madoka dependency issue
|
||||
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
|
||||
"opentelemetry-api==1.28.0",
|
||||
"opentelemetry-distro==0.49b0",
|
||||
"opentelemetry-exporter-otlp==1.28.0",
|
||||
@@ -72,10 +72,10 @@ dependencies = [
|
||||
"pyyaml~=6.0.1",
|
||||
"readabilipy~=0.3.0",
|
||||
"redis[hiredis]~=7.3.0",
|
||||
"resend~=2.26.0",
|
||||
"sentry-sdk[flask]~=2.55.0",
|
||||
"resend~=2.23.0",
|
||||
"sentry-sdk[flask]~=2.54.0",
|
||||
"sqlalchemy~=2.0.29",
|
||||
"starlette==1.0.0",
|
||||
"starlette==0.52.1",
|
||||
"tiktoken~=0.12.0",
|
||||
"transformers~=5.3.0",
|
||||
"unstructured[docx,epub,md,ppt,pptx]~=0.21.5",
|
||||
@@ -92,7 +92,7 @@ dependencies = [
|
||||
"apscheduler>=3.11.0",
|
||||
"weave>=0.52.16",
|
||||
"fastopenapi[flask]>=0.7.0",
|
||||
"bleach~=6.3.0",
|
||||
"bleach~=6.2.0",
|
||||
]
|
||||
# Before adding new dependency, consider place it in
|
||||
# alphabet order (a-z) and suitable group.
|
||||
@@ -119,7 +119,7 @@ dev = [
|
||||
"ruff~=0.15.5",
|
||||
"pytest~=9.0.2",
|
||||
"pytest-benchmark~=5.2.3",
|
||||
"pytest-cov~=7.1.0",
|
||||
"pytest-cov~=7.0.0",
|
||||
"pytest-env~=1.6.0",
|
||||
"pytest-mock~=3.15.1",
|
||||
"testcontainers~=4.14.1",
|
||||
@@ -203,7 +203,7 @@ tools = ["cloudscraper~=1.2.71", "nltk~=3.9.1"]
|
||||
# Required by vector store clients
|
||||
############################################################
|
||||
vdb = [
|
||||
"alibabacloud_gpdb20160503~=5.1.0",
|
||||
"alibabacloud_gpdb20160503~=3.8.0",
|
||||
"alibabacloud_tea_openapi~=0.4.3",
|
||||
"chromadb==0.5.20",
|
||||
"clickhouse-connect~=0.14.1",
|
||||
|
||||
@@ -7,7 +7,6 @@ from werkzeug.exceptions import NotFound
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.dataset import Dataset
|
||||
from models.enums import TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
|
||||
|
||||
@@ -84,7 +83,7 @@ class TagService:
|
||||
raise ValueError("Tag name already exists")
|
||||
tag = Tag(
|
||||
name=args["name"],
|
||||
type=TagType(args["type"]),
|
||||
type=args["type"],
|
||||
created_by=current_user.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
|
||||
from tests.test_containers_integration_tests.helpers.execution_extra_content import (
|
||||
create_human_input_message_fixture,
|
||||
)
|
||||
|
||||
|
||||
def test_get_by_message_ids_returns_human_input_content(db_session_with_containers):
|
||||
fixture = create_human_input_message_fixture(db_session_with_containers)
|
||||
repository = SQLAlchemyExecutionExtraContentRepository(
|
||||
session_maker=sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
)
|
||||
|
||||
results = repository.get_by_message_ids([fixture.message.id])
|
||||
|
||||
assert len(results) == 1
|
||||
assert len(results[0]) == 1
|
||||
content = results[0][0]
|
||||
assert content.submitted is True
|
||||
assert content.form_submission_data is not None
|
||||
assert content.form_submission_data.action_id == fixture.action_id
|
||||
assert content.form_submission_data.action_text == fixture.action_text
|
||||
assert content.form_submission_data.rendered_content == fixture.form.rendered_content
|
||||
@@ -1,407 +0,0 @@
|
||||
"""Integration tests for SQLAlchemyExecutionExtraContentRepository using Testcontainers.
|
||||
|
||||
Part of #32454 — replaces the mock-based unit tests with real database interactions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, delete, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from dify_graph.nodes.human_input.entities import FormDefinition, UserAction
|
||||
from dify_graph.nodes.human_input.enums import HumanInputFormStatus
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.enums import ConversationFromSource, InvokeFrom
|
||||
from models.execution_extra_content import ExecutionExtraContent, HumanInputContent
|
||||
from models.human_input import (
|
||||
ConsoleRecipientPayload,
|
||||
HumanInputDelivery,
|
||||
HumanInputForm,
|
||||
HumanInputFormRecipient,
|
||||
RecipientType,
|
||||
)
|
||||
from models.model import App, Conversation, Message
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TestScope:
|
||||
"""Per-test data scope used to isolate DB rows.
|
||||
|
||||
IDs are populated after flushing the base entities to the database.
|
||||
"""
|
||||
|
||||
tenant_id: str = ""
|
||||
app_id: str = ""
|
||||
user_id: str = ""
|
||||
|
||||
|
||||
def _cleanup_scope_data(session: Session, scope: _TestScope) -> None:
|
||||
"""Remove test-created DB rows for a test scope."""
|
||||
form_ids_subquery = select(HumanInputForm.id).where(
|
||||
HumanInputForm.tenant_id == scope.tenant_id,
|
||||
)
|
||||
session.execute(delete(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids_subquery)))
|
||||
session.execute(delete(HumanInputDelivery).where(HumanInputDelivery.form_id.in_(form_ids_subquery)))
|
||||
session.execute(
|
||||
delete(ExecutionExtraContent).where(
|
||||
ExecutionExtraContent.workflow_run_id.in_(
|
||||
select(HumanInputForm.workflow_run_id).where(HumanInputForm.tenant_id == scope.tenant_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
session.execute(delete(HumanInputForm).where(HumanInputForm.tenant_id == scope.tenant_id))
|
||||
session.execute(delete(Message).where(Message.app_id == scope.app_id))
|
||||
session.execute(delete(Conversation).where(Conversation.app_id == scope.app_id))
|
||||
session.execute(delete(App).where(App.id == scope.app_id))
|
||||
session.execute(delete(TenantAccountJoin).where(TenantAccountJoin.tenant_id == scope.tenant_id))
|
||||
session.execute(delete(Account).where(Account.id == scope.user_id))
|
||||
session.execute(delete(Tenant).where(Tenant.id == scope.tenant_id))
|
||||
session.commit()
|
||||
|
||||
|
||||
def _seed_base_entities(session: Session, scope: _TestScope) -> None:
|
||||
"""Create the base tenant, account, and app needed by tests."""
|
||||
tenant = Tenant(name="Test Tenant")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
scope.tenant_id = tenant.id
|
||||
|
||||
account = Account(
|
||||
name="Test Account",
|
||||
email=f"test_{uuid4()}@example.com",
|
||||
password="hashed-password",
|
||||
password_salt="salt",
|
||||
interface_language="en-US",
|
||||
timezone="UTC",
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
scope.user_id = account.id
|
||||
|
||||
tenant_join = TenantAccountJoin(
|
||||
tenant_id=scope.tenant_id,
|
||||
account_id=scope.user_id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
current=True,
|
||||
)
|
||||
session.add(tenant_join)
|
||||
|
||||
app = App(
|
||||
tenant_id=scope.tenant_id,
|
||||
name="Test App",
|
||||
description="",
|
||||
mode="chat",
|
||||
icon_type="emoji",
|
||||
icon="bot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
api_rpm=100,
|
||||
api_rph=100,
|
||||
is_demo=False,
|
||||
is_public=False,
|
||||
is_universal=False,
|
||||
created_by=scope.user_id,
|
||||
updated_by=scope.user_id,
|
||||
)
|
||||
session.add(app)
|
||||
session.flush()
|
||||
scope.app_id = app.id
|
||||
|
||||
|
||||
def _create_conversation(session: Session, scope: _TestScope) -> Conversation:
|
||||
conversation = Conversation(
|
||||
app_id=scope.app_id,
|
||||
mode="chat",
|
||||
name="Test Conversation",
|
||||
summary="",
|
||||
introduction="",
|
||||
system_instruction="",
|
||||
status="normal",
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=scope.user_id,
|
||||
from_end_user_id=None,
|
||||
)
|
||||
conversation.inputs = {}
|
||||
session.add(conversation)
|
||||
session.flush()
|
||||
return conversation
|
||||
|
||||
|
||||
def _create_message(
|
||||
session: Session,
|
||||
scope: _TestScope,
|
||||
conversation_id: str,
|
||||
workflow_run_id: str,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
app_id=scope.app_id,
|
||||
conversation_id=conversation_id,
|
||||
inputs={},
|
||||
query="test query",
|
||||
message={"messages": []},
|
||||
answer="test answer",
|
||||
message_tokens=50,
|
||||
message_unit_price=Decimal("0.001"),
|
||||
answer_tokens=80,
|
||||
answer_unit_price=Decimal("0.001"),
|
||||
provider_response_latency=0.5,
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=scope.user_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
session.add(message)
|
||||
session.flush()
|
||||
return message
|
||||
|
||||
|
||||
def _create_submitted_form(
|
||||
session: Session,
|
||||
scope: _TestScope,
|
||||
*,
|
||||
workflow_run_id: str,
|
||||
action_id: str = "approve",
|
||||
action_title: str = "Approve",
|
||||
node_title: str = "Approval",
|
||||
) -> HumanInputForm:
|
||||
expiration_time = datetime.utcnow() + timedelta(days=1)
|
||||
form_definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
user_actions=[UserAction(id=action_id, title=action_title)],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
node_title=node_title,
|
||||
display_in_ui=True,
|
||||
)
|
||||
form = HumanInputForm(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id="node-id",
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content=f"Rendered {action_title}",
|
||||
status=HumanInputFormStatus.SUBMITTED,
|
||||
expiration_time=expiration_time,
|
||||
selected_action_id=action_id,
|
||||
)
|
||||
session.add(form)
|
||||
session.flush()
|
||||
return form
|
||||
|
||||
|
||||
def _create_waiting_form(
|
||||
session: Session,
|
||||
scope: _TestScope,
|
||||
*,
|
||||
workflow_run_id: str,
|
||||
default_values: dict | None = None,
|
||||
) -> HumanInputForm:
|
||||
expiration_time = datetime.utcnow() + timedelta(days=1)
|
||||
form_definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
user_actions=[UserAction(id="approve", title="Approve")],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
default_values=default_values or {"name": "John"},
|
||||
node_title="Approval",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form = HumanInputForm(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id="node-id",
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content="Rendered block",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
session.add(form)
|
||||
session.flush()
|
||||
return form
|
||||
|
||||
|
||||
def _create_human_input_content(
|
||||
session: Session,
|
||||
*,
|
||||
workflow_run_id: str,
|
||||
message_id: str,
|
||||
form_id: str,
|
||||
) -> HumanInputContent:
|
||||
content = HumanInputContent.new(
|
||||
workflow_run_id=workflow_run_id,
|
||||
message_id=message_id,
|
||||
form_id=form_id,
|
||||
)
|
||||
session.add(content)
|
||||
return content
|
||||
|
||||
|
||||
def _create_recipient(
|
||||
session: Session,
|
||||
*,
|
||||
form_id: str,
|
||||
delivery_id: str,
|
||||
recipient_type: RecipientType = RecipientType.CONSOLE,
|
||||
access_token: str = "token-1",
|
||||
) -> HumanInputFormRecipient:
|
||||
payload = ConsoleRecipientPayload(account_id=None)
|
||||
recipient = HumanInputFormRecipient(
|
||||
form_id=form_id,
|
||||
delivery_id=delivery_id,
|
||||
recipient_type=recipient_type,
|
||||
recipient_payload=payload.model_dump_json(),
|
||||
access_token=access_token,
|
||||
)
|
||||
session.add(recipient)
|
||||
return recipient
|
||||
|
||||
|
||||
def _create_delivery(session: Session, *, form_id: str) -> HumanInputDelivery:
|
||||
from dify_graph.nodes.human_input.enums import DeliveryMethodType
|
||||
from models.human_input import ConsoleDeliveryPayload
|
||||
|
||||
delivery = HumanInputDelivery(
|
||||
form_id=form_id,
|
||||
delivery_method_type=DeliveryMethodType.WEBAPP,
|
||||
channel_payload=ConsoleDeliveryPayload().model_dump_json(),
|
||||
)
|
||||
session.add(delivery)
|
||||
session.flush()
|
||||
return delivery
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(db_session_with_containers: Session) -> SQLAlchemyExecutionExtraContentRepository:
|
||||
"""Build a repository backed by the testcontainers database engine."""
|
||||
engine = db_session_with_containers.get_bind()
|
||||
assert isinstance(engine, Engine)
|
||||
return SQLAlchemyExecutionExtraContentRepository(sessionmaker(bind=engine, expire_on_commit=False))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_scope(db_session_with_containers: Session) -> Generator[_TestScope]:
|
||||
"""Provide an isolated scope and clean related data after each test."""
|
||||
scope = _TestScope()
|
||||
_seed_base_entities(db_session_with_containers, scope)
|
||||
db_session_with_containers.commit()
|
||||
yield scope
|
||||
_cleanup_scope_data(db_session_with_containers, scope)
|
||||
|
||||
|
||||
class TestGetByMessageIds:
|
||||
"""Tests for SQLAlchemyExecutionExtraContentRepository.get_by_message_ids."""
|
||||
|
||||
def test_groups_contents_by_message(
|
||||
self,
|
||||
db_session_with_containers: Session,
|
||||
repository: SQLAlchemyExecutionExtraContentRepository,
|
||||
test_scope: _TestScope,
|
||||
) -> None:
|
||||
"""Submitted forms are correctly mapped and grouped by message ID."""
|
||||
workflow_run_id = str(uuid4())
|
||||
conversation = _create_conversation(db_session_with_containers, test_scope)
|
||||
msg1 = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
|
||||
msg2 = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
|
||||
|
||||
form = _create_submitted_form(
|
||||
db_session_with_containers,
|
||||
test_scope,
|
||||
workflow_run_id=workflow_run_id,
|
||||
action_id="approve",
|
||||
action_title="Approve",
|
||||
)
|
||||
_create_human_input_content(
|
||||
db_session_with_containers,
|
||||
workflow_run_id=workflow_run_id,
|
||||
message_id=msg1.id,
|
||||
form_id=form.id,
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
result = repository.get_by_message_ids([msg1.id, msg2.id])
|
||||
|
||||
assert len(result) == 2
|
||||
# msg1 has one submitted content
|
||||
assert len(result[0]) == 1
|
||||
content = result[0][0]
|
||||
assert content.submitted is True
|
||||
assert content.workflow_run_id == workflow_run_id
|
||||
assert content.form_submission_data is not None
|
||||
assert content.form_submission_data.action_id == "approve"
|
||||
assert content.form_submission_data.action_text == "Approve"
|
||||
assert content.form_submission_data.rendered_content == "Rendered Approve"
|
||||
assert content.form_submission_data.node_id == "node-id"
|
||||
assert content.form_submission_data.node_title == "Approval"
|
||||
# msg2 has no content
|
||||
assert result[1] == []
|
||||
|
||||
def test_returns_unsubmitted_form_definition(
|
||||
self,
|
||||
db_session_with_containers: Session,
|
||||
repository: SQLAlchemyExecutionExtraContentRepository,
|
||||
test_scope: _TestScope,
|
||||
) -> None:
|
||||
"""Waiting forms return full form_definition with resolved token and defaults."""
|
||||
workflow_run_id = str(uuid4())
|
||||
conversation = _create_conversation(db_session_with_containers, test_scope)
|
||||
msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
|
||||
|
||||
form = _create_waiting_form(
|
||||
db_session_with_containers,
|
||||
test_scope,
|
||||
workflow_run_id=workflow_run_id,
|
||||
default_values={"name": "John"},
|
||||
)
|
||||
delivery = _create_delivery(db_session_with_containers, form_id=form.id)
|
||||
_create_recipient(
|
||||
db_session_with_containers,
|
||||
form_id=form.id,
|
||||
delivery_id=delivery.id,
|
||||
access_token="token-1",
|
||||
)
|
||||
_create_human_input_content(
|
||||
db_session_with_containers,
|
||||
workflow_run_id=workflow_run_id,
|
||||
message_id=msg.id,
|
||||
form_id=form.id,
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
result = repository.get_by_message_ids([msg.id])
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 1
|
||||
domain_content = result[0][0]
|
||||
assert domain_content.submitted is False
|
||||
assert domain_content.workflow_run_id == workflow_run_id
|
||||
assert domain_content.form_definition is not None
|
||||
form_def = domain_content.form_definition
|
||||
assert form_def.form_id == form.id
|
||||
assert form_def.node_id == "node-id"
|
||||
assert form_def.node_title == "Approval"
|
||||
assert form_def.form_content == "Rendered block"
|
||||
assert form_def.display_in_ui is True
|
||||
assert form_def.form_token == "token-1"
|
||||
assert form_def.resolved_default_values == {"name": "John"}
|
||||
assert form_def.expiration_time == int(form.expiration_time.timestamp())
|
||||
|
||||
def test_empty_message_ids_returns_empty_list(
|
||||
self,
|
||||
repository: SQLAlchemyExecutionExtraContentRepository,
|
||||
) -> None:
|
||||
"""Passing no message IDs returns an empty list without hitting the DB."""
|
||||
result = repository.get_by_message_ids([])
|
||||
assert result == []
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Testcontainers integration tests for AttachmentService."""
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
import services.attachment_service as attachment_service_module
|
||||
from extensions.ext_database import db
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import UploadFile
|
||||
from services.attachment_service import AttachmentService
|
||||
|
||||
|
||||
class TestAttachmentService:
|
||||
def _create_upload_file(self, db_session_with_containers, *, tenant_id: str | None = None) -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id or str(uuid4()),
|
||||
storage_type=StorageType.OPENDAL,
|
||||
key=f"upload/{uuid4()}.txt",
|
||||
name="test-file.txt",
|
||||
size=100,
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid4()),
|
||||
created_at=datetime.now(UTC),
|
||||
used=False,
|
||||
)
|
||||
db_session_with_containers.add(upload_file)
|
||||
db_session_with_containers.commit()
|
||||
return upload_file
|
||||
|
||||
def test_should_initialize_with_sessionmaker(self):
|
||||
session_factory = sessionmaker()
|
||||
|
||||
service = AttachmentService(session_factory=session_factory)
|
||||
|
||||
assert service._session_maker is session_factory
|
||||
|
||||
def test_should_initialize_with_engine(self):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
service = AttachmentService(session_factory=engine)
|
||||
session = service._session_maker()
|
||||
try:
|
||||
assert session.bind == engine
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
@pytest.mark.parametrize("invalid_session_factory", [None, "not-a-session-factory", 1])
|
||||
def test_should_raise_assertion_error_for_invalid_session_factory(self, invalid_session_factory):
|
||||
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
|
||||
AttachmentService(session_factory=invalid_session_factory)
|
||||
|
||||
def test_should_return_base64_when_file_exists(self, db_session_with_containers):
|
||||
upload_file = self._create_upload_file(db_session_with_containers)
|
||||
service = AttachmentService(session_factory=sessionmaker(bind=db.engine))
|
||||
|
||||
with patch.object(attachment_service_module.storage, "load_once", return_value=b"binary-content") as mock_load:
|
||||
result = service.get_file_base64(upload_file.id)
|
||||
|
||||
assert result == base64.b64encode(b"binary-content").decode()
|
||||
mock_load.assert_called_once_with(upload_file.key)
|
||||
|
||||
def test_should_raise_not_found_when_file_missing(self, db_session_with_containers):
|
||||
service = AttachmentService(session_factory=sessionmaker(bind=db.engine))
|
||||
|
||||
with patch.object(attachment_service_module.storage, "load_once") as mock_load:
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
service.get_file_base64(str(uuid4()))
|
||||
|
||||
mock_load.assert_not_called()
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Testcontainers integration tests for ConversationVariableUpdater."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from dify_graph.variables import StringVariable
|
||||
from extensions.ext_database import db
|
||||
from models.workflow import ConversationVariable
|
||||
from services.conversation_variable_updater import ConversationVariableNotFoundError, ConversationVariableUpdater
|
||||
|
||||
|
||||
class TestConversationVariableUpdater:
|
||||
def _create_conversation_variable(
|
||||
self, db_session_with_containers, *, conversation_id: str, variable: StringVariable, app_id: str | None = None
|
||||
) -> ConversationVariable:
|
||||
row = ConversationVariable(
|
||||
id=variable.id,
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id or str(uuid4()),
|
||||
data=variable.model_dump_json(),
|
||||
)
|
||||
db_session_with_containers.add(row)
|
||||
db_session_with_containers.commit()
|
||||
return row
|
||||
|
||||
def test_should_update_conversation_variable_data_and_commit(self, db_session_with_containers):
|
||||
conversation_id = str(uuid4())
|
||||
variable = StringVariable(id=str(uuid4()), name="topic", value="old value")
|
||||
self._create_conversation_variable(
|
||||
db_session_with_containers, conversation_id=conversation_id, variable=variable
|
||||
)
|
||||
|
||||
updated_variable = StringVariable(id=variable.id, name="topic", value="new value")
|
||||
updater = ConversationVariableUpdater(sessionmaker(bind=db.engine))
|
||||
|
||||
updater.update(conversation_id=conversation_id, variable=updated_variable)
|
||||
|
||||
db_session_with_containers.expire_all()
|
||||
row = db_session_with_containers.get(ConversationVariable, (variable.id, conversation_id))
|
||||
assert row is not None
|
||||
assert row.data == updated_variable.model_dump_json()
|
||||
|
||||
def test_should_raise_not_found_when_variable_missing(self, db_session_with_containers):
|
||||
conversation_id = str(uuid4())
|
||||
variable = StringVariable(id=str(uuid4()), name="topic", value="value")
|
||||
updater = ConversationVariableUpdater(sessionmaker(bind=db.engine))
|
||||
|
||||
with pytest.raises(ConversationVariableNotFoundError, match="conversation variable not found in the database"):
|
||||
updater.update(conversation_id=conversation_id, variable=variable)
|
||||
|
||||
def test_should_do_nothing_when_flush_is_called(self, db_session_with_containers):
|
||||
updater = ConversationVariableUpdater(sessionmaker(bind=db.engine))
|
||||
|
||||
result = updater.flush()
|
||||
|
||||
assert result is None
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Testcontainers integration tests for CreditPoolService."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from core.errors.error import QuotaExceededError
|
||||
from models import TenantCreditPool
|
||||
from services.credit_pool_service import CreditPoolService
|
||||
|
||||
|
||||
class TestCreditPoolService:
|
||||
def _create_tenant_id(self) -> str:
|
||||
return str(uuid4())
|
||||
|
||||
def test_create_default_pool(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
|
||||
assert isinstance(pool, TenantCreditPool)
|
||||
assert pool.tenant_id == tenant_id
|
||||
assert pool.pool_type == "trial"
|
||||
assert pool.quota_used == 0
|
||||
assert pool.quota_limit > 0
|
||||
|
||||
def test_get_pool_returns_pool_when_exists(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
CreditPoolService.create_default_pool(tenant_id)
|
||||
|
||||
result = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="trial")
|
||||
|
||||
assert result is not None
|
||||
assert result.tenant_id == tenant_id
|
||||
assert result.pool_type == "trial"
|
||||
|
||||
def test_get_pool_returns_none_when_not_exists(self, db_session_with_containers):
|
||||
result = CreditPoolService.get_pool(tenant_id=self._create_tenant_id(), pool_type="trial")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_check_credits_available_returns_false_when_no_pool(self, db_session_with_containers):
|
||||
result = CreditPoolService.check_credits_available(tenant_id=self._create_tenant_id(), credits_required=10)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_check_credits_available_returns_true_when_sufficient(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
CreditPoolService.create_default_pool(tenant_id)
|
||||
|
||||
result = CreditPoolService.check_credits_available(tenant_id=tenant_id, credits_required=10)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_check_credits_available_returns_false_when_insufficient(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
# Exhaust credits
|
||||
pool.quota_used = pool.quota_limit
|
||||
db_session_with_containers.commit()
|
||||
|
||||
result = CreditPoolService.check_credits_available(tenant_id=tenant_id, credits_required=1)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_check_and_deduct_credits_raises_when_no_pool(self, db_session_with_containers):
|
||||
with pytest.raises(QuotaExceededError, match="Credit pool not found"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id=self._create_tenant_id(), credits_required=10)
|
||||
|
||||
def test_check_and_deduct_credits_raises_when_no_remaining(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
pool.quota_used = pool.quota_limit
|
||||
db_session_with_containers.commit()
|
||||
|
||||
with pytest.raises(QuotaExceededError, match="No credits remaining"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=10)
|
||||
|
||||
def test_check_and_deduct_credits_deducts_required_amount(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
CreditPoolService.create_default_pool(tenant_id)
|
||||
credits_required = 10
|
||||
|
||||
result = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=credits_required)
|
||||
|
||||
assert result == credits_required
|
||||
db_session_with_containers.expire_all()
|
||||
pool = CreditPoolService.get_pool(tenant_id=tenant_id)
|
||||
assert pool.quota_used == credits_required
|
||||
|
||||
def test_check_and_deduct_credits_caps_at_remaining(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
remaining = 5
|
||||
pool.quota_used = pool.quota_limit - remaining
|
||||
db_session_with_containers.commit()
|
||||
|
||||
result = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=200)
|
||||
|
||||
assert result == remaining
|
||||
db_session_with_containers.expire_all()
|
||||
updated_pool = CreditPoolService.get_pool(tenant_id=tenant_id)
|
||||
assert updated_pool.quota_used == pool.quota_limit
|
||||
@@ -397,68 +397,6 @@ class TestDatasetPermissionServiceClearPartialMemberList:
|
||||
class TestDatasetServiceCheckDatasetPermission:
|
||||
"""Verify dataset access checks against persisted partial-member permissions."""
|
||||
|
||||
def test_check_dataset_permission_different_tenant_should_fail(self, db_session_with_containers):
|
||||
"""Test that users from different tenants cannot access dataset."""
|
||||
owner, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.OWNER)
|
||||
other_user, _ = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.EDITOR)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, owner.id, permission=DatasetPermissionEnum.ALL_TEAM
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
DatasetService.check_dataset_permission(dataset, other_user)
|
||||
|
||||
def test_check_dataset_permission_owner_can_access_any_dataset(self, db_session_with_containers):
|
||||
"""Test that tenant owners can access any dataset regardless of permission level."""
|
||||
owner, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.OWNER)
|
||||
creator, _ = DatasetPermissionTestDataFactory.create_account_with_tenant(
|
||||
role=TenantAccountRole.NORMAL, tenant=tenant
|
||||
)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, owner)
|
||||
|
||||
def test_check_dataset_permission_only_me_creator_can_access(self, db_session_with_containers):
|
||||
"""Test ONLY_ME permission allows only the dataset creator to access."""
|
||||
creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.EDITOR)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
|
||||
def test_check_dataset_permission_only_me_others_cannot_access(self, db_session_with_containers):
|
||||
"""Test ONLY_ME permission denies access to non-creators."""
|
||||
creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.NORMAL)
|
||||
other, _ = DatasetPermissionTestDataFactory.create_account_with_tenant(
|
||||
role=TenantAccountRole.NORMAL, tenant=tenant
|
||||
)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
DatasetService.check_dataset_permission(dataset, other)
|
||||
|
||||
def test_check_dataset_permission_all_team_allows_access(self, db_session_with_containers):
|
||||
"""Test ALL_TEAM permission allows any team member to access the dataset."""
|
||||
creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.NORMAL)
|
||||
member, _ = DatasetPermissionTestDataFactory.create_account_with_tenant(
|
||||
role=TenantAccountRole.NORMAL, tenant=tenant
|
||||
)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ALL_TEAM
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
|
||||
def test_check_dataset_permission_partial_members_with_permission_success(self, db_session_with_containers):
|
||||
"""
|
||||
Test that user with explicit permission can access partial_members dataset.
|
||||
@@ -505,16 +443,6 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset"):
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
def test_check_dataset_permission_partial_team_creator_can_access(self, db_session_with_containers):
|
||||
"""Test PARTIAL_TEAM permission allows creator to access without explicit permission."""
|
||||
creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.EDITOR)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.PARTIAL_TEAM
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
|
||||
|
||||
class TestDatasetServiceCheckDatasetOperatorPermission:
|
||||
"""Verify operator permission checks against persisted partial-member permissions."""
|
||||
|
||||
@@ -9,7 +9,7 @@ from werkzeug.exceptions import NotFound
|
||||
|
||||
from models import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.dataset import Dataset
|
||||
from models.enums import DataSourceType, TagType
|
||||
from models.enums import DataSourceType
|
||||
from models.model import App, Tag, TagBinding
|
||||
from services.tag_service import TagService
|
||||
|
||||
@@ -547,7 +547,7 @@ class TestTagService:
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "python_tag"
|
||||
assert result[0].type == TagType.APP
|
||||
assert result[0].type == "app"
|
||||
assert result[0].tenant_id == tenant.id
|
||||
|
||||
def test_get_tag_by_tag_name_no_matches(
|
||||
@@ -638,7 +638,7 @@ class TestTagService:
|
||||
|
||||
# Verify all tags are returned
|
||||
for tag in result:
|
||||
assert tag.type == TagType.APP
|
||||
assert tag.type == "app"
|
||||
assert tag.tenant_id == tenant.id
|
||||
assert tag.id in [t.id for t in tags]
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Testcontainers integration tests for WorkflowService.delete_workflow."""
|
||||
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account, Tenant, TenantAccountJoin
|
||||
from models.model import App
|
||||
from models.tools import WorkflowToolProvider
|
||||
from models.workflow import Workflow
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
|
||||
|
||||
class TestWorkflowDeletion:
|
||||
def _create_tenant_and_account(self, session: Session) -> tuple[Tenant, Account]:
|
||||
tenant = Tenant(name=f"Tenant {uuid4()}")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
|
||||
account = Account(
|
||||
name=f"Account {uuid4()}",
|
||||
email=f"wf_del_{uuid4()}@example.com",
|
||||
password="hashed",
|
||||
password_salt="salt",
|
||||
interface_language="en-US",
|
||||
timezone="UTC",
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
role="owner",
|
||||
current=True,
|
||||
)
|
||||
session.add(join)
|
||||
session.flush()
|
||||
return tenant, account
|
||||
|
||||
def _create_app(self, session: Session, *, tenant: Tenant, account: Account, workflow_id: str | None = None) -> App:
|
||||
app = App(
|
||||
tenant_id=tenant.id,
|
||||
name=f"App {uuid4()}",
|
||||
description="",
|
||||
mode="workflow",
|
||||
icon_type="emoji",
|
||||
icon="bot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
api_rpm=100,
|
||||
api_rph=100,
|
||||
is_demo=False,
|
||||
is_public=False,
|
||||
is_universal=False,
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
session.add(app)
|
||||
session.flush()
|
||||
return app
|
||||
|
||||
def _create_workflow(
|
||||
self, session: Session, *, tenant: Tenant, app: App, account: Account, version: str = "1.0"
|
||||
) -> Workflow:
|
||||
workflow = Workflow(
|
||||
id=str(uuid4()),
|
||||
tenant_id=tenant.id,
|
||||
app_id=app.id,
|
||||
type="workflow",
|
||||
version=version,
|
||||
graph=json.dumps({"nodes": [], "edges": []}),
|
||||
_features=json.dumps({}),
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
)
|
||||
session.add(workflow)
|
||||
session.flush()
|
||||
return workflow
|
||||
|
||||
def _create_tool_provider(
|
||||
self, session: Session, *, tenant: Tenant, app: App, account: Account, version: str
|
||||
) -> WorkflowToolProvider:
|
||||
provider = WorkflowToolProvider(
|
||||
name=f"tool-{uuid4()}",
|
||||
label=f"Tool {uuid4()}",
|
||||
icon="wrench",
|
||||
app_id=app.id,
|
||||
version=version,
|
||||
user_id=account.id,
|
||||
tenant_id=tenant.id,
|
||||
description="test tool provider",
|
||||
)
|
||||
session.add(provider)
|
||||
session.flush()
|
||||
return provider
|
||||
|
||||
def test_delete_workflow_success(self, db_session_with_containers):
|
||||
tenant, account = self._create_tenant_and_account(db_session_with_containers)
|
||||
app = self._create_app(db_session_with_containers, tenant=tenant, account=account)
|
||||
workflow = self._create_workflow(
|
||||
db_session_with_containers, tenant=tenant, app=app, account=account, version="1.0"
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
workflow_id = workflow.id
|
||||
|
||||
service = WorkflowService(sessionmaker(bind=db.engine))
|
||||
result = service.delete_workflow(
|
||||
session=db_session_with_containers, workflow_id=workflow_id, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
assert result is True
|
||||
db_session_with_containers.expire_all()
|
||||
assert db_session_with_containers.get(Workflow, workflow_id) is None
|
||||
|
||||
def test_delete_draft_workflow_raises_error(self, db_session_with_containers):
|
||||
tenant, account = self._create_tenant_and_account(db_session_with_containers)
|
||||
app = self._create_app(db_session_with_containers, tenant=tenant, account=account)
|
||||
workflow = self._create_workflow(
|
||||
db_session_with_containers, tenant=tenant, app=app, account=account, version="draft"
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
service = WorkflowService(sessionmaker(bind=db.engine))
|
||||
with pytest.raises(DraftWorkflowDeletionError):
|
||||
service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id)
|
||||
|
||||
def test_delete_workflow_in_use_by_app_raises_error(self, db_session_with_containers):
|
||||
tenant, account = self._create_tenant_and_account(db_session_with_containers)
|
||||
app = self._create_app(db_session_with_containers, tenant=tenant, account=account)
|
||||
workflow = self._create_workflow(
|
||||
db_session_with_containers, tenant=tenant, app=app, account=account, version="1.0"
|
||||
)
|
||||
# Point app to this workflow
|
||||
app.workflow_id = workflow.id
|
||||
db_session_with_containers.commit()
|
||||
|
||||
service = WorkflowService(sessionmaker(bind=db.engine))
|
||||
with pytest.raises(WorkflowInUseError, match="currently in use by app"):
|
||||
service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id)
|
||||
|
||||
def test_delete_workflow_published_as_tool_raises_error(self, db_session_with_containers):
|
||||
tenant, account = self._create_tenant_and_account(db_session_with_containers)
|
||||
app = self._create_app(db_session_with_containers, tenant=tenant, account=account)
|
||||
workflow = self._create_workflow(
|
||||
db_session_with_containers, tenant=tenant, app=app, account=account, version="1.0"
|
||||
)
|
||||
self._create_tool_provider(db_session_with_containers, tenant=tenant, app=app, account=account, version="1.0")
|
||||
db_session_with_containers.commit()
|
||||
|
||||
service = WorkflowService(sessionmaker(bind=db.engine))
|
||||
with pytest.raises(WorkflowInUseError, match="published as a tool"):
|
||||
service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id)
|
||||
@@ -170,7 +170,7 @@ class TestMessageEndpoints:
|
||||
mock_app_model,
|
||||
qs={"conversation_id": "123e4567-e89b-12d3-a456-426614174000"},
|
||||
) as (api, mock_db, v_args):
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_db.data_query.where.return_value.first.return_value = None
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
api.get(**v_args)
|
||||
@@ -198,11 +198,11 @@ class TestMessageEndpoints:
|
||||
mock_msg.message = {}
|
||||
mock_msg.message_metadata_dict = {}
|
||||
|
||||
# scalar() is called twice: first for conversation lookup, second for has_more check
|
||||
mock_db.session.scalar.side_effect = [mock_conv, False]
|
||||
scalars_result = MagicMock()
|
||||
scalars_result.all.return_value = [mock_msg]
|
||||
mock_db.session.scalars.return_value = scalars_result
|
||||
# mock returns
|
||||
q_mock = mock_db.data_query
|
||||
q_mock.where.return_value.first.side_effect = [mock_conv]
|
||||
q_mock.where.return_value.order_by.return_value.limit.return_value.all.return_value = [mock_msg]
|
||||
mock_db.session.scalar.return_value = False
|
||||
|
||||
resp = api.get(**v_args)
|
||||
assert resp["limit"] == 1
|
||||
@@ -219,7 +219,7 @@ class TestMessageEndpoints:
|
||||
mock_app_model,
|
||||
payload={"message_id": "123e4567-e89b-12d3-a456-426614174000"},
|
||||
) as (api, mock_db, v_args):
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_db.data_query.where.return_value.first.return_value = None
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
api.post(**v_args)
|
||||
@@ -231,7 +231,7 @@ class TestMessageEndpoints:
|
||||
) as (api, mock_db, v_args):
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.admin_feedback = None
|
||||
mock_db.session.scalar.return_value = mock_msg
|
||||
mock_db.data_query.where.return_value.first.return_value = mock_msg
|
||||
|
||||
resp = api.post(**v_args)
|
||||
assert resp == {"result": "success"}
|
||||
@@ -240,7 +240,7 @@ class TestMessageEndpoints:
|
||||
with setup_test_context(
|
||||
app, MessageAnnotationCountApi, "/apps/app_123/annotations/count", "GET", mock_account, mock_app_model
|
||||
) as (api, mock_db, v_args):
|
||||
mock_db.session.scalar.return_value = 5
|
||||
mock_db.data_query.where.return_value.count.return_value = 5
|
||||
|
||||
resp = api.get(**v_args)
|
||||
assert resp == {"count": 5}
|
||||
@@ -314,7 +314,7 @@ class TestMessageEndpoints:
|
||||
mock_msg.message = {}
|
||||
mock_msg.message_metadata_dict = {}
|
||||
|
||||
mock_db.session.scalar.return_value = mock_msg
|
||||
mock_db.data_query.where.return_value.first.return_value = mock_msg
|
||||
|
||||
resp = api.get(**v_args)
|
||||
assert resp["id"] == "msg_123"
|
||||
|
||||
@@ -11,7 +11,6 @@ from controllers.console.tag.tags import (
|
||||
TagListApi,
|
||||
TagUpdateDeleteApi,
|
||||
)
|
||||
from models.enums import TagType
|
||||
|
||||
|
||||
def unwrap(func):
|
||||
@@ -53,7 +52,7 @@ def tag():
|
||||
tag = MagicMock()
|
||||
tag.id = "tag-1"
|
||||
tag.name = "test-tag"
|
||||
tag.type = TagType.KNOWLEDGE
|
||||
tag.type = "knowledge"
|
||||
return tag
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ from controllers.service_api.dataset.dataset import (
|
||||
from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError
|
||||
from models.account import Account
|
||||
from models.dataset import DatasetPermissionEnum
|
||||
from models.enums import TagType
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
|
||||
from services.tag_service import TagService
|
||||
|
||||
@@ -278,7 +277,7 @@ class TestDatasetTagsApi:
|
||||
mock_tag = Mock()
|
||||
mock_tag.id = "tag_1"
|
||||
mock_tag.name = "Test Tag"
|
||||
mock_tag.type = TagType.KNOWLEDGE
|
||||
mock_tag.type = "knowledge"
|
||||
mock_tag.binding_count = "0" # Required for Pydantic validation - must be string
|
||||
mock_tag_service.get_tags.return_value = [mock_tag]
|
||||
|
||||
@@ -317,7 +316,7 @@ class TestDatasetTagsApi:
|
||||
mock_tag = Mock()
|
||||
mock_tag.id = "new_tag_1"
|
||||
mock_tag.name = "New Tag"
|
||||
mock_tag.type = TagType.KNOWLEDGE
|
||||
mock_tag.type = "knowledge"
|
||||
mock_tag_service.save_tags.return_value = mock_tag
|
||||
mock_service_api_ns.payload = {"name": "New Tag"}
|
||||
|
||||
@@ -379,7 +378,7 @@ class TestDatasetTagsApi:
|
||||
mock_tag = Mock()
|
||||
mock_tag.id = "tag_1"
|
||||
mock_tag.name = "Updated Tag"
|
||||
mock_tag.type = TagType.KNOWLEDGE
|
||||
mock_tag.type = "knowledge"
|
||||
mock_tag.binding_count = "5"
|
||||
mock_tag_service.update_tags.return_value = mock_tag
|
||||
mock_tag_service.get_tag_binding_count.return_value = 5
|
||||
@@ -867,7 +866,7 @@ class TestTagService:
|
||||
mock_tag = Mock()
|
||||
mock_tag.id = str(uuid.uuid4())
|
||||
mock_tag.name = "New Tag"
|
||||
mock_tag.type = TagType.KNOWLEDGE
|
||||
mock_tag.type = "knowledge"
|
||||
mock_save.return_value = mock_tag
|
||||
|
||||
result = TagService.save_tags({"name": "New Tag", "type": "knowledge"})
|
||||
|
||||
@@ -3,7 +3,11 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
from core.model_manager import ModelInstance
|
||||
from dify_graph.model_runtime.entities import ImagePromptMessageContent, PromptMessageRole, TextPromptMessageContent
|
||||
from dify_graph.model_runtime.entities import (
|
||||
ImagePromptMessageContent,
|
||||
PromptMessageRole,
|
||||
TextPromptMessageContent,
|
||||
)
|
||||
from dify_graph.model_runtime.entities.message_entities import SystemPromptMessage
|
||||
from dify_graph.nodes.llm import llm_utils
|
||||
from dify_graph.nodes.llm.entities import LLMNodeChatModelMessage
|
||||
@@ -11,6 +15,15 @@ from dify_graph.nodes.llm.exc import NoPromptFoundError
|
||||
from dify_graph.runtime import VariablePool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def variable_pool() -> VariablePool:
|
||||
pool = VariablePool.empty()
|
||||
pool.add(["node1", "output"], "resolved_value")
|
||||
pool.add(["node2", "text"], "hello world")
|
||||
pool.add(["start", "user_input"], "dynamic_param")
|
||||
return pool
|
||||
|
||||
|
||||
def _fetch_prompt_messages_with_mocked_content(content):
|
||||
variable_pool = VariablePool.empty()
|
||||
model_instance = mock.MagicMock(spec=ModelInstance)
|
||||
@@ -53,6 +66,159 @@ def _fetch_prompt_messages_with_mocked_content(content):
|
||||
)
|
||||
|
||||
|
||||
class TestTypeCoercionViaResolve:
|
||||
"""Type coercion is tested through the public resolve_completion_params_variables API."""
|
||||
|
||||
def test_numeric_string_coerced_to_float(self):
|
||||
pool = VariablePool.empty()
|
||||
pool.add(["n", "v"], "0.7")
|
||||
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
|
||||
assert result["p"] == 0.7
|
||||
|
||||
def test_integer_string_coerced_to_int(self):
|
||||
pool = VariablePool.empty()
|
||||
pool.add(["n", "v"], "1024")
|
||||
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
|
||||
assert result["p"] == 1024
|
||||
|
||||
def test_boolean_string_coerced_to_bool(self):
|
||||
pool = VariablePool.empty()
|
||||
pool.add(["n", "v"], "true")
|
||||
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
|
||||
assert result["p"] is True
|
||||
|
||||
def test_plain_string_stays_string(self):
|
||||
pool = VariablePool.empty()
|
||||
pool.add(["n", "v"], "json_object")
|
||||
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
|
||||
assert result["p"] == "json_object"
|
||||
|
||||
def test_json_object_string_stays_string(self):
|
||||
pool = VariablePool.empty()
|
||||
pool.add(["n", "v"], '{"key": "val"}')
|
||||
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
|
||||
assert result["p"] == '{"key": "val"}'
|
||||
|
||||
def test_mixed_text_and_variable_stays_string(self):
|
||||
pool = VariablePool.empty()
|
||||
pool.add(["n", "v"], "0.7")
|
||||
result = llm_utils.resolve_completion_params_variables({"p": "val={{#n.v#}}"}, pool)
|
||||
assert result["p"] == "val=0.7"
|
||||
|
||||
|
||||
class TestResolveCompletionParamsVariables:
|
||||
def test_plain_string_values_unchanged(self, variable_pool: VariablePool):
|
||||
params = {"response_format": "json", "custom_param": "static_value"}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
|
||||
|
||||
assert result == {"response_format": "json", "custom_param": "static_value"}
|
||||
|
||||
def test_numeric_values_unchanged(self, variable_pool: VariablePool):
|
||||
params = {"temperature": 0.7, "top_p": 0.9, "max_tokens": 1024}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
|
||||
|
||||
assert result == {"temperature": 0.7, "top_p": 0.9, "max_tokens": 1024}
|
||||
|
||||
def test_boolean_values_unchanged(self, variable_pool: VariablePool):
|
||||
params = {"stream": True, "echo": False}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
|
||||
|
||||
assert result == {"stream": True, "echo": False}
|
||||
|
||||
def test_list_values_unchanged(self, variable_pool: VariablePool):
|
||||
params = {"stop": ["Human:", "Assistant:"]}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
|
||||
|
||||
assert result == {"stop": ["Human:", "Assistant:"]}
|
||||
|
||||
def test_single_variable_reference_resolved(self, variable_pool: VariablePool):
|
||||
params = {"response_format": "{{#node1.output#}}"}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
|
||||
|
||||
assert result == {"response_format": "resolved_value"}
|
||||
|
||||
def test_multiple_variable_references_resolved(self, variable_pool: VariablePool):
|
||||
params = {
|
||||
"param_a": "{{#node1.output#}}",
|
||||
"param_b": "{{#node2.text#}}",
|
||||
}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
|
||||
|
||||
assert result == {"param_a": "resolved_value", "param_b": "hello world"}
|
||||
|
||||
def test_mixed_text_and_variable_resolved(self, variable_pool: VariablePool):
|
||||
params = {"prompt_prefix": "prefix_{{#node1.output#}}_suffix"}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
|
||||
|
||||
assert result == {"prompt_prefix": "prefix_resolved_value_suffix"}
|
||||
|
||||
def test_mixed_params_types(self, variable_pool: VariablePool):
|
||||
"""Non-string params pass through; string params with variables get resolved."""
|
||||
params = {
|
||||
"temperature": 0.7,
|
||||
"response_format": "{{#node1.output#}}",
|
||||
"custom_string": "no_vars_here",
|
||||
"max_tokens": 512,
|
||||
"stop": ["\n"],
|
||||
}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
|
||||
|
||||
assert result == {
|
||||
"temperature": 0.7,
|
||||
"response_format": "resolved_value",
|
||||
"custom_string": "no_vars_here",
|
||||
"max_tokens": 512,
|
||||
"stop": ["\n"],
|
||||
}
|
||||
|
||||
def test_empty_params(self, variable_pool: VariablePool):
|
||||
result = llm_utils.resolve_completion_params_variables({}, variable_pool)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_unresolvable_variable_keeps_selector_text(self):
|
||||
"""When a referenced variable doesn't exist in the pool, convert_template
|
||||
falls back to the raw selector path (e.g. 'nonexistent.var')."""
|
||||
pool = VariablePool.empty()
|
||||
params = {"format": "{{#nonexistent.var#}}"}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, pool)
|
||||
|
||||
assert result["format"] == "nonexistent.var"
|
||||
|
||||
def test_multiple_variables_in_single_value(self, variable_pool: VariablePool):
|
||||
params = {"combined": "{{#node1.output#}} and {{#node2.text#}}"}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
|
||||
|
||||
assert result == {"combined": "resolved_value and hello world"}
|
||||
|
||||
def test_original_params_not_mutated(self, variable_pool: VariablePool):
|
||||
original = {"response_format": "{{#node1.output#}}", "temperature": 0.5}
|
||||
original_copy = dict(original)
|
||||
|
||||
_ = llm_utils.resolve_completion_params_variables(original, variable_pool)
|
||||
|
||||
assert original == original_copy
|
||||
|
||||
def test_long_value_truncated(self):
|
||||
pool = VariablePool.empty()
|
||||
pool.add(["node1", "big"], "x" * 2000)
|
||||
params = {"param": "{{#node1.big#}}"}
|
||||
|
||||
result = llm_utils.resolve_completion_params_variables(params, pool)
|
||||
|
||||
assert len(result["param"]) == llm_utils.MAX_RESOLVED_VALUE_LENGTH
|
||||
|
||||
|
||||
def test_fetch_prompt_messages_skips_messages_when_all_contents_are_filtered_out():
|
||||
with pytest.raises(NoPromptFoundError):
|
||||
_fetch_prompt_messages_with_mocked_content(
|
||||
|
||||
@@ -95,11 +95,13 @@ class TestGitHubOAuth(BaseOAuthTest):
|
||||
],
|
||||
"primary@example.com",
|
||||
),
|
||||
# User with private email (null email and name from API)
|
||||
# User with no emails - fallback to noreply
|
||||
({"id": 12345, "login": "testuser", "name": "Test User"}, [], "12345+testuser@users.noreply.github.com"),
|
||||
# User with only secondary email - fallback to noreply
|
||||
(
|
||||
{"id": 12345, "login": "testuser", "name": None, "email": None},
|
||||
[{"email": "primary@example.com", "primary": True}],
|
||||
"primary@example.com",
|
||||
{"id": 12345, "login": "testuser", "name": "Test User"},
|
||||
[{"email": "secondary@example.com", "primary": False}],
|
||||
"12345+testuser@users.noreply.github.com",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -116,54 +118,9 @@ class TestGitHubOAuth(BaseOAuthTest):
|
||||
user_info = oauth.get_user_info("test_token")
|
||||
|
||||
assert user_info.id == str(user_data["id"])
|
||||
assert user_info.name == (user_data["name"] or "")
|
||||
assert user_info.name == user_data["name"]
|
||||
assert user_info.email == expected_email
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_data", "email_data"),
|
||||
[
|
||||
# User with no emails
|
||||
({"id": 12345, "login": "testuser", "name": "Test User"}, []),
|
||||
# User with only secondary email
|
||||
(
|
||||
{"id": 12345, "login": "testuser", "name": "Test User"},
|
||||
[{"email": "secondary@example.com", "primary": False}],
|
||||
),
|
||||
# User with private email and no primary in emails endpoint
|
||||
(
|
||||
{"id": 12345, "login": "testuser", "name": None, "email": None},
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
@patch("httpx.get", autospec=True)
|
||||
def test_should_raise_error_when_no_primary_email(self, mock_get, oauth, user_data, email_data):
|
||||
user_response = MagicMock()
|
||||
user_response.json.return_value = user_data
|
||||
|
||||
email_response = MagicMock()
|
||||
email_response.json.return_value = email_data
|
||||
|
||||
mock_get.side_effect = [user_response, email_response]
|
||||
|
||||
with pytest.raises(ValueError, match="Keep my email addresses private"):
|
||||
oauth.get_user_info("test_token")
|
||||
|
||||
@patch("httpx.get", autospec=True)
|
||||
def test_should_raise_error_when_email_endpoint_fails(self, mock_get, oauth):
|
||||
user_response = MagicMock()
|
||||
user_response.json.return_value = {"id": 12345, "login": "testuser", "name": "Test User"}
|
||||
|
||||
email_response = MagicMock()
|
||||
email_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Forbidden", request=MagicMock(), response=MagicMock()
|
||||
)
|
||||
|
||||
mock_get.side_effect = [user_response, email_response]
|
||||
|
||||
with pytest.raises(ValueError, match="Keep my email addresses private"):
|
||||
oauth.get_user_info("test_token")
|
||||
|
||||
@patch("httpx.get", autospec=True)
|
||||
def test_should_handle_network_errors(self, mock_get, oauth):
|
||||
mock_get.side_effect = httpx.RequestError("Network error")
|
||||
|
||||
@@ -12,7 +12,7 @@ This test suite covers:
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolProviderType
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType
|
||||
from models.tools import (
|
||||
ApiToolProvider,
|
||||
BuiltinToolProvider,
|
||||
@@ -631,7 +631,7 @@ class TestToolLabelBinding:
|
||||
"""Test creating a tool label binding."""
|
||||
# Arrange
|
||||
tool_id = "google.search"
|
||||
tool_type = ToolProviderType.BUILT_IN
|
||||
tool_type = "builtin"
|
||||
label_name = "search"
|
||||
|
||||
# Act
|
||||
@@ -655,7 +655,7 @@ class TestToolLabelBinding:
|
||||
# Act
|
||||
label_binding = ToolLabelBinding(
|
||||
tool_id=tool_id,
|
||||
tool_type=ToolProviderType.BUILT_IN,
|
||||
tool_type="builtin",
|
||||
label_name=label_name,
|
||||
)
|
||||
|
||||
@@ -667,7 +667,7 @@ class TestToolLabelBinding:
|
||||
"""Test multiple labels can be bound to the same tool."""
|
||||
# Arrange
|
||||
tool_id = "google.search"
|
||||
tool_type = ToolProviderType.BUILT_IN
|
||||
tool_type = "builtin"
|
||||
|
||||
# Act
|
||||
binding1 = ToolLabelBinding(
|
||||
@@ -688,7 +688,7 @@ class TestToolLabelBinding:
|
||||
def test_tool_label_binding_different_tool_types(self):
|
||||
"""Test label bindings for different tool types."""
|
||||
# Arrange
|
||||
tool_types = [ToolProviderType.BUILT_IN, ToolProviderType.API, ToolProviderType.WORKFLOW]
|
||||
tool_types = ["builtin", "api", "workflow"]
|
||||
|
||||
# Act & Assert
|
||||
for tool_type in tool_types:
|
||||
@@ -951,12 +951,12 @@ class TestToolProviderRelationships:
|
||||
# Act
|
||||
binding1 = ToolLabelBinding(
|
||||
tool_id=tool_id,
|
||||
tool_type=ToolProviderType.BUILT_IN,
|
||||
tool_type="builtin",
|
||||
label_name="search",
|
||||
)
|
||||
binding2 = ToolLabelBinding(
|
||||
tool_id=tool_id,
|
||||
tool_type=ToolProviderType.BUILT_IN,
|
||||
tool_type="builtin",
|
||||
label_name="web",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from core.entities.execution_extra_content import HumanInputContent as HumanInputContentDomain
|
||||
from core.entities.execution_extra_content import HumanInputFormSubmissionData
|
||||
from dify_graph.nodes.human_input.entities import (
|
||||
FormDefinition,
|
||||
UserAction,
|
||||
)
|
||||
from dify_graph.nodes.human_input.enums import HumanInputFormStatus
|
||||
from models.execution_extra_content import HumanInputContent as HumanInputContentModel
|
||||
from models.human_input import ConsoleRecipientPayload, HumanInputForm, HumanInputFormRecipient, RecipientType
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
def __init__(self, values: Sequence[HumanInputContentModel]):
|
||||
self._values = list(values)
|
||||
|
||||
def all(self) -> list[HumanInputContentModel]:
|
||||
return list(self._values)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, values: Sequence[Sequence[object]]):
|
||||
self._values = list(values)
|
||||
|
||||
def scalars(self, _stmt):
|
||||
if not self._values:
|
||||
return _FakeScalarResult([])
|
||||
return _FakeScalarResult(self._values.pop(0))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeSessionMaker:
|
||||
session: _FakeSession
|
||||
|
||||
def __call__(self) -> _FakeSession:
|
||||
return self.session
|
||||
|
||||
|
||||
def _build_form(action_id: str, action_title: str, rendered_content: str) -> HumanInputForm:
|
||||
expiration_time = datetime.now(UTC) + timedelta(days=1)
|
||||
definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
user_actions=[UserAction(id=action_id, title=action_title)],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
node_title="Approval",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form = HumanInputForm(
|
||||
id=f"form-{action_id}",
|
||||
tenant_id="tenant-id",
|
||||
app_id="app-id",
|
||||
workflow_run_id="workflow-run",
|
||||
node_id="node-id",
|
||||
form_definition=definition.model_dump_json(),
|
||||
rendered_content=rendered_content,
|
||||
status=HumanInputFormStatus.SUBMITTED,
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
form.selected_action_id = action_id
|
||||
return form
|
||||
|
||||
|
||||
def _build_content(message_id: str, action_id: str, action_title: str) -> HumanInputContentModel:
|
||||
form = _build_form(
|
||||
action_id=action_id,
|
||||
action_title=action_title,
|
||||
rendered_content=f"Rendered {action_title}",
|
||||
)
|
||||
content = HumanInputContentModel(
|
||||
id=f"content-{message_id}",
|
||||
form_id=form.id,
|
||||
message_id=message_id,
|
||||
workflow_run_id=form.workflow_run_id,
|
||||
)
|
||||
content.form = form
|
||||
return content
|
||||
|
||||
|
||||
def test_get_by_message_ids_groups_contents_by_message() -> None:
|
||||
message_ids = ["msg-1", "msg-2"]
|
||||
contents = [_build_content("msg-1", "approve", "Approve")]
|
||||
repository = SQLAlchemyExecutionExtraContentRepository(
|
||||
session_maker=_FakeSessionMaker(session=_FakeSession(values=[contents, []]))
|
||||
)
|
||||
|
||||
result = repository.get_by_message_ids(message_ids)
|
||||
|
||||
assert len(result) == 2
|
||||
assert [content.model_dump(mode="json", exclude_none=True) for content in result[0]] == [
|
||||
HumanInputContentDomain(
|
||||
workflow_run_id="workflow-run",
|
||||
submitted=True,
|
||||
form_submission_data=HumanInputFormSubmissionData(
|
||||
node_id="node-id",
|
||||
node_title="Approval",
|
||||
rendered_content="Rendered Approve",
|
||||
action_id="approve",
|
||||
action_text="Approve",
|
||||
),
|
||||
).model_dump(mode="json", exclude_none=True)
|
||||
]
|
||||
assert result[1] == []
|
||||
|
||||
|
||||
def test_get_by_message_ids_returns_unsubmitted_form_definition() -> None:
|
||||
expiration_time = datetime.now(UTC) + timedelta(days=1)
|
||||
definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
user_actions=[UserAction(id="approve", title="Approve")],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
default_values={"name": "John"},
|
||||
node_title="Approval",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form = HumanInputForm(
|
||||
id="form-1",
|
||||
tenant_id="tenant-id",
|
||||
app_id="app-id",
|
||||
workflow_run_id="workflow-run",
|
||||
node_id="node-id",
|
||||
form_definition=definition.model_dump_json(),
|
||||
rendered_content="Rendered block",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
content = HumanInputContentModel(
|
||||
id="content-msg-1",
|
||||
form_id=form.id,
|
||||
message_id="msg-1",
|
||||
workflow_run_id=form.workflow_run_id,
|
||||
)
|
||||
content.form = form
|
||||
|
||||
recipient = HumanInputFormRecipient(
|
||||
form_id=form.id,
|
||||
delivery_id="delivery-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
recipient_payload=ConsoleRecipientPayload(account_id=None).model_dump_json(),
|
||||
access_token="token-1",
|
||||
)
|
||||
|
||||
repository = SQLAlchemyExecutionExtraContentRepository(
|
||||
session_maker=_FakeSessionMaker(session=_FakeSession(values=[[content], [recipient]]))
|
||||
)
|
||||
|
||||
result = repository.get_by_message_ids(["msg-1"])
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 1
|
||||
domain_content = result[0][0]
|
||||
assert domain_content.submitted is False
|
||||
assert domain_content.workflow_run_id == "workflow-run"
|
||||
assert domain_content.form_definition is not None
|
||||
assert domain_content.form_definition.expiration_time == int(form.expiration_time.timestamp())
|
||||
assert domain_content.form_definition is not None
|
||||
form_definition = domain_content.form_definition
|
||||
assert form_definition.form_id == "form-1"
|
||||
assert form_definition.node_id == "node-id"
|
||||
assert form_definition.node_title == "Approval"
|
||||
assert form_definition.form_content == "Rendered block"
|
||||
assert form_definition.display_in_ui is True
|
||||
assert form_definition.form_token == "token-1"
|
||||
assert form_definition.resolved_default_values == {"name": "John"}
|
||||
assert form_definition.expiration_time == int(form.expiration_time.timestamp())
|
||||
73
api/tests/unit_tests/services/test_attachment_service.py
Normal file
73
api/tests/unit_tests/services/test_attachment_service.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import base64
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
import services.attachment_service as attachment_service_module
|
||||
from models.model import UploadFile
|
||||
from services.attachment_service import AttachmentService
|
||||
|
||||
|
||||
class TestAttachmentService:
|
||||
def test_should_initialize_with_sessionmaker_when_sessionmaker_is_provided(self):
|
||||
"""Test that AttachmentService keeps the provided sessionmaker instance."""
|
||||
session_factory = sessionmaker()
|
||||
|
||||
service = AttachmentService(session_factory=session_factory)
|
||||
|
||||
assert service._session_maker is session_factory
|
||||
|
||||
def test_should_initialize_with_bound_sessionmaker_when_engine_is_provided(self):
|
||||
"""Test that AttachmentService builds a sessionmaker bound to the provided engine."""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
service = AttachmentService(session_factory=engine)
|
||||
session = service._session_maker()
|
||||
try:
|
||||
assert session.bind == engine
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
@pytest.mark.parametrize("invalid_session_factory", [None, "not-a-session-factory", 1])
|
||||
def test_should_raise_assertion_error_when_session_factory_type_is_invalid(self, invalid_session_factory):
|
||||
"""Test that invalid session_factory types are rejected."""
|
||||
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
|
||||
AttachmentService(session_factory=invalid_session_factory)
|
||||
|
||||
def test_should_return_base64_encoded_blob_when_file_exists(self):
|
||||
"""Test that existing files are loaded from storage and returned as base64."""
|
||||
service = AttachmentService(session_factory=sessionmaker())
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.key = "upload-file-key"
|
||||
|
||||
session = MagicMock()
|
||||
session.query.return_value.where.return_value.first.return_value = upload_file
|
||||
service._session_maker = MagicMock(return_value=session)
|
||||
|
||||
with patch.object(attachment_service_module.storage, "load_once", return_value=b"binary-content") as mock_load:
|
||||
result = service.get_file_base64("file-123")
|
||||
|
||||
assert result == base64.b64encode(b"binary-content").decode()
|
||||
service._session_maker.assert_called_once_with(expire_on_commit=False)
|
||||
session.query.assert_called_once_with(UploadFile)
|
||||
mock_load.assert_called_once_with("upload-file-key")
|
||||
|
||||
def test_should_raise_not_found_when_file_does_not_exist(self):
|
||||
"""Test that missing files raise NotFound and never call storage."""
|
||||
service = AttachmentService(session_factory=sessionmaker())
|
||||
|
||||
session = MagicMock()
|
||||
session.query.return_value.where.return_value.first.return_value = None
|
||||
service._session_maker = MagicMock(return_value=session)
|
||||
|
||||
with patch.object(attachment_service_module.storage, "load_once") as mock_load:
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
service.get_file_base64("missing-file")
|
||||
|
||||
service._session_maker.assert_called_once_with(expire_on_commit=False)
|
||||
session.query.assert_called_once_with(UploadFile)
|
||||
mock_load.assert_not_called()
|
||||
@@ -0,0 +1,75 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_graph.variables import StringVariable
|
||||
from services.conversation_variable_updater import ConversationVariableNotFoundError, ConversationVariableUpdater
|
||||
|
||||
|
||||
class TestConversationVariableUpdater:
|
||||
def test_should_update_conversation_variable_data_and_commit(self):
|
||||
"""Test update persists serialized variable data when the row exists."""
|
||||
conversation_id = "conv-123"
|
||||
variable = StringVariable(
|
||||
id="var-123",
|
||||
name="topic",
|
||||
value="new value",
|
||||
)
|
||||
expected_json = variable.model_dump_json()
|
||||
|
||||
row = SimpleNamespace(data="old value")
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = row
|
||||
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
session_context.__exit__.return_value = None
|
||||
|
||||
session_maker = MagicMock(return_value=session_context)
|
||||
updater = ConversationVariableUpdater(session_maker)
|
||||
|
||||
updater.update(conversation_id=conversation_id, variable=variable)
|
||||
|
||||
session_maker.assert_called_once_with()
|
||||
session.scalar.assert_called_once()
|
||||
stmt = session.scalar.call_args.args[0]
|
||||
compiled_params = stmt.compile().params
|
||||
assert variable.id in compiled_params.values()
|
||||
assert conversation_id in compiled_params.values()
|
||||
assert row.data == expected_json
|
||||
session.commit.assert_called_once()
|
||||
|
||||
def test_should_raise_not_found_error_when_conversation_variable_missing(self):
|
||||
"""Test update raises ConversationVariableNotFoundError when no matching row exists."""
|
||||
conversation_id = "conv-404"
|
||||
variable = StringVariable(
|
||||
id="var-404",
|
||||
name="topic",
|
||||
value="value",
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
session_context.__exit__.return_value = None
|
||||
|
||||
session_maker = MagicMock(return_value=session_context)
|
||||
updater = ConversationVariableUpdater(session_maker)
|
||||
|
||||
with pytest.raises(ConversationVariableNotFoundError, match="conversation variable not found in the database"):
|
||||
updater.update(conversation_id=conversation_id, variable=variable)
|
||||
|
||||
session.commit.assert_not_called()
|
||||
|
||||
def test_should_do_nothing_when_flush_is_called(self):
|
||||
"""Test flush currently behaves as a no-op and returns None."""
|
||||
session_maker = MagicMock()
|
||||
updater = ConversationVariableUpdater(session_maker)
|
||||
|
||||
result = updater.flush()
|
||||
|
||||
assert result is None
|
||||
session_maker.assert_not_called()
|
||||
157
api/tests/unit_tests/services/test_credit_pool_service.py
Normal file
157
api/tests/unit_tests/services/test_credit_pool_service.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import services.credit_pool_service as credit_pool_service_module
|
||||
from core.errors.error import QuotaExceededError
|
||||
from models import TenantCreditPool
|
||||
from services.credit_pool_service import CreditPoolService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credit_deduction_setup():
|
||||
"""Fixture providing common setup for credit deduction tests."""
|
||||
pool = SimpleNamespace(remaining_credits=50)
|
||||
fake_engine = MagicMock()
|
||||
session = MagicMock()
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
session_context.__exit__.return_value = None
|
||||
|
||||
mock_get_pool = patch.object(CreditPoolService, "get_pool", return_value=pool)
|
||||
mock_db = patch.object(credit_pool_service_module, "db", new=SimpleNamespace(engine=fake_engine))
|
||||
mock_session = patch.object(credit_pool_service_module, "Session", return_value=session_context)
|
||||
|
||||
return {
|
||||
"pool": pool,
|
||||
"fake_engine": fake_engine,
|
||||
"session": session,
|
||||
"session_context": session_context,
|
||||
"patches": (mock_get_pool, mock_db, mock_session),
|
||||
}
|
||||
|
||||
|
||||
class TestCreditPoolService:
|
||||
def test_should_create_default_pool_with_trial_type_and_configured_quota(self):
|
||||
"""Test create_default_pool persists a trial pool using configured hosted credits."""
|
||||
tenant_id = "tenant-123"
|
||||
hosted_pool_credits = 5000
|
||||
|
||||
with (
|
||||
patch.object(credit_pool_service_module.dify_config, "HOSTED_POOL_CREDITS", hosted_pool_credits),
|
||||
patch.object(credit_pool_service_module, "db") as mock_db,
|
||||
):
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
|
||||
assert isinstance(pool, TenantCreditPool)
|
||||
assert pool.tenant_id == tenant_id
|
||||
assert pool.pool_type == "trial"
|
||||
assert pool.quota_limit == hosted_pool_credits
|
||||
assert pool.quota_used == 0
|
||||
mock_db.session.add.assert_called_once_with(pool)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
def test_should_return_first_pool_from_query_when_get_pool_called(self):
|
||||
"""Test get_pool queries by tenant and pool_type and returns first result."""
|
||||
tenant_id = "tenant-123"
|
||||
pool_type = "enterprise"
|
||||
expected_pool = MagicMock(spec=TenantCreditPool)
|
||||
|
||||
with patch.object(credit_pool_service_module, "db") as mock_db:
|
||||
query = mock_db.session.query.return_value
|
||||
filtered_query = query.filter_by.return_value
|
||||
filtered_query.first.return_value = expected_pool
|
||||
|
||||
result = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type=pool_type)
|
||||
|
||||
assert result == expected_pool
|
||||
mock_db.session.query.assert_called_once_with(TenantCreditPool)
|
||||
query.filter_by.assert_called_once_with(tenant_id=tenant_id, pool_type=pool_type)
|
||||
filtered_query.first.assert_called_once()
|
||||
|
||||
def test_should_return_false_when_pool_not_found_in_check_credits_available(self):
|
||||
"""Test check_credits_available returns False when tenant has no pool."""
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=None) as mock_get_pool:
|
||||
result = CreditPoolService.check_credits_available(tenant_id="tenant-123", credits_required=10)
|
||||
|
||||
assert result is False
|
||||
mock_get_pool.assert_called_once_with("tenant-123", "trial")
|
||||
|
||||
def test_should_return_true_when_remaining_credits_cover_required_amount(self):
|
||||
"""Test check_credits_available returns True when remaining credits are sufficient."""
|
||||
pool = SimpleNamespace(remaining_credits=100)
|
||||
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=pool) as mock_get_pool:
|
||||
result = CreditPoolService.check_credits_available(tenant_id="tenant-123", credits_required=60)
|
||||
|
||||
assert result is True
|
||||
mock_get_pool.assert_called_once_with("tenant-123", "trial")
|
||||
|
||||
def test_should_return_false_when_remaining_credits_are_insufficient(self):
|
||||
"""Test check_credits_available returns False when required credits exceed remaining credits."""
|
||||
pool = SimpleNamespace(remaining_credits=30)
|
||||
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=pool):
|
||||
result = CreditPoolService.check_credits_available(tenant_id="tenant-123", credits_required=60)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_should_raise_quota_exceeded_when_pool_not_found_in_check_and_deduct(self):
|
||||
"""Test check_and_deduct_credits raises when tenant credit pool does not exist."""
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=None):
|
||||
with pytest.raises(QuotaExceededError, match="Credit pool not found"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-123", credits_required=10)
|
||||
|
||||
def test_should_raise_quota_exceeded_when_pool_has_no_remaining_credits(self):
|
||||
"""Test check_and_deduct_credits raises when remaining credits are zero or negative."""
|
||||
pool = SimpleNamespace(remaining_credits=0)
|
||||
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=pool):
|
||||
with pytest.raises(QuotaExceededError, match="No credits remaining"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-123", credits_required=10)
|
||||
|
||||
def test_should_deduct_minimum_of_required_and_remaining_credits(self, mock_credit_deduction_setup):
|
||||
"""Test check_and_deduct_credits updates quota_used by the actual deducted amount."""
|
||||
tenant_id = "tenant-123"
|
||||
pool_type = "trial"
|
||||
credits_required = 200
|
||||
remaining_credits = 120
|
||||
expected_deducted_credits = 120
|
||||
|
||||
mock_credit_deduction_setup["pool"].remaining_credits = remaining_credits
|
||||
patches = mock_credit_deduction_setup["patches"]
|
||||
session = mock_credit_deduction_setup["session"]
|
||||
|
||||
with patches[0], patches[1], patches[2]:
|
||||
result = CreditPoolService.check_and_deduct_credits(
|
||||
tenant_id=tenant_id,
|
||||
credits_required=credits_required,
|
||||
pool_type=pool_type,
|
||||
)
|
||||
|
||||
assert result == expected_deducted_credits
|
||||
session.execute.assert_called_once()
|
||||
session.commit.assert_called_once()
|
||||
|
||||
stmt = session.execute.call_args.args[0]
|
||||
compiled_params = stmt.compile().params
|
||||
assert tenant_id in compiled_params.values()
|
||||
assert pool_type in compiled_params.values()
|
||||
assert expected_deducted_credits in compiled_params.values()
|
||||
|
||||
def test_should_raise_quota_exceeded_when_deduction_update_fails(self, mock_credit_deduction_setup):
|
||||
"""Test check_and_deduct_credits translates DB update failures to QuotaExceededError."""
|
||||
mock_credit_deduction_setup["pool"].remaining_credits = 50
|
||||
mock_credit_deduction_setup["session"].execute.side_effect = Exception("db failure")
|
||||
session = mock_credit_deduction_setup["session"]
|
||||
|
||||
patches = mock_credit_deduction_setup["patches"]
|
||||
mock_logger = patch.object(credit_pool_service_module, "logger")
|
||||
|
||||
with patches[0], patches[1], patches[2], mock_logger as mock_logger_obj:
|
||||
with pytest.raises(QuotaExceededError, match="Failed to deduct credits"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-123", credits_required=10)
|
||||
|
||||
session.commit.assert_not_called()
|
||||
mock_logger_obj.exception.assert_called_once()
|
||||
305
api/tests/unit_tests/services/test_dataset_permission.py
Normal file
305
api/tests/unit_tests/services/test_dataset_permission.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.account import Account, TenantAccountRole
|
||||
from models.dataset import Dataset, DatasetPermission, DatasetPermissionEnum
|
||||
from services.dataset_service import DatasetService
|
||||
from services.errors.account import NoPermissionError
|
||||
|
||||
|
||||
class DatasetPermissionTestDataFactory:
|
||||
"""Factory class for creating test data and mock objects for dataset permission tests."""
|
||||
|
||||
@staticmethod
|
||||
def create_dataset_mock(
|
||||
dataset_id: str = "dataset-123",
|
||||
tenant_id: str = "test-tenant-123",
|
||||
created_by: str = "creator-456",
|
||||
permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""Create a mock dataset with specified attributes."""
|
||||
dataset = Mock(spec=Dataset)
|
||||
dataset.id = dataset_id
|
||||
dataset.tenant_id = tenant_id
|
||||
dataset.created_by = created_by
|
||||
dataset.permission = permission
|
||||
for key, value in kwargs.items():
|
||||
setattr(dataset, key, value)
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def create_user_mock(
|
||||
user_id: str = "user-789",
|
||||
tenant_id: str = "test-tenant-123",
|
||||
role: TenantAccountRole = TenantAccountRole.NORMAL,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""Create a mock user with specified attributes."""
|
||||
user = Mock(spec=Account)
|
||||
user.id = user_id
|
||||
user.current_tenant_id = tenant_id
|
||||
user.current_role = role
|
||||
for key, value in kwargs.items():
|
||||
setattr(user, key, value)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_dataset_permission_mock(
|
||||
dataset_id: str = "dataset-123",
|
||||
account_id: str = "user-789",
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""Create a mock dataset permission record."""
|
||||
permission = Mock(spec=DatasetPermission)
|
||||
permission.dataset_id = dataset_id
|
||||
permission.account_id = account_id
|
||||
for key, value in kwargs.items():
|
||||
setattr(permission, key, value)
|
||||
return permission
|
||||
|
||||
|
||||
class TestDatasetPermissionService:
|
||||
"""
|
||||
Comprehensive unit tests for DatasetService.check_dataset_permission method.
|
||||
|
||||
This test suite covers all permission scenarios including:
|
||||
- Cross-tenant access restrictions
|
||||
- Owner privilege checks
|
||||
- Different permission levels (ONLY_ME, ALL_TEAM, PARTIAL_TEAM)
|
||||
- Explicit permission checks for PARTIAL_TEAM
|
||||
- Error conditions and logging
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dataset_service_dependencies(self):
|
||||
"""Common mock setup for dataset service dependencies."""
|
||||
with patch("services.dataset_service.db.session") as mock_session:
|
||||
yield {
|
||||
"db_session": mock_session,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logging_dependencies(self):
|
||||
"""Mock setup for logging tests."""
|
||||
with patch("services.dataset_service.logger") as mock_logging:
|
||||
yield {
|
||||
"logging": mock_logging,
|
||||
}
|
||||
|
||||
def _assert_permission_check_passes(self, dataset: Mock, user: Mock):
|
||||
"""Helper method to verify that permission check passes without raising exceptions."""
|
||||
# Should not raise any exception
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
def _assert_permission_check_fails(
|
||||
self, dataset: Mock, user: Mock, expected_message: str = "You do not have permission to access this dataset."
|
||||
):
|
||||
"""Helper method to verify that permission check fails with expected error."""
|
||||
with pytest.raises(NoPermissionError, match=expected_message):
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
def _assert_database_query_called(self, mock_session: Mock, dataset_id: str, account_id: str):
|
||||
"""Helper method to verify database query calls for permission checks."""
|
||||
mock_session.query().filter_by.assert_called_with(dataset_id=dataset_id, account_id=account_id)
|
||||
|
||||
def _assert_database_query_not_called(self, mock_session: Mock):
|
||||
"""Helper method to verify that database query was not called."""
|
||||
mock_session.query.assert_not_called()
|
||||
|
||||
# ==================== Cross-Tenant Access Tests ====================
|
||||
|
||||
def test_permission_check_different_tenant_should_fail(self):
|
||||
"""Test that users from different tenants cannot access dataset regardless of other permissions."""
|
||||
# Create dataset and user from different tenants
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
tenant_id="tenant-123", permission=DatasetPermissionEnum.ALL_TEAM
|
||||
)
|
||||
user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="user-789", tenant_id="different-tenant-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# Should fail due to different tenant
|
||||
self._assert_permission_check_fails(dataset, user)
|
||||
|
||||
# ==================== Owner Privilege Tests ====================
|
||||
|
||||
def test_owner_can_access_any_dataset(self):
|
||||
"""Test that tenant owners can access any dataset regardless of permission level."""
|
||||
# Create dataset with restrictive permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.ONLY_ME)
|
||||
|
||||
# Create owner user
|
||||
owner_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="owner-999", role=TenantAccountRole.OWNER
|
||||
)
|
||||
|
||||
# Owner should have access regardless of dataset permission
|
||||
self._assert_permission_check_passes(dataset, owner_user)
|
||||
|
||||
# ==================== ONLY_ME Permission Tests ====================
|
||||
|
||||
def test_only_me_permission_creator_can_access(self):
|
||||
"""Test ONLY_ME permission allows only the dataset creator to access."""
|
||||
# Create dataset with ONLY_ME permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
# Create creator user
|
||||
creator_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="creator-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# Creator should be able to access
|
||||
self._assert_permission_check_passes(dataset, creator_user)
|
||||
|
||||
def test_only_me_permission_others_cannot_access(self):
|
||||
"""Test ONLY_ME permission denies access to non-creators."""
|
||||
# Create dataset with ONLY_ME permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
# Create normal user (not the creator)
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Non-creator should be denied access
|
||||
self._assert_permission_check_fails(dataset, normal_user)
|
||||
|
||||
# ==================== ALL_TEAM Permission Tests ====================
|
||||
|
||||
def test_all_team_permission_allows_access(self):
|
||||
"""Test ALL_TEAM permission allows any team member to access the dataset."""
|
||||
# Create dataset with ALL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.ALL_TEAM)
|
||||
|
||||
# Create different types of team members
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
editor_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="editor-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# All team members should have access
|
||||
self._assert_permission_check_passes(dataset, normal_user)
|
||||
self._assert_permission_check_passes(dataset, editor_user)
|
||||
|
||||
# ==================== PARTIAL_TEAM Permission Tests ====================
|
||||
|
||||
def test_partial_team_permission_creator_can_access(self, mock_dataset_service_dependencies):
|
||||
"""Test PARTIAL_TEAM permission allows creator to access without database query."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.PARTIAL_TEAM
|
||||
)
|
||||
|
||||
# Create creator user
|
||||
creator_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="creator-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# Creator should have access without database query
|
||||
self._assert_permission_check_passes(dataset, creator_user)
|
||||
self._assert_database_query_not_called(mock_dataset_service_dependencies["db_session"])
|
||||
|
||||
def test_partial_team_permission_with_explicit_permission(self, mock_dataset_service_dependencies):
|
||||
"""Test PARTIAL_TEAM permission allows users with explicit permission records."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM)
|
||||
|
||||
# Create normal user (not the creator)
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Mock database query to return a permission record
|
||||
mock_permission = DatasetPermissionTestDataFactory.create_dataset_permission_mock(
|
||||
dataset_id=dataset.id, account_id=normal_user.id
|
||||
)
|
||||
mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = mock_permission
|
||||
|
||||
# User with explicit permission should have access
|
||||
self._assert_permission_check_passes(dataset, normal_user)
|
||||
self._assert_database_query_called(mock_dataset_service_dependencies["db_session"], dataset.id, normal_user.id)
|
||||
|
||||
def test_partial_team_permission_without_explicit_permission(self, mock_dataset_service_dependencies):
|
||||
"""Test PARTIAL_TEAM permission denies users without explicit permission records."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM)
|
||||
|
||||
# Create normal user (not the creator)
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Mock database query to return None (no permission record)
|
||||
mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = None
|
||||
|
||||
# User without explicit permission should be denied access
|
||||
self._assert_permission_check_fails(dataset, normal_user)
|
||||
self._assert_database_query_called(mock_dataset_service_dependencies["db_session"], dataset.id, normal_user.id)
|
||||
|
||||
def test_partial_team_permission_non_creator_without_permission_fails(self, mock_dataset_service_dependencies):
|
||||
"""Test that non-creators without explicit permission are denied access to PARTIAL_TEAM datasets."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.PARTIAL_TEAM
|
||||
)
|
||||
|
||||
# Create a different user (not the creator)
|
||||
other_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="other-user-123", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Mock database query to return None (no permission record)
|
||||
mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = None
|
||||
|
||||
# Non-creator without explicit permission should be denied access
|
||||
self._assert_permission_check_fails(dataset, other_user)
|
||||
self._assert_database_query_called(mock_dataset_service_dependencies["db_session"], dataset.id, other_user.id)
|
||||
|
||||
# ==================== Enum Usage Tests ====================
|
||||
|
||||
def test_partial_team_permission_uses_correct_enum(self):
|
||||
"""Test that the method correctly uses DatasetPermissionEnum.PARTIAL_TEAM instead of string literals."""
|
||||
# Create dataset with PARTIAL_TEAM permission using enum
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.PARTIAL_TEAM
|
||||
)
|
||||
|
||||
# Create creator user
|
||||
creator_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="creator-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# Creator should always have access regardless of permission level
|
||||
self._assert_permission_check_passes(dataset, creator_user)
|
||||
|
||||
# ==================== Logging Tests ====================
|
||||
|
||||
def test_permission_denied_logs_debug_message(self, mock_dataset_service_dependencies, mock_logging_dependencies):
|
||||
"""Test that permission denied events are properly logged for debugging purposes."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM)
|
||||
|
||||
# Create normal user (not the creator)
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Mock database query to return None (no permission record)
|
||||
mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = None
|
||||
|
||||
# Attempt permission check (should fail)
|
||||
with pytest.raises(NoPermissionError):
|
||||
DatasetService.check_dataset_permission(dataset, normal_user)
|
||||
|
||||
# Verify debug message was logged with correct user and dataset information
|
||||
mock_logging_dependencies["logging"].debug.assert_called_with(
|
||||
"User %s does not have permission to access dataset %s", normal_user.id, dataset.id
|
||||
)
|
||||
@@ -75,7 +75,6 @@ import pytest
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from models.dataset import Dataset
|
||||
from models.enums import TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
from services.tag_service import TagService
|
||||
|
||||
@@ -103,7 +102,7 @@ class TagServiceTestDataFactory:
|
||||
def create_tag_mock(
|
||||
tag_id: str = "tag-123",
|
||||
name: str = "Test Tag",
|
||||
tag_type: TagType = TagType.APP,
|
||||
tag_type: str = "app",
|
||||
tenant_id: str = "tenant-123",
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
@@ -706,7 +705,7 @@ class TestTagServiceCRUD:
|
||||
# Verify tag attributes
|
||||
added_tag = mock_db_session.add.call_args[0][0]
|
||||
assert added_tag.name == "New Tag", "Tag name should match"
|
||||
assert added_tag.type == TagType.APP, "Tag type should match"
|
||||
assert added_tag.type == "app", "Tag type should match"
|
||||
assert added_tag.created_by == "user-123", "Created by should match current user"
|
||||
assert added_tag.tenant_id == "tenant-123", "Tenant ID should match current tenant"
|
||||
|
||||
|
||||
127
api/tests/unit_tests/services/workflow/test_workflow_deletion.py
Normal file
127
api/tests/unit_tests/services/workflow/test_workflow_deletion.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.model import App
|
||||
from models.workflow import Workflow
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_setup():
|
||||
mock_session_maker = MagicMock()
|
||||
workflow_service = WorkflowService(mock_session_maker)
|
||||
session = MagicMock(spec=Session)
|
||||
tenant_id = "test-tenant-id"
|
||||
workflow_id = "test-workflow-id"
|
||||
|
||||
# Mock workflow
|
||||
workflow = MagicMock(spec=Workflow)
|
||||
workflow.id = workflow_id
|
||||
workflow.tenant_id = tenant_id
|
||||
workflow.version = "1.0" # Not a draft
|
||||
workflow.tool_published = False # Not published as a tool by default
|
||||
|
||||
# Mock app
|
||||
app = MagicMock(spec=App)
|
||||
app.id = "test-app-id"
|
||||
app.name = "Test App"
|
||||
app.workflow_id = None # Not used by an app by default
|
||||
|
||||
return {
|
||||
"workflow_service": workflow_service,
|
||||
"session": session,
|
||||
"tenant_id": tenant_id,
|
||||
"workflow_id": workflow_id,
|
||||
"workflow": workflow,
|
||||
"app": app,
|
||||
}
|
||||
|
||||
|
||||
def test_delete_workflow_success(workflow_setup):
|
||||
# Setup mocks
|
||||
|
||||
# Mock the tool provider query to return None (not published as a tool)
|
||||
workflow_setup["session"].query.return_value.where.return_value.first.return_value = None
|
||||
|
||||
workflow_setup["session"].scalar = MagicMock(
|
||||
side_effect=[workflow_setup["workflow"], None]
|
||||
) # Return workflow first, then None for app
|
||||
|
||||
# Call the method
|
||||
result = workflow_setup["workflow_service"].delete_workflow(
|
||||
session=workflow_setup["session"],
|
||||
workflow_id=workflow_setup["workflow_id"],
|
||||
tenant_id=workflow_setup["tenant_id"],
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result is True
|
||||
workflow_setup["session"].delete.assert_called_once_with(workflow_setup["workflow"])
|
||||
|
||||
|
||||
def test_delete_workflow_draft_error(workflow_setup):
|
||||
# Setup mocks
|
||||
workflow_setup["workflow"].version = "draft"
|
||||
workflow_setup["session"].scalar = MagicMock(return_value=workflow_setup["workflow"])
|
||||
|
||||
# Call the method and verify exception
|
||||
with pytest.raises(DraftWorkflowDeletionError):
|
||||
workflow_setup["workflow_service"].delete_workflow(
|
||||
session=workflow_setup["session"],
|
||||
workflow_id=workflow_setup["workflow_id"],
|
||||
tenant_id=workflow_setup["tenant_id"],
|
||||
)
|
||||
|
||||
# Verify
|
||||
workflow_setup["session"].delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_workflow_in_use_by_app_error(workflow_setup):
|
||||
# Setup mocks
|
||||
workflow_setup["app"].workflow_id = workflow_setup["workflow_id"]
|
||||
workflow_setup["session"].scalar = MagicMock(
|
||||
side_effect=[workflow_setup["workflow"], workflow_setup["app"]]
|
||||
) # Return workflow first, then app
|
||||
|
||||
# Call the method and verify exception
|
||||
with pytest.raises(WorkflowInUseError) as excinfo:
|
||||
workflow_setup["workflow_service"].delete_workflow(
|
||||
session=workflow_setup["session"],
|
||||
workflow_id=workflow_setup["workflow_id"],
|
||||
tenant_id=workflow_setup["tenant_id"],
|
||||
)
|
||||
|
||||
# Verify error message contains app name
|
||||
assert "Cannot delete workflow that is currently in use by app" in str(excinfo.value)
|
||||
|
||||
# Verify
|
||||
workflow_setup["session"].delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_workflow_published_as_tool_error(workflow_setup):
|
||||
# Setup mocks
|
||||
from models.tools import WorkflowToolProvider
|
||||
|
||||
# Mock the tool provider query
|
||||
mock_tool_provider = MagicMock(spec=WorkflowToolProvider)
|
||||
workflow_setup["session"].query.return_value.where.return_value.first.return_value = mock_tool_provider
|
||||
|
||||
workflow_setup["session"].scalar = MagicMock(
|
||||
side_effect=[workflow_setup["workflow"], None]
|
||||
) # Return workflow first, then None for app
|
||||
|
||||
# Call the method and verify exception
|
||||
with pytest.raises(WorkflowInUseError) as excinfo:
|
||||
workflow_setup["workflow_service"].delete_workflow(
|
||||
session=workflow_setup["session"],
|
||||
workflow_id=workflow_setup["workflow_id"],
|
||||
tenant_id=workflow_setup["tenant_id"],
|
||||
)
|
||||
|
||||
# Verify error message
|
||||
assert "Cannot delete workflow that is published as a tool" in str(excinfo.value)
|
||||
|
||||
# Verify
|
||||
workflow_setup["session"].delete.assert_not_called()
|
||||
360
api/uv.lock
generated
360
api/uv.lock
generated
@@ -169,6 +169,12 @@ version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb076b2f7e3c81ab3c2ba91de3bb067196f675d60d34c/alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f", size = 2330, upload-time = "2025-01-13T05:53:04.931Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-endpoint-util"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/7d/8cc92a95c920e344835b005af6ea45a0db98763ad6ad19299d26892e6c8d/alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90", size = 2813, upload-time = "2025-06-12T07:20:52.572Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-gateway-spi"
|
||||
version = "0.0.3"
|
||||
@@ -180,17 +186,69 @@ sdist = { url = "https://files.pythonhosted.org/packages/ab/98/d7111245f17935bf7
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-gpdb20160503"
|
||||
version = "5.1.0"
|
||||
version = "3.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-endpoint-util" },
|
||||
{ name = "alibabacloud-openapi-util" },
|
||||
{ name = "alibabacloud-openplatform20191219" },
|
||||
{ name = "alibabacloud-oss-sdk" },
|
||||
{ name = "alibabacloud-oss-util" },
|
||||
{ name = "alibabacloud-tea-fileform" },
|
||||
{ name = "alibabacloud-tea-openapi" },
|
||||
{ name = "alibabacloud-tea-util" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/6a/cc72e744e95c8f37fa6a84e66ae0b9b57a13ee97a0ef03d94c7127c31d75/alibabacloud_gpdb20160503-3.8.3.tar.gz", hash = "sha256:4dfcc0d9cff5a921d529d76f4bf97e2ceb9dc2fa53f00ab055f08509423d8e30", size = 155092, upload-time = "2024-07-18T17:09:42.438Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/36/bce41704b3bf59d607590ec73a42a254c5dea27c0f707aee11d20512a200/alibabacloud_gpdb20160503-3.8.3-py3-none-any.whl", hash = "sha256:06e1c46ce5e4e9d1bcae76e76e51034196c625799d06b2efec8d46a7df323fe8", size = 156097, upload-time = "2024-07-18T17:09:40.414Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-openapi-util"
|
||||
version = "0.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-tea-util" },
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/50/5f41ab550d7874c623f6e992758429802c4b52a6804db437017e5387de33/alibabacloud_openapi_util-0.2.2.tar.gz", hash = "sha256:ebbc3906f554cb4bf8f513e43e8a33e8b6a3d4a0ef13617a0e14c3dda8ef52a8", size = 7201, upload-time = "2023-10-23T07:44:18.523Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-openplatform20191219"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-endpoint-util" },
|
||||
{ name = "alibabacloud-openapi-util" },
|
||||
{ name = "alibabacloud-tea-openapi" },
|
||||
{ name = "alibabacloud-tea-util" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/bf/f7fa2f3657ed352870f442434cb2f27b7f70dcd52a544a1f3998eeaf6d71/alibabacloud_openplatform20191219-2.0.0.tar.gz", hash = "sha256:e67f4c337b7542538746592c6a474bd4ae3a9edccdf62e11a32ca61fad3c9020", size = 5038, upload-time = "2022-09-21T06:16:10.683Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e5/18c75213551eeca9db1f6b41ddcc0bd87b5b6508c75a67f05cd8671847b4/alibabacloud_openplatform20191219-2.0.0-py3-none-any.whl", hash = "sha256:873821c45bca72a6c6ec7a906c9cb21554c122e88893bbac3986934dab30dd36", size = 5204, upload-time = "2022-09-21T06:16:07.844Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-oss-sdk"
|
||||
version = "0.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-credentials" },
|
||||
{ name = "alibabacloud-tea-openapi" },
|
||||
{ name = "darabonba-core" },
|
||||
{ name = "alibabacloud-oss-util" },
|
||||
{ name = "alibabacloud-tea-fileform" },
|
||||
{ name = "alibabacloud-tea-util" },
|
||||
{ name = "alibabacloud-tea-xml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/36/69333c7fb7fb5267f338371b14fdd8dbdd503717c97bbc7a6419d155ab4c/alibabacloud_gpdb20160503-5.1.0.tar.gz", hash = "sha256:086ec6d5e39b64f54d0e44bb3fd4fde1a4822a53eb9f6ff7464dff7d19b07b63", size = 295641, upload-time = "2026-03-19T10:09:02.444Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/7f/a91a2f9ad97c92fa9a6981587ea0ff789240cea05b17b17b7c244e5bac64/alibabacloud_gpdb20160503-5.1.0-py3-none-any.whl", hash = "sha256:580e4579285a54c7f04570782e0f60423a1997568684187fe88e4110acfb640e", size = 848784, upload-time = "2026-03-19T10:09:00.72Z" },
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/d1/f442dd026908fcf55340ca694bb1d027aa91e119e76ae2fbea62f2bde4f4/alibabacloud_oss_sdk-0.1.1.tar.gz", hash = "sha256:f51a368020d0964fcc0978f96736006f49f5ab6a4a4bf4f0b8549e2c659e7358", size = 46434, upload-time = "2025-04-22T12:40:41.717Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-oss-util"
|
||||
version = "0.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-tea" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/7c/d7e812b9968247a302573daebcfef95d0f9a718f7b4bfcca8d3d83e266be/alibabacloud_oss_util-0.0.6.tar.gz", hash = "sha256:d3ecec36632434bd509a113e8cf327dc23e830ac8d9dd6949926f4e334c8b5d6", size = 10008, upload-time = "2021-04-28T09:25:04.056Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea"
|
||||
@@ -202,6 +260,15 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0f3f9d7f26b76b9ed93d4101add7867a2c87ed2534f5/alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a", size = 8785, upload-time = "2025-03-24T07:34:42.958Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-fileform"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-tea" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/8a/ef8ddf5ee0350984cad2749414b420369fe943e15e6d96b79be45367630e/alibabacloud_tea_fileform-0.0.5.tar.gz", hash = "sha256:fd00a8c9d85e785a7655059e9651f9e91784678881831f60589172387b968ee8", size = 3961, upload-time = "2021-04-28T09:22:54.56Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-openapi"
|
||||
version = "0.4.3"
|
||||
@@ -230,6 +297,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/9e/c394b4e2104766fb28a1e44e3ed36e4c7773b4d05c868e482be99d5635c9/alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe", size = 6697, upload-time = "2025-11-19T06:01:07.355Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-xml"
|
||||
version = "0.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-tea" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/eb/5e82e419c3061823f3feae9b5681588762929dc4da0176667297c2784c1a/alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c", size = 3466, upload-time = "2025-07-01T08:04:55.144Z" }
|
||||
|
||||
[[package]]
|
||||
name = "aliyun-log-python-sdk"
|
||||
version = "0.9.37"
|
||||
@@ -494,28 +570,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "basedpyright"
|
||||
version = "1.38.3"
|
||||
version = "1.38.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodejs-wheel-binaries" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/58/7abba2c743571a42b2548f07aee556ebc1e4d0bc2b277aeba1ee6c83b0af/basedpyright-1.38.3.tar.gz", hash = "sha256:9725419786afbfad8a9539527f162da02d462afad440b0412fdb3f3cdf179b90", size = 25277430, upload-time = "2026-03-17T13:10:41.526Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/a3/20aa7c4e83f2f614e0036300f3c352775dede0655c66814da16c37b661a9/basedpyright-1.38.2.tar.gz", hash = "sha256:b433b2b8ba745ed7520cdc79a29a03682f3fb00346d272ece5944e9e5e5daa92", size = 25277019, upload-time = "2026-02-26T11:18:43.594Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e3/3ebb5c23bd3abb5fc2053b8a06a889aa5c1cf8cff738c78cb6c1957e90cd/basedpyright-1.38.3-py3-none-any.whl", hash = "sha256:1f15c2e489c67d6c5e896c24b6a63251195c04223a55e4568b8f8e8ed49ca830", size = 12313363, upload-time = "2026-03-17T13:10:47.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/12/736cab83626fea3fe65cdafb3ef3d2ee9480c56723f2fd33921537289a5e/basedpyright-1.38.2-py3-none-any.whl", hash = "sha256:153481d37fd19f9e3adedc8629d1d071b10c5f5e49321fb026b74444b7c70e24", size = 12312475, upload-time = "2026-02-26T11:18:40.373Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bce-python-sdk"
|
||||
version = "0.9.64"
|
||||
version = "0.9.63"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "future" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/33/047e9c1a6c97e0cd4d93a6490abd8fbc2ccd13569462fc0228699edc08bc/bce_python_sdk-0.9.64.tar.gz", hash = "sha256:901bf787c26ad35855a80d65e58d7584c8541f7f0f2af20847830e572e5b622e", size = 287125, upload-time = "2026-03-17T11:24:29.345Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ab/4c2927b01a97562af6a296b722eee79658335795f341a395a12742d5e1a3/bce_python_sdk-0.9.63.tar.gz", hash = "sha256:0c80bc3ac128a0a144bae3b8dff1f397f42c30b36f7677e3a39d8df8e77b1088", size = 284419, upload-time = "2026-03-06T14:54:06.592Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/7f/dd289582f37ab4effea47b2a8503880db4781ca0fc8e0a8ed5ff493359e5/bce_python_sdk-0.9.64-py3-none-any.whl", hash = "sha256:eaad97e4f0e7d613ae978da3cdc5294e9f724ffca2735f79820037fa1317cd6d", size = 402233, upload-time = "2026-03-17T11:24:24.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/a4/501e978776c7060aa8ba77e68536597e754d938bcdbe1826618acebfbddf/bce_python_sdk-0.9.63-py3-none-any.whl", hash = "sha256:ec66eee8807c6aa4036412592da7e8c9e2cd7fdec494190986288ac2195d8276", size = 400305, upload-time = "2026-03-06T14:53:52.887Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -584,14 +660,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "bleach"
|
||||
version = "6.3.0"
|
||||
version = "6.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -630,30 +706,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.42.73"
|
||||
version = "1.42.68"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8b/d00575be514744ca4839e7d85bf4a8a3c7b6b4574433291e58d14c68ae09/boto3-1.42.73.tar.gz", hash = "sha256:d37b58d6cd452ca808dd6823ae19ca65b6244096c5125ef9052988b337298bae", size = 112775, upload-time = "2026-03-20T19:39:52.814Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ae/60c642aa5413e560b671da825329f510b29a77274ed0f580bde77562294d/boto3-1.42.68.tar.gz", hash = "sha256:3f349f967ab38c23425626d130962bcb363e75f042734fe856ea8c5a00eef03c", size = 112761, upload-time = "2026-03-13T19:32:17.137Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/05/1fcf03d90abaa3d0b42a6bfd10231dd709493ecbacf794aa2eea5eae6841/boto3-1.42.73-py3-none-any.whl", hash = "sha256:1f81b79b873f130eeab14bb556417a7c66d38f3396b7f2fe3b958b3f9094f455", size = 140556, upload-time = "2026-03-20T19:39:50.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/f6/dc6e993479dbb597d68223fbf61cb026511737696b15bd7d2a33e9b2c24f/boto3-1.42.68-py3-none-any.whl", hash = "sha256:dbff353eb7dc93cbddd7926ed24793e0174c04adbe88860dfa639568442e4962", size = 140556, upload-time = "2026-03-13T19:32:14.951Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3-stubs"
|
||||
version = "1.42.73"
|
||||
version = "1.42.68"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore-stubs" },
|
||||
{ name = "types-s3transfer" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/c3/fcc47102c63278af25ad57c93d97dc393f4dbc54c0117a29c78f2b96ec1e/boto3_stubs-1.42.73.tar.gz", hash = "sha256:36f625769b5505c4bc627f16244b98de9e10dae3ac36f1aa0f0ebe2f201dc138", size = 101373, upload-time = "2026-03-20T19:59:51.463Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/8c/dd4b0c95ff008bed5a35ab411452ece121b355539d2a0b6dcd62a0c47be5/boto3_stubs-1.42.68.tar.gz", hash = "sha256:96ad1020735619483fb9b4da7a5e694b460bf2e18f84a34d5d175d0ffe8c4653", size = 101372, upload-time = "2026-03-13T19:49:54.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/57/d570ba61a2a0c7fe0c8667e41269a0480293cb53e1786d6661a2bd827fc5/boto3_stubs-1.42.73-py3-none-any.whl", hash = "sha256:bd658429069d8215247fc3abc003220cd875c24ab6eda7b3405090408afaacdf", size = 70009, upload-time = "2026-03-20T19:59:43.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/15/3ca5848917214a168134512a5b45f856a56e913659888947a052e02031b5/boto3_stubs-1.42.68-py3-none-any.whl", hash = "sha256:ed7f98334ef7b2377fa8532190e63dc2c6d1dc895e3d7cb3d6d1c83771b81bf6", size = 70011, upload-time = "2026-03-13T19:49:42.801Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -663,16 +739,16 @@ bedrock-runtime = [
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.42.73"
|
||||
version = "1.42.68"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/22/87502d5fbbfa8189406a617b30b1e2a3dc0ab2669f7268e91b385c1c1c7a/botocore-1.42.68.tar.gz", hash = "sha256:3951c69e12ac871dda245f48dac5c7dd88ea1bfdd74a8879ec356cf2874b806a", size = 14994514, upload-time = "2026-03-13T19:32:03.577Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2a/1428f6594799780fe6ee845d8e6aeffafe026cd16a70c878684e2dcbbfc8/botocore-1.42.68-py3-none-any.whl", hash = "sha256:9df7da26374601f890e2f115bfa573d65bf15b25fe136bb3aac809f6145f52ab", size = 14668816, upload-time = "2026-03-13T19:31:58.572Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1214,41 +1290,41 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.5"
|
||||
version = "7.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1668,8 +1744,8 @@ requires-dist = [
|
||||
{ name = "arize-phoenix-otel", specifier = "~=0.15.0" },
|
||||
{ name = "azure-identity", specifier = "==1.25.3" },
|
||||
{ name = "beautifulsoup4", specifier = "==4.14.3" },
|
||||
{ name = "bleach", specifier = "~=6.3.0" },
|
||||
{ name = "boto3", specifier = "==1.42.73" },
|
||||
{ name = "bleach", specifier = "~=6.2.0" },
|
||||
{ name = "boto3", specifier = "==1.42.68" },
|
||||
{ name = "bs4", specifier = "~=0.0.1" },
|
||||
{ name = "cachetools", specifier = "~=5.3.0" },
|
||||
{ name = "celery", specifier = "~=5.6.2" },
|
||||
@@ -1687,7 +1763,7 @@ requires-dist = [
|
||||
{ name = "gevent", specifier = "~=25.9.1" },
|
||||
{ name = "gmpy2", specifier = "~=2.3.0" },
|
||||
{ name = "google-api-core", specifier = ">=2.19.1" },
|
||||
{ name = "google-api-python-client", specifier = "==2.193.0" },
|
||||
{ name = "google-api-python-client", specifier = "==2.192.0" },
|
||||
{ name = "google-auth", specifier = ">=2.47.0" },
|
||||
{ name = "google-auth-httplib2", specifier = "==0.3.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = ">=1.123.0" },
|
||||
@@ -1700,7 +1776,7 @@ requires-dist = [
|
||||
{ name = "jsonschema", specifier = ">=4.25.1" },
|
||||
{ name = "langfuse", specifier = "~=2.51.3" },
|
||||
{ name = "langsmith", specifier = "~=0.7.16" },
|
||||
{ name = "litellm", specifier = "==1.82.6" },
|
||||
{ name = "litellm", specifier = "==1.82.2" },
|
||||
{ name = "markdown", specifier = "~=3.10.2" },
|
||||
{ name = "mlflow-skinny", specifier = ">=3.0.0" },
|
||||
{ name = "numpy", specifier = "~=1.26.4" },
|
||||
@@ -1739,12 +1815,12 @@ requires-dist = [
|
||||
{ name = "pyyaml", specifier = "~=6.0.1" },
|
||||
{ name = "readabilipy", specifier = "~=0.3.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = "~=7.3.0" },
|
||||
{ name = "resend", specifier = "~=2.26.0" },
|
||||
{ name = "resend", specifier = "~=2.23.0" },
|
||||
{ name = "sendgrid", specifier = "~=6.12.3" },
|
||||
{ name = "sentry-sdk", extras = ["flask"], specifier = "~=2.55.0" },
|
||||
{ name = "sentry-sdk", extras = ["flask"], specifier = "~=2.54.0" },
|
||||
{ name = "sqlalchemy", specifier = "~=2.0.29" },
|
||||
{ name = "sseclient-py", specifier = "~=1.9.0" },
|
||||
{ name = "starlette", specifier = "==1.0.0" },
|
||||
{ name = "starlette", specifier = "==0.52.1" },
|
||||
{ name = "tiktoken", specifier = "~=0.12.0" },
|
||||
{ name = "transformers", specifier = "~=5.3.0" },
|
||||
{ name = "unstructured", extras = ["docx", "epub", "md", "ppt", "pptx"], specifier = "~=0.21.5" },
|
||||
@@ -1770,7 +1846,7 @@ dev = [
|
||||
{ name = "pyrefly", specifier = ">=0.55.0" },
|
||||
{ name = "pytest", specifier = "~=9.0.2" },
|
||||
{ name = "pytest-benchmark", specifier = "~=5.2.3" },
|
||||
{ name = "pytest-cov", specifier = "~=7.1.0" },
|
||||
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
||||
{ name = "pytest-env", specifier = "~=1.6.0" },
|
||||
{ name = "pytest-mock", specifier = "~=3.15.1" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
@@ -1836,7 +1912,7 @@ tools = [
|
||||
{ name = "nltk", specifier = "~=3.9.1" },
|
||||
]
|
||||
vdb = [
|
||||
{ name = "alibabacloud-gpdb20160503", specifier = "~=5.1.0" },
|
||||
{ name = "alibabacloud-gpdb20160503", specifier = "~=3.8.0" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "~=0.4.3" },
|
||||
{ name = "chromadb", specifier = "==0.5.20" },
|
||||
{ name = "clickhouse-connect", specifier = "~=0.14.1" },
|
||||
@@ -2425,7 +2501,7 @@ grpc = [
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.193.0"
|
||||
version = "2.192.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
@@ -2434,9 +2510,9 @@ dependencies = [
|
||||
{ name = "httplib2" },
|
||||
{ name = "uritemplate" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/f4/e14b6815d3b1885328dd209676a3a4c704882743ac94e18ef0093894f5c8/google_api_python_client-2.193.0.tar.gz", hash = "sha256:8f88d16e89d11341e0a8b199cafde0fb7e6b44260dffb88d451577cbd1bb5d33", size = 14281006, upload-time = "2026-03-17T18:25:29.415Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/d8/489052a40935e45b9b5b3d6accc14b041360c1507bdc659c2e1a19aaa3ff/google_api_python_client-2.192.0.tar.gz", hash = "sha256:d48cfa6078fadea788425481b007af33fe0ab6537b78f37da914fb6fc112eb27", size = 14209505, upload-time = "2026-03-05T15:17:01.598Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/6d/fe75167797790a56d17799b75e1129bb93f7ff061efc7b36e9731bd4be2b/google_api_python_client-2.193.0-py3-none-any.whl", hash = "sha256:c42aa324b822109901cfecab5dc4fc3915d35a7b376835233c916c70610322db", size = 14856490, upload-time = "2026-03-17T18:25:26.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/76/ec4128f00fefb9011635ae2abc67d7dacd05c8559378f8f05f0c907c38d8/google_api_python_client-2.192.0-py3-none-any.whl", hash = "sha256:63a57d4457cd97df1d63eb89c5fda03c5a50588dcbc32c0115dd1433c08f4b62", size = 14783267, upload-time = "2026-03-05T15:16:58.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2472,7 +2548,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform"
|
||||
version = "1.142.0"
|
||||
version = "1.141.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docstring-parser" },
|
||||
@@ -2488,9 +2564,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/41/0d/3063a0512d60cf18854a279e00ccb796429545464345ef821cf77cb93d05/google_cloud_aiplatform-1.142.0.tar.gz", hash = "sha256:87b49e002703dc14885093e9b264587db84222bef5f70f5a442d03f41beecdd1", size = 10207993, upload-time = "2026-03-20T22:49:13.797Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/dc/1209c7aab43bd7233cf631165a3b1b4284d22fc7fe7387c66228d07868ab/google_cloud_aiplatform-1.141.0.tar.gz", hash = "sha256:e3b1cdb28865dd862aac9c685dfc5ac076488705aba0a5354016efadcddd59c6", size = 10152688, upload-time = "2026-03-10T22:20:08.692Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8b/f29646d3fa940f0e38cfcc12137f4851856b50d7486a3c05103ebc78d82d/google_cloud_aiplatform-1.142.0-py2.py3-none-any.whl", hash = "sha256:17c91db9b613cbbafb2c36335b123686aeb2b4b8448be5134b565ae07165a39a", size = 8388991, upload-time = "2026-03-20T22:49:10.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/fc/428af69a69ff2e477e7f5e12d227b31fe5790f1a8234aacd54297f49c836/google_cloud_aiplatform-1.141.0-py2.py3-none-any.whl", hash = "sha256:6bd25b4d514c40b8181ca703e1b313ad6d0454ab8006fc9907fb3e9f672f31d1", size = 8358409, upload-time = "2026-03-10T22:20:04.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2543,7 +2619,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-storage"
|
||||
version = "3.10.0"
|
||||
version = "3.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
@@ -2553,9 +2629,9 @@ dependencies = [
|
||||
{ name = "google-resumable-media" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/e3/747759eebc72e420c25903d6bc231d0ceb110b66ac7e6ee3f350417152cd/google_cloud_storage-3.10.0.tar.gz", hash = "sha256:1aeebf097c27d718d84077059a28d7e87f136f3700212215f1ceeae1d1c5d504", size = 17309829, upload-time = "2026-03-18T15:54:11.875Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/b1/4f0798e88285b50dfc60ed3a7de071def538b358db2da468c2e0deecbb40/google_cloud_storage-3.9.0.tar.gz", hash = "sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc", size = 17298544, upload-time = "2026-02-02T13:36:34.119Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/e2/d58442f4daee5babd9255cf492a1f3d114357164072f8339a22a3ad460a2/google_cloud_storage-3.10.0-py3-none-any.whl", hash = "sha256:0072e7783b201e45af78fd9779894cdb6bec2bf922ee932f3fcc16f8bce9b9a3", size = 324382, upload-time = "2026-03-18T15:54:10.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/0b/816a6ae3c9fd096937d2e5f9670558908811d57d59ddf69dd4b83b326fd1/google_cloud_storage-3.9.0-py3-none-any.whl", hash = "sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066", size = 321324, upload-time = "2026-02-02T13:36:32.271Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3384,7 +3460,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.22"
|
||||
version = "0.7.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -3397,9 +3473,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/2a/2d5e6c67396fd228670af278c4da7bd6db2b8d11deaf6f108490b6d3f561/langsmith-0.7.22.tar.gz", hash = "sha256:35bfe795d648b069958280760564632fd28ebc9921c04f3e209c0db6a6c7dc04", size = 1134923, upload-time = "2026-03-19T22:45:23.492Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/79/81041dde07a974e728db7def23c1c7255950b8874102925cc77093bc847d/langsmith-0.7.17.tar.gz", hash = "sha256:6c1b0c2863cdd6636d2a58b8d5b1b80060703d98cac2593f4233e09ac25b5a9d", size = 1132228, upload-time = "2026-03-12T20:41:10.808Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/94/1f5d72655ab6534129540843776c40eff757387b88e798d8b3bf7e313fd4/langsmith-0.7.22-py3-none-any.whl", hash = "sha256:6e9d5148314d74e86748cb9d3898632cad0320c9323d95f70f969e5bc078eee4", size = 359927, upload-time = "2026-03-19T22:45:21.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/31/62689d57f4d25792bd6a3c05c868771899481be2f3e31f9e71d31e1ac4ab/langsmith-0.7.17-py3-none-any.whl", hash = "sha256:cbec10460cb6c6ecc94c18c807be88a9984838144ae6c4693c9f859f378d7d02", size = 359147, upload-time = "2026-03-12T20:41:08.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3447,7 +3523,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.82.6"
|
||||
version = "1.82.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -3463,9 +3539,9 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147, upload-time = "2026-03-22T06:36:00.452Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/12/010a86643f12ac0b004032d5927c260094299a84ed38b5ed20a8f8c7e3c4/litellm-1.82.2.tar.gz", hash = "sha256:f5f4c4049f344a88bf80b2e421bb927807687c99624515d7ff4152d533ec9dcb", size = 17353218, upload-time = "2026-03-13T21:24:24.5Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e4/87e3ca82a8bf6e6bfffb42a539a1350dd6ced1b7169397bd439ba56fde10/litellm-1.82.2-py3-none-any.whl", hash = "sha256:641ed024774fa3d5b4dd9347f0efb1e31fa422fba2a6500aabedee085d1194cb", size = 15524224, upload-time = "2026-03-13T21:24:21.288Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4462,7 +4538,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "opik"
|
||||
version = "1.10.45"
|
||||
version = "1.10.39"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "boto3-stubs", extra = ["bedrock-runtime"] },
|
||||
@@ -4481,9 +4557,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "uuid6" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/17/edea6308347cec62e6828de7c573c596559c502b54fa4f0c88a52e2e81f5/opik-1.10.45.tar.gz", hash = "sha256:d8d8627ba03d12def46965e03d58f611daaf5cf878b3d087c53fe1159788c140", size = 789876, upload-time = "2026-03-20T11:35:12.457Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/0f/b1e00a18cac16b4f36bf6cecc2de962fda810a9416d1159c48f46b81f5ec/opik-1.10.39.tar.gz", hash = "sha256:4d808eb2137070fc5d92a3bed3c3100d9cccfb35f4f0b71ea9990733f293dbb2", size = 780312, upload-time = "2026-03-12T14:08:25.746Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/17/150e9eecfa28cb23f7a0bfe83ae1486a11022b97fe6d12328b455784658d/opik-1.10.45-py3-none-any.whl", hash = "sha256:e8050d9e5e0d92ff587f156eacbdd02099897f39cfe79a98380b6c8ae9906b95", size = 1337714, upload-time = "2026-03-20T11:35:10.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/24/0f4404907a98b4aec4508504570a78a61a3a8b5e451c67326632695ba8e6/opik-1.10.39-py3-none-any.whl", hash = "sha256:a72d735b9afac62e5262294b2f704aca89ec31f5c9beda17504815f7423870c3", size = 1317833, upload-time = "2026-03-12T14:08:23.954Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5199,15 +5275,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-extra-types"
|
||||
version = "2.11.1"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5447,16 +5523,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5892,15 +5968,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "resend"
|
||||
version = "2.26.0"
|
||||
version = "2.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/ff/6a4e5e758fc2145c6a7d8563934d8ee24bf96a0212d7ec7d1af1f155bb74/resend-2.26.0.tar.gz", hash = "sha256:957a6a59dc597ce27fbd6d5383220dd9cc497fab99d4f3d775c8a42a449a569e", size = 36238, upload-time = "2026-03-20T22:49:09.728Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/a3/20003e7d14604fef778bd30c69604df3560a657a95a5c29a9688610759b6/resend-2.23.0.tar.gz", hash = "sha256:df613827dcc40eb1c9de2e5ff600cd4081b89b206537dec8067af1a5016d23c7", size = 31416, upload-time = "2026-02-23T19:01:57.603Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/c2/f88d3299d97aa1d36a923d0846fe185fcf5355ca898c954b2e5a79f090b5/resend-2.26.0-py2.py3-none-any.whl", hash = "sha256:5e25a804a84a68df504f2ade5369ac37e0139e37788a1f20b66c88696595b4bc", size = 57699, upload-time = "2026-03-20T22:49:08.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/35/64df775b8cd95e89798fd7b1b7fcafa975b6b09f559c10c0650e65b33580/resend-2.23.0-py2.py3-none-any.whl", hash = "sha256:eca6d28a1ffd36c1fc489fa83cb6b511f384792c9f07465f7c92d96c8b4d5636", size = 52599, upload-time = "2026-02-23T19:01:55.962Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5981,27 +6057,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.7"
|
||||
version = "0.15.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6040,14 +6116,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "scipy-stubs"
|
||||
version = "1.17.1.3"
|
||||
version = "1.17.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "optype", extra = ["numpy"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/59c6cc3f9970154b9ed6b1aff42a0185cdd60cef54adc0404b9e77972221/scipy_stubs-1.17.1.3.tar.gz", hash = "sha256:5eb87a8d23d726706259b012ebe76a4a96a9ae9e141fc59bf55fc8eac2ed9e0f", size = 392185, upload-time = "2026-03-22T22:11:58.34Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/ab/43f681ffba42f363b7ed6b767fd215d1e26006578214ff8330586a11bf95/scipy_stubs-1.17.1.2.tar.gz", hash = "sha256:2ecadc8c87a3b61aaf7379d6d6b10f1038a829c53b9efe5b174fb97fc8b52237", size = 388354, upload-time = "2026-03-15T22:33:20.449Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/d4/94304532c0a75a55526119043dd44a9bd1541a21e14483cbb54261c527d2/scipy_stubs-1.17.1.3-py3-none-any.whl", hash = "sha256:7b91d3f05aa47da06fbca14eb6c5bb4c28994e9245fd250cc847e375bab31297", size = 597933, upload-time = "2026-03-22T22:11:56.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/0b/ec4fe720c1202d9df729a3e9d9b7e4d2da9f6e7f28bd2877b7d0769f4f75/scipy_stubs-1.17.1.2-py3-none-any.whl", hash = "sha256:f19e8f5273dbe3b7ee6a9554678c3973b9695fa66b91f29206d00830a1536c06", size = 594377, upload-time = "2026-03-15T22:33:18.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6066,15 +6142,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.55.0"
|
||||
version = "2.54.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/b8/285293dc60fc198fffc3fcdbc7c6d4e646e0f74e61461c355d40faa64ceb/sentry_sdk-2.55.0.tar.gz", hash = "sha256:3774c4d8820720ca4101548131b9c162f4c9426eb7f4d24aca453012a7470f69", size = 424505, upload-time = "2026-03-17T14:15:51.707Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/66/20465097782d7e1e742d846407ea7262d338c6e876ddddad38ca8907b38f/sentry_sdk-2.55.0-py2.py3-none-any.whl", hash = "sha256:97026981cb15699394474a196b88503a393cbc58d182ece0d3abe12b9bd978d4", size = 449284, upload-time = "2026-03-17T14:15:49.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -6310,15 +6386,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.0"
|
||||
version = "0.52.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6727,11 +6803,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-cachetools"
|
||||
version = "6.2.0.20260317"
|
||||
version = "6.2.0.20251022"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/7f/16a4d8344c28193a5a74358028c2d2f753f0d9658dd98b9e1967c50045a2/types_cachetools-6.2.0.20260317.tar.gz", hash = "sha256:6d91855bcc944665897c125e720aa3c80aace929b77a64e796343701df4f61c6", size = 9812, upload-time = "2026-03-17T04:06:32.007Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/a8/f9bcc7f1be63af43ef0170a773e2d88817bcc7c9d8769f2228c802826efe/types_cachetools-6.2.0.20251022.tar.gz", hash = "sha256:f1d3c736f0f741e89ec10f0e1b0138625023e21eb33603a930c149e0318c0cef", size = 9608, upload-time = "2025-10-22T03:03:58.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9a/b00b23054934c4d569c19f7278c4fb32746cd36a64a175a216d3073a4713/types_cachetools-6.2.0.20260317-py3-none-any.whl", hash = "sha256:92fa9bc50e4629e31fca67ceb3fb1de71791e314fa16c0a0d2728724dc222c8b", size = 9346, upload-time = "2026-03-17T04:06:31.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/2d/8d821ed80f6c2c5b427f650bf4dc25b80676ed63d03388e4b637d2557107/types_cachetools-6.2.0.20251022-py3-none-any.whl", hash = "sha256:698eb17b8f16b661b90624708b6915f33dbac2d185db499ed57e4997e7962cad", size = 9341, upload-time = "2025-10-22T03:03:57.036Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6775,11 +6851,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-docutils"
|
||||
version = "0.22.3.20260322"
|
||||
version = "0.22.3.20260316"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/27/a7f16b3a2fad0a4ddd85a668319f9a1d0311c4bd9578894f6471c7e6c788/types_docutils-0.22.3.20260316.tar.gz", hash = "sha256:8ef27d565b9831ff094fe2eac75337a74151013e2d21ecabd445c2955f891564", size = 57263, upload-time = "2026-03-16T04:29:12.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/c1f22b7cfc4837d5419e5a2d8702c7d65f03343f866364b71cccd8a73b79/types_docutils-0.22.3.20260316-py3-none-any.whl", hash = "sha256:083c7091b8072c242998ec51da1bf1492f0332387da81c3b085efbf5ca754c7d", size = 91968, upload-time = "2026-03-16T04:29:11.114Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6809,15 +6885,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-gevent"
|
||||
version = "25.9.0.20260322"
|
||||
version = "25.9.0.20251228"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-greenlet" },
|
||||
{ name = "types-psutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/f0/14a99ddcaa69b559fa7cec8c9de880b792bebb0b848ae865d94ea9058533/types_gevent-25.9.0.20260322.tar.gz", hash = "sha256:91257920845762f09753c08aa20fad1743ac13d2de8bcf23f4b8fe967d803732", size = 38241, upload-time = "2026-03-22T04:08:55.213Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/85/c5043c4472f82c8ee3d9e0673eb4093c7d16770a26541a137a53a1d096f6/types_gevent-25.9.0.20251228.tar.gz", hash = "sha256:423ef9891d25c5a3af236c3e9aace4c444c86ff773fe13ef22731bc61d59abef", size = 38063, upload-time = "2025-12-28T03:28:28.651Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/0f/964440b57eb4ddb4aca03479a4093852e1ce79010d1c5967234e6f5d6bd9/types_gevent-25.9.0.20260322-py3-none-any.whl", hash = "sha256:21b3c269b3a20ecb0e4668289c63b97d21694d84a004ab059c1e32ab970eacc2", size = 55500, upload-time = "2026-03-22T04:08:54.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/b7/a2d6b652ab5a26318b68cafd58c46fafb9b15c5313d2d76a70b838febb4b/types_gevent-25.9.0.20251228-py3-none-any.whl", hash = "sha256:e2e225af4fface9241c16044983eb2fc3993f2d13d801f55c2932848649b7f2f", size = 55486, upload-time = "2025-12-28T03:28:27.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6900,11 +6976,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-openpyxl"
|
||||
version = "3.1.5.20260322"
|
||||
version = "3.1.5.20260316"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/bf/15240de4d68192d2a1f385ef2f6f1ecb29b85d2f3791dd2e2d5b980be30f/types_openpyxl-3.1.5.20260322.tar.gz", hash = "sha256:a61d66ebe1e49697853c6db8e0929e1cda2c96755e71fb676ed7fc48dfdcf697", size = 101325, upload-time = "2026-03-22T04:08:40.426Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/38/32f8ee633dd66ca6d52b8853b9fd45dc3869490195a6ed435d5c868b9c2d/types_openpyxl-3.1.5.20260316.tar.gz", hash = "sha256:081dda9427ea1141e5649e3dcf630e7013a4cf254a5862a7e0a3f53c123b7ceb", size = 101318, upload-time = "2026-03-16T04:29:05.004Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/b4/c14191b30bcb266365b124b2bb4e67ecd68425a78ba77ee026f33667daa9/types_openpyxl-3.1.5.20260322-py3-none-any.whl", hash = "sha256:2f515f0b0bbfb04bfb587de34f7522d90b5151a8da7bbbd11ecec4ca40f64238", size = 166102, upload-time = "2026-03-22T04:08:39.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/df/b87ae6226ed7cc84b9e43119c489c7f053a9a25e209e0ebb5d84bc36fa37/types_openpyxl-3.1.5.20260316-py3-none-any.whl", hash = "sha256:38e7e125df520fb7eb72cb1129c9f024eb99ef9564aad2c27f68f080c26bcf2d", size = 166084, upload-time = "2026-03-16T04:29:03.657Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6979,11 +7055,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-python-dateutil"
|
||||
version = "2.9.0.20260323"
|
||||
version = "2.9.0.20260305"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/02/f72df9ef5ffc4f959b83cb80c8aa03eb8718a43e563ecd99ccffe265fa89/types_python_dateutil-2.9.0.20260323.tar.gz", hash = "sha256:a107aef5841db41ace381dbbbd7e4945220fc940f7a72172a0be5a92d9ab7164", size = 16897, upload-time = "2026-03-23T04:15:14.829Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/025c624f347e10476b439a6619a95f1d200250ea88e7ccea6e09e48a7544/types_python_dateutil-2.9.0.20260305.tar.gz", hash = "sha256:389717c9f64d8f769f36d55a01873915b37e97e52ce21928198d210fbd393c8b", size = 16885, upload-time = "2026-03-05T04:00:47.409Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c1/b661838b97453e699a215451f2e22cee750eaaf4ea4619b34bdaf01221a4/types_python_dateutil-2.9.0.20260323-py3-none-any.whl", hash = "sha256:a23a50a07f6eb87e729d4cb0c2eb511c81761eeb3f505db2c1413be94aae8335", size = 18433, upload-time = "2026-03-23T04:15:13.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/77/8c0d1ec97f0d9707ad3d8fa270ab8964e7b31b076d2f641c94987395cc75/types_python_dateutil-2.9.0.20260305-py3-none-any.whl", hash = "sha256:a3be9ca444d38cadabd756cfbb29780d8b338ae2a3020e73c266a83cc3025dd7", size = 18419, upload-time = "2026-03-05T04:00:46.392Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6997,11 +7073,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-pywin32"
|
||||
version = "311.0.0.20260323"
|
||||
version = "311.0.0.20260316"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/cc/f03ddb7412ac2fc2238358b617c2d5919ba96812dff8d3081f3b2754bb83/types_pywin32-311.0.0.20260323.tar.gz", hash = "sha256:2e8dc6a59fedccbc51b241651ce1e8aa58488934f517debf23a9c6d0ff329b4b", size = 332263, upload-time = "2026-03-23T04:15:20.004Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/a8/b4652002a854fcfe5d272872a0ae2d5df0e9dc482e1a6dfb5e97b905b76f/types_pywin32-311.0.0.20260316.tar.gz", hash = "sha256:c136fa489fe6279a13bca167b750414e18d657169b7cf398025856dc363004e8", size = 329956, upload-time = "2026-03-16T04:28:57.366Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/82/d786d5d8b846e3cbe1ee52da8945560b111c789b42c3771b2129b312ab94/types_pywin32-311.0.0.20260323-py3-none-any.whl", hash = "sha256:2f2b03fc72ae77ccbb0ee258da0f181c3a38bd8602f6e332e42587b3b0d5f095", size = 395435, upload-time = "2026-03-23T04:15:18.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/83/704698d93788cf1c2f5e236eae2b37f1b2152ef84dc66b4b83f6c7487b76/types_pywin32-311.0.0.20260316-py3-none-any.whl", hash = "sha256:abb643d50012386d697af49384cc0e6e475eab76b0ca2a7f93d480d0862b3692", size = 392959, upload-time = "2026-03-16T04:28:56.104Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7097,16 +7173,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-tensorflow"
|
||||
version = "2.18.0.20260322"
|
||||
version = "2.18.0.20260224"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "types-protobuf" },
|
||||
{ name = "types-requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/cb/81dfaa2680031a6e087bcdfaf1c0556371098e229aee541e21c81a381065/types_tensorflow-2.18.0.20260322.tar.gz", hash = "sha256:135dc6ca06cc647a002e1bca5c5c99516fde51efd08e46c48a9b1916fc5df07f", size = 259030, upload-time = "2026-03-22T04:09:14.069Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/cb/4914c2fbc1cf8a8d1ef2a7c727bb6f694879be85edeee880a0c88e696af8/types_tensorflow-2.18.0.20260224.tar.gz", hash = "sha256:9b0ccc91c79c88791e43d3f80d6c879748fa0361409c5ff23c7ffe3709be00f2", size = 258786, upload-time = "2026-02-24T04:06:45.613Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/0c/a178061450b640e53577e2c423ad22bf5d3f692f6bfeeb12156d02b531ef/types_tensorflow-2.18.0.20260322-py3-none-any.whl", hash = "sha256:d8776b6daacdb279e64f105f9dcbc0b8e3544b9a2f2eb71ec6ea5955081f65e6", size = 329771, upload-time = "2026-03-22T04:09:12.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/1d/a1c3c60f0eb1a204500dbdc66e3d18aafabc86ad07a8eca71ea05bc8c5a8/types_tensorflow-2.18.0.20260224-py3-none-any.whl", hash = "sha256:6a25f5f41f3e06f28c1f65c6e09f484d4ba0031d6d8df83a39df9d890245eefc", size = 329746, upload-time = "2026-02-24T04:06:44.4Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# ================================================================
|
||||
# Dify Environment Variables Synchronization Script
|
||||
#
|
||||
# Features:
|
||||
# - Synchronize latest settings from .env.example to .env
|
||||
# - Preserve custom settings in existing .env
|
||||
# - Add new environment variables
|
||||
# - Detect removed environment variables
|
||||
# - Create backup files
|
||||
# ================================================================
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ANSI color codes
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
NC = "\033[0m" # No Color
|
||||
|
||||
|
||||
def supports_color() -> bool:
|
||||
"""Return True if the terminal supports ANSI color codes."""
|
||||
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
||||
|
||||
|
||||
def log_info(message: str) -> None:
|
||||
"""Print an informational message in blue."""
|
||||
if supports_color():
|
||||
print(f"{BLUE}[INFO]{NC} {message}")
|
||||
else:
|
||||
print(f"[INFO] {message}")
|
||||
|
||||
|
||||
def log_success(message: str) -> None:
|
||||
"""Print a success message in green."""
|
||||
if supports_color():
|
||||
print(f"{GREEN}[SUCCESS]{NC} {message}")
|
||||
else:
|
||||
print(f"[SUCCESS] {message}")
|
||||
|
||||
|
||||
def log_warning(message: str) -> None:
|
||||
"""Print a warning message in yellow to stderr."""
|
||||
if supports_color():
|
||||
print(f"{YELLOW}[WARNING]{NC} {message}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[WARNING] {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def log_error(message: str) -> None:
|
||||
"""Print an error message in red to stderr."""
|
||||
if supports_color():
|
||||
print(f"{RED}[ERROR]{NC} {message}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[ERROR] {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def parse_env_file(path: Path) -> dict[str, str]:
|
||||
"""Parse an .env-style file and return a mapping of key to raw value.
|
||||
|
||||
Lines that are blank or start with '#' (after optional whitespace) are
|
||||
skipped. Only lines containing '=' are considered variable definitions.
|
||||
|
||||
Args:
|
||||
path: Path to the .env file to parse.
|
||||
|
||||
Returns:
|
||||
Ordered dict mapping variable name to its value string.
|
||||
"""
|
||||
variables: dict[str, str] = {}
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.rstrip("\n")
|
||||
# Skip blank lines and comment lines
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key:
|
||||
variables[key] = value.strip()
|
||||
return variables
|
||||
|
||||
|
||||
def check_files(work_dir: Path) -> None:
|
||||
"""Verify required files exist; create .env from .env.example if absent.
|
||||
|
||||
Args:
|
||||
work_dir: Directory that must contain .env.example (and optionally .env).
|
||||
|
||||
Raises:
|
||||
SystemExit: If .env.example does not exist.
|
||||
"""
|
||||
log_info("Checking required files...")
|
||||
|
||||
example_file = work_dir / ".env.example"
|
||||
env_file = work_dir / ".env"
|
||||
|
||||
if not example_file.exists():
|
||||
log_error(".env.example file not found")
|
||||
sys.exit(1)
|
||||
|
||||
if not env_file.exists():
|
||||
log_warning(".env file does not exist. Creating from .env.example.")
|
||||
shutil.copy2(example_file, env_file)
|
||||
log_success(".env file created")
|
||||
|
||||
log_success("Required files verified")
|
||||
|
||||
|
||||
def create_backup(work_dir: Path) -> None:
|
||||
"""Create a timestamped backup of the current .env file.
|
||||
|
||||
Backups are placed in ``<work_dir>/env-backup/`` with the filename
|
||||
``.env.backup_<YYYYMMDD_HHMMSS>``.
|
||||
|
||||
Args:
|
||||
work_dir: Directory containing the .env file to back up.
|
||||
"""
|
||||
env_file = work_dir / ".env"
|
||||
if not env_file.exists():
|
||||
return
|
||||
|
||||
backup_dir = work_dir / "env-backup"
|
||||
if not backup_dir.exists():
|
||||
backup_dir.mkdir(parents=True)
|
||||
log_info(f"Created backup directory: {backup_dir}")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_file = backup_dir / f".env.backup_{timestamp}"
|
||||
shutil.copy2(env_file, backup_file)
|
||||
log_success(f"Backed up existing .env to {backup_file}")
|
||||
|
||||
|
||||
def analyze_value_change(current: str, recommended: str) -> str | None:
|
||||
"""Analyse what kind of change occurred between two env values.
|
||||
|
||||
Args:
|
||||
current: Value currently set in .env.
|
||||
recommended: Value present in .env.example.
|
||||
|
||||
Returns:
|
||||
A human-readable description string, or None when no analysis applies.
|
||||
"""
|
||||
use_colors = supports_color()
|
||||
|
||||
def colorize(color: str, text: str) -> str:
|
||||
return f"{color}{text}{NC}" if use_colors else text
|
||||
|
||||
if not current and recommended:
|
||||
return colorize(RED, " -> Setting from empty to recommended value")
|
||||
if current and not recommended:
|
||||
return colorize(RED, " -> Recommended value changed to empty")
|
||||
|
||||
# Numeric comparison
|
||||
if re.fullmatch(r"\d+", current) and re.fullmatch(r"\d+", recommended):
|
||||
cur_int, rec_int = int(current), int(recommended)
|
||||
if cur_int < rec_int:
|
||||
return colorize(BLUE, f" -> Numeric increase ({current} < {recommended})")
|
||||
if cur_int > rec_int:
|
||||
return colorize(YELLOW, f" -> Numeric decrease ({current} > {recommended})")
|
||||
return None
|
||||
|
||||
# Boolean comparison
|
||||
if current.lower() in {"true", "false"} and recommended.lower() in {"true", "false"}:
|
||||
if current.lower() != recommended.lower():
|
||||
return colorize(BLUE, f" -> Boolean value change ({current} -> {recommended})")
|
||||
return None
|
||||
|
||||
# URL / endpoint
|
||||
if current.startswith(("http://", "https://")) or recommended.startswith(("http://", "https://")):
|
||||
return colorize(BLUE, " -> URL/endpoint change")
|
||||
|
||||
# File path
|
||||
if current.startswith("/") or recommended.startswith("/"):
|
||||
return colorize(BLUE, " -> File path change")
|
||||
|
||||
# String length
|
||||
if len(current) != len(recommended):
|
||||
return colorize(YELLOW, f" -> String length change ({len(current)} -> {len(recommended)} characters)")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_differences(env_vars: dict[str, str], example_vars: dict[str, str]) -> dict[str, tuple[str, str]]:
|
||||
"""Find variables whose values differ between .env and .env.example.
|
||||
|
||||
Only variables present in *both* files are compared; new or removed
|
||||
variables are handled by separate functions.
|
||||
|
||||
Args:
|
||||
env_vars: Parsed key/value pairs from .env.
|
||||
example_vars: Parsed key/value pairs from .env.example.
|
||||
|
||||
Returns:
|
||||
Mapping of key -> (env_value, example_value) for every key whose
|
||||
values differ.
|
||||
"""
|
||||
log_info("Detecting differences between .env and .env.example...")
|
||||
|
||||
diffs: dict[str, tuple[str, str]] = {}
|
||||
for key, example_value in example_vars.items():
|
||||
if key in env_vars and env_vars[key] != example_value:
|
||||
diffs[key] = (env_vars[key], example_value)
|
||||
|
||||
if diffs:
|
||||
log_success(f"Detected differences in {len(diffs)} environment variables")
|
||||
show_differences_detail(diffs)
|
||||
else:
|
||||
log_info("No differences detected")
|
||||
|
||||
return diffs
|
||||
|
||||
|
||||
def show_differences_detail(diffs: dict[str, tuple[str, str]]) -> None:
|
||||
"""Print a formatted table of differing environment variables.
|
||||
|
||||
Args:
|
||||
diffs: Mapping of key -> (current_value, recommended_value).
|
||||
"""
|
||||
use_colors = supports_color()
|
||||
|
||||
log_info("")
|
||||
log_info("=== Environment Variable Differences ===")
|
||||
|
||||
if not diffs:
|
||||
log_info("No differences to display")
|
||||
return
|
||||
|
||||
for count, (key, (env_value, example_value)) in enumerate(diffs.items(), start=1):
|
||||
print()
|
||||
if use_colors:
|
||||
print(f"{YELLOW}[{count}] {key}{NC}")
|
||||
print(f" {GREEN}.env (current){NC} : {env_value}")
|
||||
print(f" {BLUE}.env.example (recommended){NC} : {example_value}")
|
||||
else:
|
||||
print(f"[{count}] {key}")
|
||||
print(f" .env (current) : {env_value}")
|
||||
print(f" .env.example (recommended) : {example_value}")
|
||||
|
||||
analysis = analyze_value_change(env_value, example_value)
|
||||
if analysis:
|
||||
print(analysis)
|
||||
|
||||
print()
|
||||
log_info("=== Difference Analysis Complete ===")
|
||||
log_info("Note: Consider changing to the recommended values above.")
|
||||
log_info("Current implementation preserves .env values.")
|
||||
print()
|
||||
|
||||
|
||||
def detect_removed_variables(env_vars: dict[str, str], example_vars: dict[str, str]) -> list[str]:
|
||||
"""Identify variables present in .env but absent from .env.example.
|
||||
|
||||
Args:
|
||||
env_vars: Parsed key/value pairs from .env.
|
||||
example_vars: Parsed key/value pairs from .env.example.
|
||||
|
||||
Returns:
|
||||
Sorted list of variable names that no longer appear in .env.example.
|
||||
"""
|
||||
log_info("Detecting removed environment variables...")
|
||||
|
||||
removed = sorted(set(env_vars) - set(example_vars))
|
||||
|
||||
if removed:
|
||||
log_warning("The following environment variables have been removed from .env.example:")
|
||||
for var in removed:
|
||||
log_warning(f" - {var}")
|
||||
log_warning("Consider manually removing these variables from .env")
|
||||
else:
|
||||
log_success("No removed environment variables found")
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tuple[str, str]]) -> None:
|
||||
"""Rewrite .env based on .env.example while preserving custom values.
|
||||
|
||||
The output file follows the exact line structure of .env.example
|
||||
(preserving comments, blank lines, and ordering). For every variable
|
||||
that exists in .env with a different value from the example, the
|
||||
current .env value is kept. Variables that are new in .env.example
|
||||
(not present in .env at all) are added with the example's default.
|
||||
|
||||
Args:
|
||||
work_dir: Directory containing .env and .env.example.
|
||||
env_vars: Parsed key/value pairs from the original .env.
|
||||
diffs: Keys whose .env values differ from .env.example (to preserve).
|
||||
"""
|
||||
log_info("Starting partial synchronization of .env file...")
|
||||
|
||||
example_file = work_dir / ".env.example"
|
||||
new_env_file = work_dir / ".env.new"
|
||||
|
||||
# Keys whose current .env value should override the example default
|
||||
preserved_keys: set[str] = set(diffs.keys())
|
||||
|
||||
preserved_count = 0
|
||||
updated_count = 0
|
||||
|
||||
env_var_pattern = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
||||
|
||||
with example_file.open(encoding="utf-8") as src, new_env_file.open("w", encoding="utf-8") as dst:
|
||||
for line in src:
|
||||
raw_line = line.rstrip("\n")
|
||||
match = env_var_pattern.match(raw_line)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
if key in preserved_keys:
|
||||
# Write the preserved value from .env
|
||||
dst.write(f"{key}={env_vars[key]}\n")
|
||||
log_info(f" Preserved: {key} (.env value)")
|
||||
preserved_count += 1
|
||||
else:
|
||||
# Use the example value (covers new vars and unchanged ones)
|
||||
dst.write(line if line.endswith("\n") else raw_line + "\n")
|
||||
updated_count += 1
|
||||
else:
|
||||
# Blank line, comment, or non-variable line — keep as-is
|
||||
dst.write(line if line.endswith("\n") else raw_line + "\n")
|
||||
|
||||
# Atomically replace the original .env
|
||||
try:
|
||||
new_env_file.replace(work_dir / ".env")
|
||||
except OSError as exc:
|
||||
log_error(f"Failed to replace .env file: {exc}")
|
||||
new_env_file.unlink(missing_ok=True)
|
||||
sys.exit(1)
|
||||
|
||||
log_success("Successfully created new .env file")
|
||||
log_success("Partial synchronization of .env file completed")
|
||||
log_info(f" Preserved .env values: {preserved_count}")
|
||||
log_info(f" Updated to .env.example values: {updated_count}")
|
||||
|
||||
|
||||
def show_statistics(work_dir: Path) -> None:
|
||||
"""Print a summary of variable counts from both env files.
|
||||
|
||||
Args:
|
||||
work_dir: Directory containing .env and .env.example.
|
||||
"""
|
||||
log_info("Synchronization statistics:")
|
||||
|
||||
example_file = work_dir / ".env.example"
|
||||
env_file = work_dir / ".env"
|
||||
|
||||
example_count = len(parse_env_file(example_file)) if example_file.exists() else 0
|
||||
env_count = len(parse_env_file(env_file)) if env_file.exists() else 0
|
||||
|
||||
log_info(f" .env.example environment variables: {example_count}")
|
||||
log_info(f" .env environment variables: {env_count}")
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
"""Build and return the CLI argument parser.
|
||||
|
||||
Returns:
|
||||
Configured ArgumentParser instance.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="dify-env-sync",
|
||||
description=(
|
||||
"Synchronize .env with .env.example: add new variables, "
|
||||
"preserve custom values, and report removed variables."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" # Run from the docker/ directory (default)\n"
|
||||
" python dify-env-sync.py\n\n"
|
||||
" # Specify a custom working directory\n"
|
||||
" python dify-env-sync.py --dir /path/to/docker\n"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dir",
|
||||
metavar="DIRECTORY",
|
||||
default=".",
|
||||
help="Working directory containing .env and .env.example (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip creating a timestamped backup of the existing .env file",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Orchestrate the complete environment variable synchronization process."""
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
work_dir = Path(args.dir).resolve()
|
||||
|
||||
log_info("=== Dify Environment Variables Synchronization Script ===")
|
||||
log_info(f"Execution started: {datetime.now()}")
|
||||
log_info(f"Working directory: {work_dir}")
|
||||
|
||||
# 1. Verify prerequisites
|
||||
check_files(work_dir)
|
||||
|
||||
# 2. Backup existing .env
|
||||
if not args.no_backup:
|
||||
create_backup(work_dir)
|
||||
|
||||
# 3. Parse both files
|
||||
env_vars = parse_env_file(work_dir / ".env")
|
||||
example_vars = parse_env_file(work_dir / ".env.example")
|
||||
|
||||
# 4. Report differences (values that changed in the example)
|
||||
diffs = detect_differences(env_vars, example_vars)
|
||||
|
||||
# 5. Report variables removed from the example
|
||||
detect_removed_variables(env_vars, example_vars)
|
||||
|
||||
# 6. Rewrite .env
|
||||
sync_env_file(work_dir, env_vars, diffs)
|
||||
|
||||
# 7. Print summary statistics
|
||||
show_statistics(work_dir)
|
||||
|
||||
log_success("=== Synchronization process completed successfully ===")
|
||||
log_info(f"Execution finished: {datetime.now()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -28,7 +28,6 @@ http_access deny manager
|
||||
http_access allow localhost
|
||||
include /etc/squid/conf.d/*.conf
|
||||
http_access deny all
|
||||
tcp_outgoing_address 0.0.0.0
|
||||
|
||||
################################## Proxy Server ################################
|
||||
http_port ${HTTP_PORT}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
# EU AI Act Compliance Guide for Dify Deployers
|
||||
|
||||
Dify is an LLMOps platform for building RAG pipelines, agents, and AI workflows. If you deploy Dify in the EU — whether self-hosted or using a cloud provider — the EU AI Act applies to your deployment. This guide covers what the regulation requires and how Dify's architecture maps to those requirements.
|
||||
|
||||
## Is your system in scope?
|
||||
|
||||
The detailed obligations in Articles 12, 13, and 14 only apply to **high-risk AI systems** as defined in Annex III of the EU AI Act. A Dify application is high-risk if it is used for:
|
||||
|
||||
- **Recruitment and HR** — screening candidates, evaluating employee performance, allocating tasks
|
||||
- **Credit scoring and insurance** — assessing creditworthiness or setting premiums
|
||||
- **Law enforcement** — profiling, criminal risk assessment, border control
|
||||
- **Critical infrastructure** — managing energy, water, transport, or telecommunications systems
|
||||
- **Education assessment** — grading students, determining admissions
|
||||
- **Essential public services** — evaluating eligibility for benefits, housing, or emergency services
|
||||
|
||||
Most Dify deployments (customer-facing chatbots, internal knowledge bases, content generation workflows) are **not** high-risk. If your Dify application does not fall into one of the categories above:
|
||||
|
||||
- **Article 50** (end-user transparency) still applies if users interact with your application directly. See the [Article 50 section](#article-50-end-user-transparency) below.
|
||||
- **GDPR** still applies if you process personal data. See the [GDPR section](#gdpr-considerations) below.
|
||||
- The high-risk obligations (Articles 9-15) are less likely to apply, but risk classification is context-dependent. **Do not self-classify without legal review.** Focus on Article 50 (transparency) and GDPR (data protection) as your baseline obligations.
|
||||
|
||||
If you are unsure whether your use case qualifies as high-risk, consult a qualified legal professional before proceeding.
|
||||
|
||||
## Self-hosted vs cloud: different compliance profiles
|
||||
|
||||
| Deployment | Your role | Dify's role | Who handles compliance? |
|
||||
|-----------|----------|-------------|------------------------|
|
||||
| **Self-hosted** | Provider and deployer | Framework provider — obligations under Article 25 apply only if Dify is placed on the market or put into service as part of a complete AI system bearing its name or trademark | You |
|
||||
| **Dify Cloud** | Deployer | Provider and processor | Shared — Dify handles SOC 2 and GDPR for the platform; you handle AI Act obligations for your specific use case |
|
||||
|
||||
Dify Cloud already has SOC 2 Type II and GDPR compliance for the platform itself. But the EU AI Act adds obligations specific to AI systems that SOC 2 does not cover: risk classification, technical documentation, transparency, and human oversight.
|
||||
|
||||
## Supported providers and services
|
||||
|
||||
Dify integrates with a broad range of AI providers and data stores. The following are the key ones relevant to compliance:
|
||||
|
||||
- **AI providers:** HuggingFace (core), plus integrations with OpenAI, Anthropic, Google, and 100+ models via provider plugins
|
||||
- **Model identifiers include:** gpt-4o, gpt-3.5-turbo, claude-3-opus, gemini-2.5-flash, whisper-1, and others
|
||||
- **Vector database connections:** Extensive RAG infrastructure supporting numerous vector stores
|
||||
|
||||
Dify's plugin architecture means actual provider usage depends on your configuration. Document which providers and models are active in your deployment.
|
||||
|
||||
## Data flow diagram
|
||||
|
||||
A typical Dify RAG deployment:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
USER((User)) -->|query| DIFY[Dify Platform]
|
||||
DIFY -->|prompts| LLM([LLM Provider])
|
||||
LLM -->|responses| DIFY
|
||||
DIFY -->|documents| EMBED([Embedding Model])
|
||||
EMBED -->|vectors| DIFY
|
||||
DIFY -->|store/retrieve| VS[(Vector Store)]
|
||||
DIFY -->|knowledge| KB[(Knowledge Base)]
|
||||
DIFY -->|response| USER
|
||||
|
||||
classDef processor fill:#60a5fa,stroke:#1e40af,color:#000
|
||||
classDef controller fill:#4ade80,stroke:#166534,color:#000
|
||||
classDef app fill:#a78bfa,stroke:#5b21b6,color:#000
|
||||
classDef user fill:#f472b6,stroke:#be185d,color:#000
|
||||
|
||||
class USER user
|
||||
class DIFY app
|
||||
class LLM processor
|
||||
class EMBED processor
|
||||
class VS controller
|
||||
class KB controller
|
||||
```
|
||||
|
||||
**GDPR roles** (providers are typically processors for customer-submitted data, but the exact role depends on each provider's terms of service and processing purpose; deployers should review each provider's DPA):
|
||||
- **Cloud LLM providers (OpenAI, Anthropic, Google)** typically act as processors — requires DPA.
|
||||
- **Cloud embedding services** typically act as processors — requires DPA.
|
||||
- **Self-hosted vector stores (Weaviate, Qdrant, pgvector):** Your organization remains the controller — no third-party transfer.
|
||||
- **Cloud vector stores (Pinecone, Zilliz Cloud)** typically act as processors — requires DPA.
|
||||
- **Knowledge base documents:** Your organization is the controller — stored in your infrastructure.
|
||||
|
||||
## Article 11: Technical documentation
|
||||
|
||||
High-risk systems need Annex IV documentation. For Dify deployments, key sections include:
|
||||
|
||||
| Section | What Dify provides | What you must document |
|
||||
|---------|-------------------|----------------------|
|
||||
| General description | Platform capabilities, supported models | Your specific use case, intended users, deployment context |
|
||||
| Development process | Dify's architecture, plugin system | Your RAG pipeline design, prompt engineering, knowledge base curation |
|
||||
| Monitoring | Dify's built-in logging and analytics | Your monitoring plan, alert thresholds, incident response |
|
||||
| Performance metrics | Dify's evaluation features | Your accuracy benchmarks, quality thresholds, bias testing |
|
||||
| Risk management | — | Risk assessment for your specific use case |
|
||||
|
||||
Some sections can be derived from Dify's architecture and your deployment configuration, as shown in the table above. The remaining sections require your input.
|
||||
|
||||
## Article 12: Record-keeping
|
||||
|
||||
Dify's built-in logging covers several Article 12 requirements:
|
||||
|
||||
| Requirement | Dify Feature | Status |
|
||||
|------------|-------------|--------|
|
||||
| Conversation logs | Full conversation history with timestamps | **Covered** |
|
||||
| Model tracking | Model name recorded per interaction | **Covered** |
|
||||
| Token usage | Token counts per message | **Covered** |
|
||||
| Cost tracking | Cost per conversation (if provider reports it) | **Partial** |
|
||||
| Document retrieval | RAG source documents logged | **Covered** |
|
||||
| User identification | User session tracking | **Covered** |
|
||||
| Error logging | Failed generation logs | **Covered** |
|
||||
| Data retention | Configurable | **Your responsibility** |
|
||||
|
||||
**Retention periods:** The required retention period depends on your role under the Act. Article 18 requires **providers** of high-risk systems to retain logs and technical documentation for **10 years** after market placement. Article 26(6) requires **deployers** to retain logs for at least **6 months**. If you self-host Dify and have substantially modified the system, you may be classified as a provider rather than a deployer. Confirm the applicable retention period with legal counsel.
|
||||
|
||||
## Article 13: Transparency to deployers
|
||||
|
||||
Article 13 requires providers of high-risk AI systems to supply deployers with the information needed to understand and operate the system correctly. This is a **documentation obligation**, not a logging obligation. For Dify deployments, this means the upstream LLM and embedding providers must give you:
|
||||
|
||||
- Instructions for use, including intended purpose and known limitations
|
||||
- Accuracy metrics and performance benchmarks
|
||||
- Known or foreseeable risks and residual risks after mitigation
|
||||
- Technical specifications: input/output formats, training data characteristics, model architecture details
|
||||
|
||||
As a deployer, collect model cards, system documentation, and accuracy reports from each AI provider your Dify application uses. Maintain these as part of your Annex IV technical documentation.
|
||||
|
||||
Dify's platform features provide **supporting evidence** that can inform Article 13 documentation, but they do not satisfy Article 13 on their own:
|
||||
- **Source attribution** — Dify's RAG citation feature shows which documents informed the response, supporting deployer-side auditing
|
||||
- **Model identification** — Dify logs which LLM model generates responses, providing evidence for system documentation
|
||||
- **Conversation logs** — execution history helps compile performance and behavior evidence
|
||||
|
||||
You must independently produce system documentation covering how your specific Dify deployment uses AI, its intended purpose, performance characteristics, and residual risks.
|
||||
|
||||
## Article 50: End-user transparency
|
||||
|
||||
Article 50 requires deployers to inform end users that they are interacting with an AI system. This is a separate obligation from Article 13 and applies even to limited-risk systems.
|
||||
|
||||
For Dify applications serving end users:
|
||||
|
||||
1. **Disclose AI involvement** — tell users they are interacting with an AI system
|
||||
2. **AI-generated content labeling** — identify AI-generated content as such (e.g., clear labeling in the UI)
|
||||
|
||||
Dify's "citation" feature also supports end-user transparency by showing users which knowledge base documents informed the answer.
|
||||
|
||||
> **Note:** Article 50 applies to chatbots and systems interacting directly with natural persons. It has a separate scope from the high-risk designation under Annex III — it applies even to limited-risk systems.
|
||||
|
||||
## Article 14: Human oversight
|
||||
|
||||
Article 14 requires that high-risk AI systems be designed so that natural persons can effectively oversee them. Dify provides **automated technical safeguards** that support human oversight, but they are not a substitute for it:
|
||||
|
||||
| Dify Feature | What It Does | Oversight Role |
|
||||
|-------------|-------------|----------------|
|
||||
| Annotation/feedback system | Human review of AI outputs | **Direct oversight** — humans evaluate and correct AI responses |
|
||||
| Content moderation | Built-in filtering before responses reach users | **Automated safeguard** — reduces harmful outputs but does not replace human judgment on edge cases |
|
||||
| Rate limiting | Controls on API usage | **Automated safeguard** — bounds system behavior, supports overseer's ability to maintain control |
|
||||
| Workflow control | Insert human review steps between AI generation and output | **Oversight enabler** — allows building approval gates into the pipeline |
|
||||
|
||||
These automated controls are necessary building blocks, but Article 14 compliance requires **human oversight procedures** on top of them:
|
||||
- **Escalation procedures** — define what happens when moderation triggers or edge cases arise (who is notified, what action is taken)
|
||||
- **Human review pipeline** — for high-stakes decisions, route AI outputs to a qualified person before they take effect
|
||||
- **Override mechanism** — a human must be able to halt AI responses or override the system's output
|
||||
- **Competence requirements** — the human overseer must understand the system's capabilities, limitations, and the context of its outputs
|
||||
|
||||
### Recommended pattern
|
||||
|
||||
For high-risk use cases (HR, legal, medical), configure your Dify workflow to require human approval before the AI response is delivered to the end user or acted upon.
|
||||
|
||||
## Knowledge base compliance
|
||||
|
||||
Dify's knowledge base feature has specific compliance implications:
|
||||
|
||||
1. **Data provenance:** Document where your knowledge base documents come from. Article 10 requires data governance for training data; knowledge bases are analogous.
|
||||
2. **Update tracking:** When you add, remove, or update documents in the knowledge base, log the change. The AI system's behavior changes with its knowledge base.
|
||||
3. **PII in documents:** If knowledge base documents contain personal data, GDPR applies to the entire RAG pipeline. Implement access controls and consider PII redaction before indexing.
|
||||
4. **Copyright:** Ensure you have the right to use the documents in your knowledge base for AI-assisted generation.
|
||||
|
||||
## GDPR considerations
|
||||
|
||||
1. **Legal basis** (Article 6): Document why AI processing of user queries is necessary
|
||||
2. **Data Processing Agreements** (Article 28): Required for each cloud LLM and embedding provider
|
||||
3. **Data minimization:** Only include necessary context in prompts; avoid sending entire documents when a relevant excerpt suffices
|
||||
4. **Right to erasure:** If a user requests deletion, ensure their conversations are removed from Dify's logs AND any vector store entries derived from their data
|
||||
5. **Cross-border transfers:** Providers based outside the EEA — including US-based providers (OpenAI, Anthropic), and any other non-EEA providers you route to — require Standard Contractual Clauses (SCCs) or equivalent safeguards under Chapter V of the GDPR. Review each provider's transfer mechanism individually.
|
||||
|
||||
## Resources
|
||||
|
||||
- [EU AI Act full text](https://artificialintelligenceact.eu/)
|
||||
- [Dify documentation](https://docs.dify.ai/)
|
||||
- [Dify SOC 2 compliance](https://dify.ai/trust)
|
||||
|
||||
---
|
||||
|
||||
*This is not legal advice. Consult a qualified professional for compliance decisions.*
|
||||
@@ -35,7 +35,7 @@ vi.mock('../ExternalApiSelect', () => ({
|
||||
<span data-testid="select-value">{value}</span>
|
||||
<span data-testid="select-items-count">{items.length}</span>
|
||||
{items.map((item: MockSelectItem) => (
|
||||
<button type="button" key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
|
||||
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Banner } from '@/models/app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -6,11 +5,6 @@ import { BannerItem } from '../banner-item'
|
||||
|
||||
const mockScrollTo = vi.fn()
|
||||
const mockSlideNodes = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/carousel', () => ({
|
||||
useCarousel: () => ({
|
||||
@@ -54,34 +48,19 @@ class MockResizeObserver {
|
||||
}
|
||||
}
|
||||
|
||||
const renderBannerItem = (
|
||||
banner: Banner = createMockBanner(),
|
||||
props: Partial<ComponentProps<typeof BannerItem>> = {},
|
||||
) => {
|
||||
return render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
sort={1}
|
||||
language="en-US"
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BannerItem', () => {
|
||||
let mockWindowOpen: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}]) // 3 slides
|
||||
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1400,
|
||||
value: 1400, // Above RESPONSIVE_BREAKPOINT (1200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,51 +73,81 @@ describe('BannerItem', () => {
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('renders banner category', () => {
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Featured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders banner title', () => {
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders banner description', () => {
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test banner description text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders banner image with correct src and alt', () => {
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const image = screen.getByRole('img')
|
||||
expect(image).toHaveAttribute('src', 'https://example.com/image.png')
|
||||
expect(image).toHaveAttribute('alt', 'Test Banner Title')
|
||||
})
|
||||
|
||||
it('renders view more text', () => {
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('click handling', () => {
|
||||
it('opens banner link in new tab and tracks click when clicked', () => {
|
||||
it('opens banner link in new tab when clicked', () => {
|
||||
const banner = createMockBanner({ link: 'https://test-link.com' })
|
||||
renderBannerItem(banner, { sort: 2, language: 'zh-Hans', accountId: 'account-123' })
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(bannerElement!)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
|
||||
banner_id: 'banner-1',
|
||||
title: 'Test Banner Title',
|
||||
sort: 2,
|
||||
link: 'https://test-link.com',
|
||||
page: 'explore',
|
||||
language: 'zh-Hans',
|
||||
account_id: 'account-123',
|
||||
event_time: expect.any(Number),
|
||||
}))
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://test-link.com',
|
||||
'_blank',
|
||||
@@ -146,16 +155,18 @@ describe('BannerItem', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks click even when banner has no link', () => {
|
||||
it('does not open window when banner has no link', () => {
|
||||
const banner = createMockBanner({ link: '' })
|
||||
renderBannerItem(banner)
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(bannerElement!)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
|
||||
link: '',
|
||||
}))
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -163,13 +174,28 @@ describe('BannerItem', () => {
|
||||
describe('slide indicators', () => {
|
||||
it('renders correct number of indicator buttons', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
renderBannerItem()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders indicator buttons with correct numbers', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('01')).toBeInTheDocument()
|
||||
expect(screen.getByText('02')).toBeInTheDocument()
|
||||
expect(screen.getByText('03')).toBeInTheDocument()
|
||||
@@ -177,7 +203,13 @@ describe('BannerItem', () => {
|
||||
|
||||
it('calls scrollTo when indicator is clicked', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const secondIndicator = screen.getByText('02').closest('button')
|
||||
fireEvent.click(secondIndicator!)
|
||||
@@ -187,39 +219,81 @@ describe('BannerItem', () => {
|
||||
|
||||
it('renders no indicators when no slides', () => {
|
||||
mockSlideNodes.mockReturnValue([])
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPaused prop', () => {
|
||||
it('defaults isPaused to false', () => {
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts isPaused prop', () => {
|
||||
renderBannerItem(createMockBanner(), { isPaused: true })
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
isPaused={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('responsive behavior', () => {
|
||||
it('sets up ResizeObserver on mount', () => {
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockResizeObserverObserve).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds resize event listener on mount', () => {
|
||||
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
addEventListenerSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes resize event listener on unmount', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const { unmount } = renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
const { unmount } = render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
unmount()
|
||||
|
||||
@@ -234,7 +308,14 @@ describe('BannerItem', () => {
|
||||
value: 1000,
|
||||
})
|
||||
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -245,7 +326,14 @@ describe('BannerItem', () => {
|
||||
value: 800,
|
||||
})
|
||||
|
||||
renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -260,8 +348,13 @@ describe('BannerItem', () => {
|
||||
'img-src': 'https://example.com/img.png',
|
||||
},
|
||||
} as Partial<Banner>)
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem(banner)
|
||||
expect(screen.getByText('Very Long Category Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -274,8 +367,13 @@ describe('BannerItem', () => {
|
||||
'img-src': 'https://example.com/img.png',
|
||||
},
|
||||
} as Partial<Banner>)
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem(banner)
|
||||
const titleElement = screen.getByText('A Very Long Title That Should Be Truncated Eventually')
|
||||
expect(titleElement).toHaveClass('line-clamp-2')
|
||||
})
|
||||
@@ -289,8 +387,13 @@ describe('BannerItem', () => {
|
||||
'img-src': 'https://example.com/img.png',
|
||||
},
|
||||
} as Partial<Banner>)
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem(banner)
|
||||
const descriptionElement = screen.getByText(/A very long description/)
|
||||
expect(descriptionElement).toHaveClass('line-clamp-4')
|
||||
})
|
||||
@@ -299,26 +402,56 @@ describe('BannerItem', () => {
|
||||
describe('slide calculation', () => {
|
||||
it('calculates next index correctly for first slide', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
renderBannerItem()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('handles single slide case', () => {
|
||||
mockSlideNodes.mockReturnValue([{}])
|
||||
renderBannerItem()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapper styling', () => {
|
||||
it('has cursor-pointer class', () => {
|
||||
const { container } = renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
const { container } = render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('has rounded-2xl class', () => {
|
||||
const { container } = renderBannerItem()
|
||||
const banner = createMockBanner()
|
||||
const { container } = render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
@@ -6,8 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Banner from '../banner'
|
||||
|
||||
const mockUseGetBanners = vi.fn()
|
||||
const mockUseSelector = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetBanners: (...args: unknown[]) => mockUseGetBanners(...args),
|
||||
@@ -17,14 +15,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (...args: unknown[]) => mockUseSelector(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/carousel', () => ({
|
||||
Carousel: Object.assign(
|
||||
({ children, onMouseEnter, onMouseLeave, className }: {
|
||||
@@ -64,12 +54,9 @@ vi.mock('@/app/components/base/carousel', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../banner-item', () => ({
|
||||
BannerItem: ({ banner, autoplayDelay, isPaused, sort, language, accountId }: {
|
||||
BannerItem: ({ banner, autoplayDelay, isPaused }: {
|
||||
banner: BannerType
|
||||
autoplayDelay: number
|
||||
sort: number
|
||||
language: string
|
||||
accountId?: string
|
||||
isPaused?: boolean
|
||||
}) => (
|
||||
<div
|
||||
@@ -77,9 +64,6 @@ vi.mock('../banner-item', () => ({
|
||||
data-banner-id={banner.id}
|
||||
data-autoplay-delay={autoplayDelay}
|
||||
data-is-paused={isPaused}
|
||||
data-sort={sort}
|
||||
data-language={language}
|
||||
data-account-id={accountId}
|
||||
>
|
||||
BannerItem:
|
||||
{' '}
|
||||
@@ -103,11 +87,6 @@ const createMockBanner = (id: string, status: string = 'enabled', title: string
|
||||
describe('Banner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockUseSelector.mockImplementation(selector => selector({
|
||||
userProfile: {
|
||||
id: 'account-123',
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -256,59 +235,6 @@ describe('Banner', () => {
|
||||
|
||||
expect(screen.getByTestId('carousel')).toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('tracks enabled banner impressions with expected payload', () => {
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: [
|
||||
createMockBanner('1', 'enabled', 'Enabled Banner 1'),
|
||||
createMockBanner('2', 'disabled', 'Disabled Banner'),
|
||||
createMockBanner('3', 'enabled', 'Enabled Banner 2'),
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(2)
|
||||
expect(mockTrackEvent).toHaveBeenNthCalledWith(1, 'explore_banner_impression', expect.objectContaining({
|
||||
banner_id: '1',
|
||||
title: 'Enabled Banner 1',
|
||||
sort: 1,
|
||||
link: 'https://example.com',
|
||||
page: 'explore',
|
||||
language: 'en-US',
|
||||
account_id: 'account-123',
|
||||
event_time: expect.any(Number),
|
||||
}))
|
||||
expect(mockTrackEvent).toHaveBeenNthCalledWith(2, 'explore_banner_impression', expect.objectContaining({
|
||||
banner_id: '3',
|
||||
title: 'Enabled Banner 2',
|
||||
sort: 2,
|
||||
link: 'https://example.com',
|
||||
page: 'explore',
|
||||
language: 'en-US',
|
||||
account_id: 'account-123',
|
||||
event_time: expect.any(Number),
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not track impressions when account id is unavailable', () => {
|
||||
mockUseSelector.mockImplementation(selector => selector({
|
||||
userProfile: {
|
||||
id: '',
|
||||
},
|
||||
}))
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: [createMockBanner('1', 'enabled', 'Enabled Banner 1')],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hover behavior', () => {
|
||||
@@ -509,25 +435,8 @@ describe('Banner', () => {
|
||||
|
||||
const bannerItems = screen.getAllByTestId('banner-item')
|
||||
expect(bannerItems[0]).toHaveAttribute('data-banner-id', '1')
|
||||
expect(bannerItems[0]).toHaveAttribute('data-sort', '1')
|
||||
expect(bannerItems[1]).toHaveAttribute('data-banner-id', '2')
|
||||
expect(bannerItems[1]).toHaveAttribute('data-sort', '2')
|
||||
expect(bannerItems[2]).toHaveAttribute('data-banner-id', '3')
|
||||
expect(bannerItems[2]).toHaveAttribute('data-sort', '3')
|
||||
})
|
||||
|
||||
it('passes tracking context to banner item', () => {
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: [createMockBanner('1', 'enabled', 'Banner 1')],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
const bannerItem = screen.getByTestId('banner-item')
|
||||
expect(bannerItem).toHaveAttribute('data-language', 'en-US')
|
||||
expect(bannerItem).toHaveAttribute('data-account-id', 'account-123')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Banner } from '@/models/app'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { useCarousel } from '@/app/components/base/carousel'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { IndicatorButton } from './indicator-button'
|
||||
@@ -12,9 +11,6 @@ import { IndicatorButton } from './indicator-button'
|
||||
type BannerItemProps = {
|
||||
banner: Banner
|
||||
autoplayDelay: number
|
||||
sort: number
|
||||
language: string
|
||||
accountId?: string
|
||||
isPaused?: boolean
|
||||
}
|
||||
|
||||
@@ -24,14 +20,7 @@ const INDICATOR_WIDTH = 20
|
||||
const INDICATOR_GAP = 8
|
||||
const MIN_VIEW_MORE_WIDTH = 480
|
||||
|
||||
export const BannerItem: FC<BannerItemProps> = ({
|
||||
banner,
|
||||
autoplayDelay,
|
||||
sort,
|
||||
language,
|
||||
accountId,
|
||||
isPaused = false,
|
||||
}) => {
|
||||
export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPaused = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const { api, selectedIndex } = useCarousel()
|
||||
const { category, title, description, 'img-src': imgSrc } = banner.content
|
||||
@@ -102,21 +91,9 @@ export const BannerItem: FC<BannerItemProps> = ({
|
||||
|
||||
const handleBannerClick = useCallback(() => {
|
||||
incrementResetKey()
|
||||
|
||||
trackEvent('explore_banner_click', {
|
||||
banner_id: banner.id,
|
||||
title: banner.content.title,
|
||||
sort,
|
||||
link: banner.link,
|
||||
page: 'explore',
|
||||
language,
|
||||
account_id: accountId,
|
||||
event_time: Date.now(),
|
||||
})
|
||||
|
||||
if (banner.link)
|
||||
window.open(banner.link, '_blank', 'noopener,noreferrer')
|
||||
}, [accountId, banner, incrementResetKey, language, sort])
|
||||
}, [banner.link, incrementResetKey])
|
||||
|
||||
const handleIndicatorClick = useCallback((index: number) => {
|
||||
incrementResetKey()
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { Carousel } from '@/app/components/base/carousel'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useGetBanners } from '@/service/use-explore'
|
||||
import Loading from '../../base/loading'
|
||||
@@ -25,11 +23,9 @@ const LoadingState: FC = () => (
|
||||
const Banner: FC = () => {
|
||||
const locale = useLocale()
|
||||
const { data: banners, isLoading, isError } = useGetBanners(locale)
|
||||
const accountId = useSelector(s => s.userProfile.id)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const trackedBannerIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const enabledBanners = useMemo(
|
||||
() => banners?.filter(banner => banner.status === 'enabled') ?? [],
|
||||
@@ -60,28 +56,6 @@ const Banner: FC = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId)
|
||||
return
|
||||
|
||||
enabledBanners.forEach((banner, index) => {
|
||||
if (trackedBannerIdsRef.current.has(banner.id))
|
||||
return
|
||||
|
||||
trackEvent('explore_banner_impression', {
|
||||
banner_id: banner.id,
|
||||
title: banner.content.title,
|
||||
sort: index + 1,
|
||||
link: banner.link,
|
||||
page: 'explore',
|
||||
language: locale,
|
||||
account_id: accountId,
|
||||
event_time: Date.now(),
|
||||
})
|
||||
trackedBannerIdsRef.current.add(banner.id)
|
||||
})
|
||||
}, [accountId, enabledBanners, locale])
|
||||
|
||||
if (isLoading)
|
||||
return <LoadingState />
|
||||
|
||||
@@ -103,15 +77,12 @@ const Banner: FC = () => {
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Carousel.Content>
|
||||
{enabledBanners.map((banner, index) => (
|
||||
{enabledBanners.map(banner => (
|
||||
<Carousel.Item key={banner.id}>
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={AUTOPLAY_DELAY}
|
||||
isPaused={isPaused}
|
||||
sort={index + 1}
|
||||
language={locale}
|
||||
accountId={accountId}
|
||||
/>
|
||||
</Carousel.Item>
|
||||
))}
|
||||
|
||||
@@ -78,7 +78,6 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({
|
||||
<Modal
|
||||
isShow
|
||||
onClose={noop}
|
||||
wrapperClassName="z-[1002]"
|
||||
className="!w-[640px] !max-w-none !p-8 !pb-6"
|
||||
>
|
||||
<div className="mb-2 text-xl font-semibold text-text-primary">
|
||||
|
||||
@@ -69,7 +69,7 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1002] w-[calc(100%-32px)] max-w-[576px]">
|
||||
<PortalToFollowElemContent className="z-[102] w-[calc(100%-32px)] max-w-[576px]">
|
||||
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||
<div className="p-1">
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-2">
|
||||
|
||||
@@ -84,7 +84,7 @@ const Configure = ({
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<PortalToFollowElemContent className="z-[61]">
|
||||
<div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg">
|
||||
{
|
||||
!!canOAuth && (
|
||||
@@ -104,7 +104,7 @@ const Configure = ({
|
||||
}
|
||||
{
|
||||
!!canApiKey && !!canOAuth && (
|
||||
<div className="flex h-4 items-center p-2 text-text-quaternary system-2xs-medium-uppercase">
|
||||
<div className="system-2xs-medium-uppercase flex h-4 items-center p-2 text-text-quaternary">
|
||||
<div className="mr-2 h-[1px] grow bg-gradient-to-l from-[rgba(16,24,40,0.08)]" />
|
||||
OR
|
||||
<div className="ml-2 h-[1px] grow bg-gradient-to-r from-[rgba(16,24,40,0.08)]" />
|
||||
|
||||
@@ -39,7 +39,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiHome9Line className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="text-text-secondary system-sm-semibold">{t('auth.setDefault', { ns: 'plugin' })}</div>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('auth.setDefault', { ns: 'plugin' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -51,7 +51,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiEditLine className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="text-text-secondary system-sm-semibold">{t('operation.rename', { ns: 'common' })}</div>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('operation.rename', { ns: 'common' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -66,7 +66,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiEqualizer2Line className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="text-text-secondary system-sm-semibold">{t('operation.edit', { ns: 'common' })}</div>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('operation.edit', { ns: 'common' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -81,7 +81,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiStickyNoteAddLine className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="mb-1 text-text-secondary system-sm-semibold">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
|
||||
<div className="system-sm-semibold mb-1 text-text-secondary">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -98,7 +98,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiDeleteBinLine className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="text-text-secondary system-sm-semibold">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@ const Operator = ({
|
||||
items={items}
|
||||
secondItems={secondItems}
|
||||
onSelect={handleSelect}
|
||||
popupClassName="z-[1002]"
|
||||
popupClassName="z-[61]"
|
||||
triggerProps={{
|
||||
size: 'l',
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
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.each([
|
||||
undefined,
|
||||
null,
|
||||
{},
|
||||
{ data: undefined },
|
||||
{ data: null },
|
||||
{ data: '' },
|
||||
{ data: 0 },
|
||||
{ data: false },
|
||||
{ data: 'http' },
|
||||
{ data: 'internal' },
|
||||
{ data: 'unknown' },
|
||||
])('should cover connection data branch: %s', async (val) => {
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
|
||||
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Trigger handleAuthAgain with these values
|
||||
const workspaceItem = getWorkspaceItem('Workspace 1')
|
||||
const actionBtn = within(workspaceItem).getByRole('button')
|
||||
fireEvent.click(actionBtn)
|
||||
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
|
||||
fireEvent.click(authAgainBtn)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDataSourceIntegrates, useNotionConnection } from '@/service/use-common'
|
||||
import Panel from '../panel'
|
||||
import { DataSourceType } from '../panel/types'
|
||||
|
||||
const Icon: FC<{
|
||||
src: string
|
||||
name: string
|
||||
className: string
|
||||
}> = ({ src, name, className }) => {
|
||||
return (
|
||||
<NotionIcon
|
||||
src={src}
|
||||
name={name}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
type Props = {
|
||||
workspaces?: TDataSourceNotion[]
|
||||
}
|
||||
|
||||
const DataSourceNotion: FC<Props> = ({
|
||||
workspaces,
|
||||
}) => {
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const [canConnectNotion, setCanConnectNotion] = useState(false)
|
||||
const { data: integrates } = useDataSourceIntegrates({
|
||||
initialData: workspaces ? { data: workspaces } : undefined,
|
||||
})
|
||||
const { data } = useNotionConnection(canConnectNotion)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolvedWorkspaces = integrates?.data ?? []
|
||||
const connected = !!resolvedWorkspaces.length
|
||||
|
||||
const handleConnectNotion = () => {
|
||||
if (!isCurrentWorkspaceManager)
|
||||
return
|
||||
|
||||
setCanConnectNotion(true)
|
||||
}
|
||||
|
||||
const handleAuthAgain = () => {
|
||||
if (data?.data)
|
||||
window.location.href = data.data
|
||||
else
|
||||
setCanConnectNotion(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (data && 'data' in data) {
|
||||
if (data.data && typeof data.data === 'string' && data.data.startsWith('http')) {
|
||||
window.location.href = data.data
|
||||
}
|
||||
else if (data.data === 'internal') {
|
||||
Toast.notify({
|
||||
type: 'info',
|
||||
message: t('dataSource.notion.integratedAlert', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [data, t])
|
||||
|
||||
return (
|
||||
<Panel
|
||||
type={DataSourceType.notion}
|
||||
isConfigured={connected}
|
||||
onConfigure={handleConnectNotion}
|
||||
readOnly={!isCurrentWorkspaceManager}
|
||||
isSupportList
|
||||
configuredList={resolvedWorkspaces.map(workspace => ({
|
||||
id: workspace.id,
|
||||
logo: ({ className }: { className: string }) => (
|
||||
<Icon
|
||||
src={workspace.source_info.workspace_icon!}
|
||||
name={workspace.source_info.workspace_name}
|
||||
className={className}
|
||||
/>
|
||||
),
|
||||
name: workspace.source_info.workspace_name,
|
||||
isActive: workspace.is_bound,
|
||||
notionConfig: {
|
||||
total: workspace.source_info.total || 0,
|
||||
},
|
||||
}))}
|
||||
onRemove={noop} // handled in operation/index.tsx
|
||||
notionActions={{
|
||||
onChangeAuthorizedPage: handleAuthAgain,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(DataSourceNotion)
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiLoopLeftLine,
|
||||
RiMoreFill,
|
||||
RiStickyNoteAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { Fragment } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { syncDataSourceNotion, updateDataSourceNotionAction } from '@/service/common'
|
||||
import { useInvalidDataSourceIntegrates } from '@/service/use-common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type OperateProps = {
|
||||
payload: {
|
||||
id: string
|
||||
total: number
|
||||
}
|
||||
onAuthAgain: () => void
|
||||
}
|
||||
export default function Operate({
|
||||
payload,
|
||||
onAuthAgain,
|
||||
}: OperateProps) {
|
||||
const { t } = useTranslation()
|
||||
const invalidateDataSourceIntegrates = useInvalidDataSourceIntegrates()
|
||||
|
||||
const updateIntegrates = () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.success', { ns: 'common' }),
|
||||
})
|
||||
invalidateDataSourceIntegrates()
|
||||
}
|
||||
const handleSync = async () => {
|
||||
await syncDataSourceNotion({ url: `/oauth/data-source/notion/${payload.id}/sync` })
|
||||
updateIntegrates()
|
||||
}
|
||||
const handleRemove = async () => {
|
||||
await updateDataSourceNotionAction({ url: `/data-source/integrates/${payload.id}/disable` })
|
||||
updateIntegrates()
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={cn('flex h-8 w-8 items-center justify-center rounded-lg hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className="h-4 w-4 text-text-secondary" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems className="absolute right-0 top-9 w-60 max-w-80 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem>
|
||||
<div
|
||||
className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover"
|
||||
onClick={onAuthAgain}
|
||||
>
|
||||
<RiStickyNoteAddLine className="mr-2 mt-[2px] h-4 w-4 text-text-tertiary" />
|
||||
<div>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{payload.total}
|
||||
{' '}
|
||||
{t('dataSource.notion.pagesAuthorized', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleSync}>
|
||||
<RiLoopLeftLine className="mr-2 mt-[2px] h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('dataSource.notion.sync', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<MenuItem>
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleRemove}>
|
||||
<RiDeleteBinLine className="mr-2 mt-[2px] h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('dataSource.notion.remove', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
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))
|
||||
})
|
||||
|
||||
it('should show encryption info and external link in the modal', async () => {
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Verify PKCS1_OAEP link exists
|
||||
const pkcsLink = screen.getByText('PKCS1_OAEP')
|
||||
expect(pkcsLink.closest('a')).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
|
||||
|
||||
// Verify the Jina Reader external link
|
||||
const jinaLink = screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })
|
||||
expect(jinaLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should return early when save is clicked while already saving (isSaving guard)', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange - a save that never resolves so isSaving stays true
|
||||
let resolveFirst: (value: { result: 'success' }) => void
|
||||
const neverResolves = new Promise<{ result: 'success' }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(neverResolves)
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
|
||||
await user.type(apiKeyInput, 'valid-key')
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
|
||||
// First click - starts saving, isSaving becomes true
|
||||
await user.click(saveBtn)
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second click using fireEvent bypasses disabled check - hits isSaving guard
|
||||
const { fireEvent: fe } = await import('@testing-library/react')
|
||||
fe.click(saveBtn)
|
||||
// Still only called once because isSaving=true returns early
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Cleanup
|
||||
resolveFirst!({ result: 'success' })
|
||||
await waitFor(() => expect(mockOnSaved).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Firecrawl Save Flow', () => {
|
||||
it('should re-fetch sources after saving Firecrawl configuration', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
|
||||
vi.mocked(fetchDataSources).mockClear()
|
||||
|
||||
// Act - fill in required API key field and save
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'test-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(fetchDataSources).toHaveBeenCalled()
|
||||
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel Flow', () => {
|
||||
it('should close watercrawl modal when cancel is clicked', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.waterCrawl)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
// Assert - modal closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close jina reader modal when cancel is clicked', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.jinaReader)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
// Assert - modal closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { FirecrawlConfig } from '@/models/common'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Field from '@/app/components/datasets/create/website/base/field'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
|
||||
type Props = {
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const I18N_PREFIX = 'firecrawl'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.firecrawl.dev'
|
||||
|
||||
const ConfigFirecrawlModal: FC<Props> = ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [config, setConfig] = useState<FirecrawlConfig>({
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
})
|
||||
|
||||
const handleConfigChange = useCallback((key: string) => {
|
||||
return (value: string | number) => {
|
||||
setConfig(prev => ({ ...prev, [key]: value as string }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving)
|
||||
return
|
||||
let errorMsg = ''
|
||||
if (config.base_url && !((config.base_url.startsWith('http://') || config.base_url.startsWith('https://'))))
|
||||
errorMsg = t('errorMsg.urlError', { ns: 'common' })
|
||||
if (!errorMsg) {
|
||||
if (!config.api_key) {
|
||||
errorMsg = t('errorMsg.fieldRequired', {
|
||||
ns: 'common',
|
||||
field: 'API Key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
const postData = {
|
||||
category: 'website',
|
||||
provider: 'firecrawl',
|
||||
credentials: {
|
||||
auth_type: 'bearer',
|
||||
config: {
|
||||
api_key: config.api_key,
|
||||
base_url: config.base_url || DEFAULT_BASE_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await createDataSourceApiKeyBinding(postData)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.success', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
}, [config.api_key, config.base_url, onSaved, t, isSaving])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className="z-[60] h-full w-full">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background-overlay">
|
||||
<div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl">
|
||||
<div className="px-8 pt-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`${I18N_PREFIX}.configFirecrawl`, { ns: 'datasetCreation' })}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label="API Key"
|
||||
labelClassName="!text-sm"
|
||||
isRequired
|
||||
value={config.api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={t(`${I18N_PREFIX}.apiKeyPlaceholder`, { ns: 'datasetCreation' })!}
|
||||
/>
|
||||
<Field
|
||||
label="Base URL"
|
||||
labelClassName="!text-sm"
|
||||
value={config.base_url}
|
||||
onChange={handleConfigChange('base_url')}
|
||||
placeholder={DEFAULT_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-8 flex h-8 items-center justify-between">
|
||||
<a className="flex items-center space-x-1 text-xs font-normal leading-[18px] text-text-accent" target="_blank" href="https://www.firecrawl.dev/account">
|
||||
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`, { ns: 'datasetCreation' })}</span>
|
||||
<LinkExternal02 className="h-3 w-3" />
|
||||
</a>
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="large"
|
||||
className="mr-2"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-[0.5px] border-t-divider-regular">
|
||||
<div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary">
|
||||
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
|
||||
{t('modelProvider.encrypted.front', { ns: 'common' })}
|
||||
<a
|
||||
className="mx-1 text-text-accent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('modelProvider.encrypted.back', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigFirecrawlModal)
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Field from '@/app/components/datasets/create/website/base/field'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
|
||||
type Props = {
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const I18N_PREFIX = 'jinaReader'
|
||||
|
||||
const ConfigJinaReaderModal: FC<Props> = ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving)
|
||||
return
|
||||
let errorMsg = ''
|
||||
if (!errorMsg) {
|
||||
if (!apiKey) {
|
||||
errorMsg = t('errorMsg.fieldRequired', {
|
||||
ns: 'common',
|
||||
field: 'API Key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
const postData = {
|
||||
category: 'website',
|
||||
provider: DataSourceProvider.jinaReader,
|
||||
credentials: {
|
||||
auth_type: 'bearer',
|
||||
config: {
|
||||
api_key: apiKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await createDataSourceApiKeyBinding(postData)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.success', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
}, [apiKey, onSaved, t, isSaving])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className="z-[60] h-full w-full">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background-overlay">
|
||||
<div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl">
|
||||
<div className="px-8 pt-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`${I18N_PREFIX}.configJinaReader`, { ns: 'datasetCreation' })}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label="API Key"
|
||||
labelClassName="!text-sm"
|
||||
isRequired
|
||||
value={apiKey}
|
||||
onChange={(value: string | number) => setApiKey(value as string)}
|
||||
placeholder={t(`${I18N_PREFIX}.apiKeyPlaceholder`, { ns: 'datasetCreation' })!}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-8 flex h-8 items-center justify-between">
|
||||
<a className="flex items-center space-x-1 text-xs font-normal leading-[18px] text-text-accent" target="_blank" href="https://jina.ai/reader/">
|
||||
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`, { ns: 'datasetCreation' })}</span>
|
||||
<LinkExternal02 className="h-3 w-3" />
|
||||
</a>
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="large"
|
||||
className="mr-2"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-[0.5px] border-t-divider-regular">
|
||||
<div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary">
|
||||
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
|
||||
{t('modelProvider.encrypted.front', { ns: 'common' })}
|
||||
<a
|
||||
className="mx-1 text-text-accent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('modelProvider.encrypted.back', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigJinaReaderModal)
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { WatercrawlConfig } from '@/models/common'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Field from '@/app/components/datasets/create/website/base/field'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
|
||||
type Props = {
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const I18N_PREFIX = 'watercrawl'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://app.watercrawl.dev'
|
||||
|
||||
const ConfigWatercrawlModal: FC<Props> = ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [config, setConfig] = useState<WatercrawlConfig>({
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
})
|
||||
|
||||
const handleConfigChange = useCallback((key: string) => {
|
||||
return (value: string | number) => {
|
||||
setConfig(prev => ({ ...prev, [key]: value as string }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving)
|
||||
return
|
||||
let errorMsg = ''
|
||||
if (config.base_url && !((config.base_url.startsWith('http://') || config.base_url.startsWith('https://'))))
|
||||
errorMsg = t('errorMsg.urlError', { ns: 'common' })
|
||||
if (!errorMsg) {
|
||||
if (!config.api_key) {
|
||||
errorMsg = t('errorMsg.fieldRequired', {
|
||||
ns: 'common',
|
||||
field: 'API Key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
const postData = {
|
||||
category: 'website',
|
||||
provider: 'watercrawl',
|
||||
credentials: {
|
||||
auth_type: 'x-api-key',
|
||||
config: {
|
||||
api_key: config.api_key,
|
||||
base_url: config.base_url || DEFAULT_BASE_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await createDataSourceApiKeyBinding(postData)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.success', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
}, [config.api_key, config.base_url, onSaved, t, isSaving])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className="z-[60] h-full w-full">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background-overlay">
|
||||
<div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl">
|
||||
<div className="px-8 pt-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`${I18N_PREFIX}.configWatercrawl`, { ns: 'datasetCreation' })}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label="API Key"
|
||||
labelClassName="!text-sm"
|
||||
isRequired
|
||||
value={config.api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={t(`${I18N_PREFIX}.apiKeyPlaceholder`, { ns: 'datasetCreation' })!}
|
||||
/>
|
||||
<Field
|
||||
label="Base URL"
|
||||
labelClassName="!text-sm"
|
||||
value={config.base_url}
|
||||
onChange={handleConfigChange('base_url')}
|
||||
placeholder={DEFAULT_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-8 flex h-8 items-center justify-between">
|
||||
<a className="flex items-center space-x-1 text-xs font-normal leading-[18px] text-text-accent" target="_blank" href="https://app.watercrawl.dev/">
|
||||
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`, { ns: 'datasetCreation' })}</span>
|
||||
<LinkExternal02 className="h-3 w-3" />
|
||||
</a>
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="large"
|
||||
className="mr-2"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-[0.5px] border-t-divider-regular">
|
||||
<div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary">
|
||||
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
|
||||
{t('modelProvider.encrypted.front', { ns: 'common' })}
|
||||
<a
|
||||
className="mx-1 text-text-accent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('modelProvider.encrypted.back', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigWatercrawlModal)
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceItem } from '@/models/common'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import s from '@/app/components/datasets/create/website/index.module.css'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { fetchDataSources, removeDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Panel from '../panel'
|
||||
|
||||
import { DataSourceType } from '../panel/types'
|
||||
import ConfigFirecrawlModal from './config-firecrawl-modal'
|
||||
import ConfigJinaReaderModal from './config-jina-reader-modal'
|
||||
import ConfigWatercrawlModal from './config-watercrawl-modal'
|
||||
|
||||
type Props = {
|
||||
provider: DataSourceProvider
|
||||
}
|
||||
|
||||
const DataSourceWebsite: FC<Props> = ({ provider }) => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const [sources, setSources] = useState<DataSourceItem[]>([])
|
||||
const checkSetApiKey = useCallback(async () => {
|
||||
const res = await fetchDataSources() as any
|
||||
const list = res.sources
|
||||
setSources(list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
checkSetApiKey()
|
||||
}, [])
|
||||
|
||||
const [configTarget, setConfigTarget] = useState<DataSourceProvider | null>(null)
|
||||
const showConfig = useCallback((provider: DataSourceProvider) => {
|
||||
setConfigTarget(provider)
|
||||
}, [setConfigTarget])
|
||||
|
||||
const hideConfig = useCallback(() => {
|
||||
setConfigTarget(null)
|
||||
}, [setConfigTarget])
|
||||
|
||||
const handleAdded = useCallback(() => {
|
||||
checkSetApiKey()
|
||||
hideConfig()
|
||||
}, [checkSetApiKey, hideConfig])
|
||||
|
||||
const getIdByProvider = (provider: DataSourceProvider): string | undefined => {
|
||||
const source = sources.find(item => item.provider === provider)
|
||||
return source?.id
|
||||
}
|
||||
|
||||
const getProviderName = (provider: DataSourceProvider): string => {
|
||||
if (provider === DataSourceProvider.fireCrawl)
|
||||
return 'Firecrawl'
|
||||
|
||||
if (provider === DataSourceProvider.waterCrawl)
|
||||
return 'WaterCrawl'
|
||||
|
||||
return 'Jina Reader'
|
||||
}
|
||||
|
||||
const handleRemove = useCallback((provider: DataSourceProvider) => {
|
||||
return async () => {
|
||||
const dataSourceId = getIdByProvider(provider)
|
||||
if (dataSourceId) {
|
||||
await removeDataSourceApiKeyBinding(dataSourceId)
|
||||
setSources(sources.filter(item => item.provider !== provider))
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.remove', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [sources, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
type={DataSourceType.website}
|
||||
provider={provider}
|
||||
isConfigured={sources.find(item => item.provider === provider) !== undefined}
|
||||
onConfigure={() => showConfig(provider)}
|
||||
readOnly={!isCurrentWorkspaceManager}
|
||||
configuredList={sources.filter(item => item.provider === provider).map(item => ({
|
||||
id: item.id,
|
||||
logo: ({ className }: { className: string }) => {
|
||||
if (item.provider === DataSourceProvider.fireCrawl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'ml-3 flex h-5 w-5 items-center justify-center rounded border border-divider-subtle !bg-background-default text-xs font-medium text-text-tertiary')}
|
||||
>
|
||||
🔥
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.provider === DataSourceProvider.waterCrawl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'ml-3 flex h-5 w-5 items-center justify-center rounded border border-divider-subtle !bg-background-default text-xs font-medium text-text-tertiary')}
|
||||
>
|
||||
<span className={s.watercrawlLogo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'ml-3 flex h-5 w-5 items-center justify-center rounded border border-divider-subtle !bg-background-default text-xs font-medium text-text-tertiary')}
|
||||
>
|
||||
<span className={s.jinaLogo} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
name: getProviderName(item.provider),
|
||||
isActive: true,
|
||||
}))}
|
||||
onRemove={handleRemove(provider)}
|
||||
/>
|
||||
{configTarget === DataSourceProvider.fireCrawl && (
|
||||
<ConfigFirecrawlModal onSaved={handleAdded} onCancel={hideConfig} />
|
||||
)}
|
||||
{configTarget === DataSourceProvider.waterCrawl && (
|
||||
<ConfigWatercrawlModal onSaved={handleAdded} onCancel={hideConfig} />
|
||||
)}
|
||||
{configTarget === DataSourceProvider.jinaReader && (
|
||||
<ConfigJinaReaderModal onSaved={handleAdded} onCancel={hideConfig} />
|
||||
)}
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(DataSourceWebsite)
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Indicator from '../../../indicator'
|
||||
import Operate from '../data-source-notion/operate'
|
||||
import s from './style.module.css'
|
||||
import { DataSourceType } from './types'
|
||||
|
||||
export type ConfigItemType = {
|
||||
id: string
|
||||
logo: any
|
||||
name: string
|
||||
isActive: boolean
|
||||
notionConfig?: {
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type: DataSourceType
|
||||
payload: ConfigItemType
|
||||
onRemove: () => void
|
||||
notionActions?: {
|
||||
onChangeAuthorizedPage: () => void
|
||||
}
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
const ConfigItem: FC<Props> = ({
|
||||
type,
|
||||
payload,
|
||||
onRemove,
|
||||
notionActions,
|
||||
readOnly,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isNotion = type === DataSourceType.notion
|
||||
const isWebsite = type === DataSourceType.website
|
||||
const onChangeAuthorizedPage = notionActions?.onChangeAuthorizedPage || noop
|
||||
|
||||
return (
|
||||
<div className={cn(s['workspace-item'], 'mb-1 flex items-center rounded-lg bg-components-panel-on-panel-item-bg py-1 pr-1')} key={payload.id}>
|
||||
<payload.logo className="ml-3 mr-1.5" />
|
||||
<div className="system-sm-medium grow truncate py-[7px] text-text-secondary" title={payload.name}>{payload.name}</div>
|
||||
{
|
||||
payload.isActive
|
||||
? <Indicator className="mr-[6px] shrink-0" color="green" />
|
||||
: <Indicator className="mr-[6px] shrink-0" color="yellow" />
|
||||
}
|
||||
<div className={`system-xs-semibold-uppercase mr-3 shrink-0 ${payload.isActive ? 'text-util-colors-green-green-600' : 'text-util-colors-warning-warning-600'}`}>
|
||||
{
|
||||
payload.isActive
|
||||
? t(isNotion ? 'dataSource.notion.connected' : 'dataSource.website.active', { ns: 'common' })
|
||||
: t(isNotion ? 'dataSource.notion.disconnected' : 'dataSource.website.inactive', { ns: 'common' })
|
||||
}
|
||||
</div>
|
||||
<div className="mr-2 h-3 w-[1px] bg-divider-regular" />
|
||||
{isNotion && (
|
||||
<Operate
|
||||
payload={{
|
||||
id: payload.id,
|
||||
total: payload.notionConfig?.total || 0,
|
||||
}}
|
||||
onAuthAgain={onChangeAuthorizedPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
isWebsite && !readOnly && (
|
||||
<div className="cursor-pointer rounded-md p-2 text-text-tertiary hover:bg-state-base-hover" onClick={onRemove}>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigItem)
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ConfigItemType } from './config-item'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ConfigItem from './config-item'
|
||||
import s from './style.module.css'
|
||||
import { DataSourceType } from './types'
|
||||
|
||||
type Props = {
|
||||
type: DataSourceType
|
||||
provider?: DataSourceProvider
|
||||
isConfigured: boolean
|
||||
onConfigure: () => void
|
||||
readOnly: boolean
|
||||
isSupportList?: boolean
|
||||
configuredList: ConfigItemType[]
|
||||
onRemove: () => void
|
||||
notionActions?: {
|
||||
onChangeAuthorizedPage: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const Panel: FC<Props> = ({
|
||||
type,
|
||||
provider,
|
||||
isConfigured,
|
||||
onConfigure,
|
||||
readOnly,
|
||||
configuredList,
|
||||
isSupportList,
|
||||
onRemove,
|
||||
notionActions,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isNotion = type === DataSourceType.notion
|
||||
const isWebsite = type === DataSourceType.website
|
||||
|
||||
const getProviderName = (): string => {
|
||||
if (provider === DataSourceProvider.fireCrawl)
|
||||
return '🔥 Firecrawl'
|
||||
if (provider === DataSourceProvider.waterCrawl)
|
||||
return 'WaterCrawl'
|
||||
return 'Jina Reader'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded-xl bg-background-section-burn">
|
||||
<div className="flex items-center px-3 py-[9px]">
|
||||
<div className={cn(s[`${type}-icon`], 'mr-3 h-8 w-8 rounded-lg border border-divider-subtle !bg-background-default')} />
|
||||
<div className="grow">
|
||||
<div className="flex h-5 items-center">
|
||||
<div className="text-sm font-medium text-text-primary">{t(`dataSource.${type}.title`, { ns: 'common' })}</div>
|
||||
{isWebsite && (
|
||||
<div className="ml-1 rounded-md bg-components-badge-white-to-dark px-1.5 text-xs font-medium leading-[18px] text-text-secondary">
|
||||
<span className="text-text-tertiary">{t('dataSource.website.with', { ns: 'common' })}</span>
|
||||
{' '}
|
||||
{getProviderName()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
!isConfigured && (
|
||||
<div className="system-xs-medium text-text-tertiary">
|
||||
{t(`dataSource.${type}.description`, { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{isNotion && (
|
||||
<>
|
||||
{
|
||||
isConfigured
|
||||
? (
|
||||
<Button
|
||||
disabled={readOnly}
|
||||
className="ml-3"
|
||||
onClick={onConfigure}
|
||||
>
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{isSupportList && (
|
||||
<div
|
||||
className={
|
||||
`system-sm-medium flex min-h-7 items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-1 text-components-button-secondary-accent-text
|
||||
${!readOnly ? 'cursor-pointer' : 'cursor-default opacity-50 grayscale'}`
|
||||
}
|
||||
onClick={onConfigure}
|
||||
>
|
||||
<RiAddLine className="mr-[5px] h-4 w-4 text-components-button-secondary-accent-text" />
|
||||
{t('dataSource.connect', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWebsite && !isConfigured && (
|
||||
<div
|
||||
className={
|
||||
`ml-3 flex h-7 items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg
|
||||
px-3 text-xs font-medium text-components-button-secondary-accent-text
|
||||
${!readOnly ? 'cursor-pointer' : 'cursor-default opacity-50 grayscale'}`
|
||||
}
|
||||
onClick={!readOnly ? onConfigure : undefined}
|
||||
>
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
{
|
||||
isConfigured && (
|
||||
<>
|
||||
<div className="flex h-[18px] items-center px-3">
|
||||
<div className="system-xs-medium text-text-tertiary">
|
||||
{isNotion ? t('dataSource.notion.connectedWorkspace', { ns: 'common' }) : t('dataSource.website.configuredCrawlers', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="ml-3 grow border-t border-t-divider-subtle" />
|
||||
</div>
|
||||
<div className="px-3 pb-3 pt-2">
|
||||
{
|
||||
configuredList.map(item => (
|
||||
<ConfigItem
|
||||
key={item.id}
|
||||
type={type}
|
||||
payload={item}
|
||||
onRemove={onRemove}
|
||||
notionActions={notionActions}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Panel)
|
||||
@@ -0,0 +1,17 @@
|
||||
.notion-icon {
|
||||
background: #ffffff url(../../../assets/notion.svg) center center no-repeat;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.website-icon {
|
||||
background: #ffffff url(../../../../datasets/create/assets/web.svg) center center no-repeat;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.workspace-item {
|
||||
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
|
||||
.workspace-item:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum DataSourceType {
|
||||
notion = 'notion',
|
||||
website = 'website',
|
||||
}
|
||||
@@ -2,15 +2,11 @@ import type { InvitationResponse } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { inviteMember } from '@/service/common'
|
||||
import InviteModal from '../index'
|
||||
|
||||
const { mockToastError } = vi.hoisted(() => ({
|
||||
mockToastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: vi.fn(),
|
||||
useProviderContext: vi.fn(() => ({
|
||||
@@ -18,11 +14,6 @@ vi.mock('@/context/provider-context', () => ({
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/service/common')
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
@@ -46,6 +37,7 @@ describe('InviteModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnSend = vi.fn()
|
||||
const mockRefreshLicenseLimit = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -57,11 +49,10 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
const renderModal = (isEmailSetup = true) => render(
|
||||
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />,
|
||||
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
const fillEmails = (value: string) => {
|
||||
fireEvent.change(screen.getByTestId('mock-email-input'), { target: { value } })
|
||||
}
|
||||
|
||||
it('should render invite modal content', async () => {
|
||||
renderModal()
|
||||
@@ -77,8 +68,12 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
it('should enable send button after entering an email', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderModal()
|
||||
fillEmails('user@example.com')
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
|
||||
})
|
||||
@@ -89,7 +84,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -108,7 +103,8 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -120,6 +116,8 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
it('should keep send button disabled when license limit is exceeded', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
@@ -127,7 +125,8 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
|
||||
})
|
||||
@@ -145,11 +144,15 @@ describe('InviteModal', () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
// Use an email that passes basic validation but fails our strict regex (needs 2+ char TLD)
|
||||
fillEmails('invalid@email.c')
|
||||
await user.type(input, 'invalid@email.c')
|
||||
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('common.members.emailInvalid')
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.members.emailInvalid',
|
||||
})
|
||||
expect(inviteMember).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -157,7 +160,8 @@ describe('InviteModal', () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
expect(screen.getByText('user@example.com')).toBeInTheDocument()
|
||||
|
||||
@@ -199,7 +203,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -210,6 +214,8 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
it('should show destructive text color when used size exceeds limit', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
@@ -217,7 +223,8 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
|
||||
const counter = screen.getByText('11')
|
||||
@@ -234,7 +241,8 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||
|
||||
@@ -256,6 +264,8 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||
@@ -264,7 +274,8 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
// isLimitExceeded=true → button is disabled, cannot submit
|
||||
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||
@@ -282,7 +293,8 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||
|
||||
@@ -308,9 +320,11 @@ describe('InviteModal', () => {
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
} as unknown as Parameters<typeof selector>[0]))
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
|
||||
fillEmails('user@example.com')
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
// isLimited=false → no destructive color
|
||||
const counter = screen.getByText('1')
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.modal {
|
||||
padding: 24px 32px !important;
|
||||
width: 400px !important;
|
||||
}
|
||||
|
||||
.emailsInput {
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
.emailBackground {
|
||||
background-color: white !important;
|
||||
}
|
||||
@@ -2,17 +2,20 @@
|
||||
import type { RoleKey } from './role-selector'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactMultiEmail } from 'react-multi-email'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { emailRegex } from '@/config'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { inviteMember } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from './index.module.css'
|
||||
import RoleSelector from './role-selector'
|
||||
import 'react-multi-email/dist/style.css'
|
||||
|
||||
@@ -31,6 +34,7 @@ const InviteModal = ({
|
||||
const licenseLimit = useProviderContextSelector(s => s.licenseLimit)
|
||||
const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit)
|
||||
const [emails, setEmails] = useState<string[]>([])
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [isLimitExceeded, setIsLimitExceeded] = useState(false)
|
||||
const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0)
|
||||
@@ -70,28 +74,21 @@ const InviteModal = ({
|
||||
catch { }
|
||||
}
|
||||
else {
|
||||
toast.error(t('members.emailInvalid', { ns: 'common' }))
|
||||
notify({ type: 'error', message: t('members.emailInvalid', { ns: 'common' }) })
|
||||
}
|
||||
setIsSubmitted()
|
||||
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
|
||||
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[400px] overflow-visible px-8 py-6"
|
||||
>
|
||||
<DialogCloseButton data-testid="invite-modal-close" className="right-8 top-6" />
|
||||
<div className="mb-2 pr-8">
|
||||
<DialogTitle className="text-xl font-semibold text-text-primary">
|
||||
{t('members.inviteTeamMember', { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
<div className={cn(s.wrap)}>
|
||||
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="text-xl font-semibold text-text-primary">{t('members.inviteTeamMember', { ns: 'common' })}</div>
|
||||
<div
|
||||
data-testid="invite-modal-close"
|
||||
className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 text-[13px] text-text-tertiary">{t('members.inviteTeamMemberTip', { ns: 'common' })}</div>
|
||||
{!isEmailSetup && (
|
||||
@@ -155,8 +152,8 @@ const InviteModal = ({
|
||||
{t('members.sendInvite', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@@ -24,111 +25,115 @@ export type RoleSelectorProps = {
|
||||
|
||||
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { datasetOperatorEnabled } = useProviderContext()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<PopoverTrigger
|
||||
data-testid="role-selector-trigger"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
|
||||
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"
|
||||
>
|
||||
<div className="p-1">
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="block"
|
||||
>
|
||||
<div
|
||||
data-testid="role-option-normal"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('normal')
|
||||
setOpen(false)
|
||||
}}
|
||||
data-testid="role-selector-trigger"
|
||||
className={cn('flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
|
||||
{value === 'normal' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
|
||||
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
|
||||
</div>
|
||||
<div
|
||||
data-testid="role-option-editor"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('editor')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
|
||||
{value === 'editor' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="role-option-admin"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('admin')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
|
||||
{value === 'admin' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{datasetOperatorEnabled && (
|
||||
<div
|
||||
data-testid="role-option-dataset_operator"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('dataset_operator')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
|
||||
{value === 'dataset_operator' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<div className="relative w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||
<div className="p-1">
|
||||
<div
|
||||
data-testid="role-option-normal"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('normal')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
|
||||
{value === 'normal' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="role-option-editor"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('editor')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
|
||||
{value === 'editor' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="role-option-admin"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('admin')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
|
||||
{value === 'admin' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{datasetOperatorEnabled && (
|
||||
<div
|
||||
data-testid="role-option-dataset_operator"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('dataset_operator')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
|
||||
{value === 'dataset_operator' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import s from './index.module.css'
|
||||
import InvitationLink from './invitation-link'
|
||||
|
||||
export type SuccessInvitationResult = Extract<InvitationResult, { status: 'success' }>
|
||||
@@ -24,18 +29,8 @@ const InvitedModal = ({
|
||||
const failedInvitationResults = useMemo<FailedInvitationResult[]>(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvitationResult[], [invitationResults])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[480px] p-8"
|
||||
>
|
||||
<DialogCloseButton className="right-8 top-8" />
|
||||
<div className={s.wrap}>
|
||||
<Modal isShow onClose={noop} className={s.modal}>
|
||||
<div className="mb-3 flex justify-between">
|
||||
<div className="
|
||||
flex h-12 w-12 items-center justify-center rounded-xl
|
||||
@@ -43,10 +38,11 @@ const InvitedModal = ({
|
||||
shadow-xl
|
||||
"
|
||||
>
|
||||
<div className="i-heroicons-check-circle-solid h-[22px] w-[22px] text-[#039855]" />
|
||||
<CheckCircleIcon className="h-[22px] w-[22px] text-[#039855]" />
|
||||
</div>
|
||||
<XMarkIcon className="h-4 w-4 cursor-pointer" onClick={onCancel} />
|
||||
</div>
|
||||
<DialogTitle className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</DialogTitle>
|
||||
<div className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</div>
|
||||
{!IS_CE_EDITION && (
|
||||
<div className="mb-10 text-sm text-text-tertiary">{t('members.invitationSentTip', { ns: 'common' })}</div>
|
||||
)}
|
||||
@@ -58,7 +54,7 @@ const InvitedModal = ({
|
||||
!!successInvitationResults.length
|
||||
&& (
|
||||
<>
|
||||
<div className="py-2 text-sm font-medium text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
|
||||
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
|
||||
{successInvitationResults.map(item =>
|
||||
<InvitationLink key={item.email} value={item} />)}
|
||||
</>
|
||||
@@ -68,23 +64,18 @@ const InvitedModal = ({
|
||||
!!failedInvitationResults.length
|
||||
&& (
|
||||
<>
|
||||
<div className="py-2 text-sm font-medium text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
|
||||
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
|
||||
<div className="flex flex-wrap justify-between gap-y-1">
|
||||
{
|
||||
failedInvitationResults.map(item => (
|
||||
<div key={item.email} className="flex justify-center rounded-md border border-red-300 bg-orange-50 px-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="flex items-center justify-center gap-1 text-sm">
|
||||
{item.email}
|
||||
<div className="i-ri-question-line h-4 w-4 text-red-300" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{item.message}
|
||||
</TooltipContent>
|
||||
<Tooltip
|
||||
popupContent={item.message}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1 text-sm">
|
||||
{item.email}
|
||||
<RiQuestionLine className="h-4 w-4 text-red-300" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
@@ -106,8 +97,8 @@ const InvitedModal = ({
|
||||
{t('members.ok', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import copy from 'copy-to-clipboard'
|
||||
import { t } from 'i18next'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import s from './index.module.css'
|
||||
|
||||
type IInvitationLinkProps = {
|
||||
@@ -38,28 +38,20 @@ const InvitationLink = ({
|
||||
<div className="flex items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal py-2 hover:bg-state-base-hover" data-testid="invitation-link-container">
|
||||
<div className="flex h-5 grow items-center">
|
||||
<div className="relative h-full grow text-[13px]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
|
||||
</TooltipContent>
|
||||
<Tooltip
|
||||
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
|
||||
>
|
||||
<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="h-4 shrink-0 border bg-divider-regular" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 px-0.5">
|
||||
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
|
||||
</TooltipContent>
|
||||
<Tooltip
|
||||
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
|
||||
>
|
||||
<div className="shrink-0 px-0.5">
|
||||
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ const Operation = ({
|
||||
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<PortalToFollowElemContent className="z-[999]">
|
||||
<div className={cn('inline-flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}>
|
||||
<div className="p-1">
|
||||
{
|
||||
|
||||
@@ -141,7 +141,6 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
wrapperClassName="z-[1002]"
|
||||
className="!w-[420px] !p-6"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -77,7 +77,7 @@ const MemberSelector: FC<Props> = ({
|
||||
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
|
||||
@@ -116,7 +116,7 @@ const AddCustomModel = ({
|
||||
>
|
||||
{renderTrigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<div className="max-h-[304px] overflow-y-auto p-1">
|
||||
{
|
||||
@@ -136,7 +136,7 @@ const AddCustomModel = ({
|
||||
modelName={model.model}
|
||||
/>
|
||||
<div
|
||||
className="grow truncate text-text-primary system-md-regular"
|
||||
className="system-md-regular grow truncate text-text-primary"
|
||||
title={model.model}
|
||||
>
|
||||
{model.model}
|
||||
@@ -148,7 +148,7 @@ const AddCustomModel = ({
|
||||
{
|
||||
!notAllowCustomCredential && (
|
||||
<div
|
||||
className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 text-text-accent-light-mode-only system-xs-medium"
|
||||
className="system-xs-medium flex cursor-pointer items-center border-t border-t-divider-subtle p-3 text-text-accent-light-mode-only"
|
||||
onClick={() => {
|
||||
handleOpenModalForAddNewCustomModel()
|
||||
setOpen(false)
|
||||
|
||||
@@ -164,7 +164,7 @@ const Authorized = ({
|
||||
>
|
||||
{renderTrigger(mergedIsOpen)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
<div className={cn(
|
||||
'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]',
|
||||
popupClassName,
|
||||
@@ -172,7 +172,7 @@ const Authorized = ({
|
||||
>
|
||||
{
|
||||
popupTitle && (
|
||||
<div className="px-3 pb-0.5 pt-[10px] text-text-tertiary system-xs-medium">
|
||||
<div className="system-xs-medium px-3 pb-0.5 pt-[10px] text-text-tertiary">
|
||||
{popupTitle}
|
||||
</div>
|
||||
)
|
||||
@@ -218,7 +218,7 @@ const Authorized = ({
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
className="flex h-[40px] cursor-pointer items-center px-3 text-text-accent-light-mode-only system-xs-medium"
|
||||
className="system-xs-medium flex h-[40px] cursor-pointer items-center px-3 text-text-accent-light-mode-only"
|
||||
>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
{t('modelProvider.auth.addModelCredential', { ns: 'common' })}
|
||||
|
||||
@@ -53,14 +53,14 @@ const CredentialSelector = ({
|
||||
triggerPopupSameWidth
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => !disabled && setOpen(v => !v)}>
|
||||
<div className="flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2 system-sm-regular">
|
||||
<div className="system-sm-regular flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2">
|
||||
{
|
||||
selectedCredential && (
|
||||
<div className="flex items-center">
|
||||
{
|
||||
!selectedCredential.addNewCredential && <Indicator className="ml-1 mr-2 shrink-0" />
|
||||
}
|
||||
<div className="truncate text-components-input-text-filled system-sm-regular" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
|
||||
<div className="system-sm-regular truncate text-components-input-text-filled" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
|
||||
{
|
||||
selectedCredential.from_enterprise && (
|
||||
<Badge className="shrink-0">Enterprise</Badge>
|
||||
@@ -71,13 +71,13 @@ const CredentialSelector = ({
|
||||
}
|
||||
{
|
||||
!selectedCredential && (
|
||||
<div className="grow truncate text-components-input-text-placeholder system-sm-regular">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
|
||||
<div className="system-sm-regular grow truncate text-components-input-text-placeholder">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
<div className="border-ccomponents-panel-border rounded-xl border-[0.5px] bg-components-panel-bg-blur shadow-lg">
|
||||
<div className="max-h-[320px] overflow-y-auto p-1">
|
||||
{
|
||||
@@ -98,7 +98,7 @@ const CredentialSelector = ({
|
||||
{
|
||||
!notAllowAddNewCredential && (
|
||||
<div
|
||||
className="flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 text-text-accent-light-mode-only system-xs-medium"
|
||||
className="system-xs-medium flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 text-text-accent-light-mode-only"
|
||||
onClick={handleAddNewCredential}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ModelParameterModal from '../index'
|
||||
|
||||
let isAPIKeySet = true
|
||||
let parameterRules: Array<Record<string, unknown>> | undefined = [
|
||||
{
|
||||
name: 'temperature',
|
||||
@@ -40,7 +39,7 @@ let activeTextGenerationModelList: Array<Record<string, unknown>> = [
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
isAPIKeySet,
|
||||
isAPIKeySet: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -50,6 +49,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
data: parameterRules,
|
||||
},
|
||||
isLoading: isRulesLoading,
|
||||
isPending: isRulesLoading,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -62,12 +62,18 @@ vi.mock('../../hooks', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../parameter-item', () => ({
|
||||
default: ({ parameterRule, onChange, onSwitch }: {
|
||||
default: ({ parameterRule, onChange, onSwitch, nodesOutputVars, availableNodes }: {
|
||||
parameterRule: { name: string, label: { en_US: string } }
|
||||
onChange: (v: number) => void
|
||||
onSwitch: (checked: boolean, val: unknown) => void
|
||||
nodesOutputVars?: unknown[]
|
||||
availableNodes?: unknown[]
|
||||
}) => (
|
||||
<div data-testid={`param-${parameterRule.name}`}>
|
||||
<div
|
||||
data-testid={`param-${parameterRule.name}`}
|
||||
data-has-nodes-output-vars={!!nodesOutputVars}
|
||||
data-has-available-nodes={!!availableNodes}
|
||||
>
|
||||
{parameterRule.label.en_US}
|
||||
<button onClick={() => onChange(0.9)}>Change</button>
|
||||
<button onClick={() => onSwitch(false, undefined)}>Remove</button>
|
||||
@@ -119,7 +125,6 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isAPIKeySet = true
|
||||
isRulesLoading = false
|
||||
parameterRules = [
|
||||
{
|
||||
@@ -233,6 +238,26 @@ describe('ModelParameterModal', () => {
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass nodesOutputVars and availableNodes to ParameterItem', () => {
|
||||
const mockNodesOutputVars = [{ nodeId: 'n1', title: 'Node', vars: [] }]
|
||||
const mockAvailableNodes = [{ id: 'n1', data: { title: 'Node', type: 'llm' } }]
|
||||
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
isInWorkflow
|
||||
nodesOutputVars={mockNodesOutputVars as never}
|
||||
availableNodes={mockAvailableNodes as never}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
const paramEl = screen.getByTestId('param-temperature')
|
||||
expect(paramEl).toHaveAttribute('data-has-nodes-output-vars', 'true')
|
||||
expect(paramEl).toHaveAttribute('data-has-available-nodes', 'true')
|
||||
})
|
||||
|
||||
it('should support custom triggers, workflow mode, and missing default model values', async () => {
|
||||
render(
|
||||
<ModelParameterModal
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { ModelParameterRule } from '../../declarations'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import ParameterItem from '../parameter-item'
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
@@ -18,6 +23,29 @@ vi.mock('@/app/components/base/tag-input', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
let promptEditorOnChange: ((text: string) => void) | undefined
|
||||
let capturedWorkflowNodesMap: Record<string, { title: string, type: string }> | undefined
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: ({ value, onChange, workflowVariableBlock }: {
|
||||
value: string
|
||||
onChange: (text: string) => void
|
||||
workflowVariableBlock?: {
|
||||
show: boolean
|
||||
variables: NodeOutPutVar[]
|
||||
workflowNodesMap?: Record<string, { title: string, type: string }>
|
||||
}
|
||||
}) => {
|
||||
promptEditorOnChange = onChange
|
||||
capturedWorkflowNodesMap = workflowVariableBlock?.workflowNodesMap
|
||||
return (
|
||||
<div data-testid="prompt-editor" data-value={value} data-has-workflow-vars={!!workflowVariableBlock?.variables}>
|
||||
{value}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ParameterItem', () => {
|
||||
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
|
||||
name: 'temp',
|
||||
@@ -30,9 +58,10 @@ describe('ParameterItem', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
promptEditorOnChange = undefined
|
||||
capturedWorkflowNodesMap = undefined
|
||||
})
|
||||
|
||||
// Float tests
|
||||
it('should render float controls and clamp numeric input to max', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} value={0.7} onChange={onChange} />)
|
||||
@@ -50,7 +79,6 @@ describe('ParameterItem', () => {
|
||||
expect(onChange).toHaveBeenCalledWith(0.1)
|
||||
})
|
||||
|
||||
// Int tests
|
||||
it('should render int controls and clamp numeric input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 10 })} value={5} onChange={onChange} />)
|
||||
@@ -75,22 +103,17 @@ describe('ParameterItem', () => {
|
||||
it('should render int input without slider if min or max is missing', () => {
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0 })} value={5} />)
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
|
||||
// No max -> precision step
|
||||
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0')
|
||||
})
|
||||
|
||||
// Slider events (uses generic value mock for slider)
|
||||
it('should handle slide change and clamp values', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 10 })} value={0.7} onChange={onChange} />)
|
||||
|
||||
// Test that the actual slider triggers the onChange logic correctly
|
||||
// The implementation of Slider uses onChange(val) directly via the mock
|
||||
fireEvent.click(screen.getByTestId('slider-btn'))
|
||||
expect(onChange).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
// Text & String tests
|
||||
it('should render exact string input and propagate text changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'string', name: 'prompt' })} value="initial" onChange={onChange} />)
|
||||
@@ -109,21 +132,17 @@ describe('ParameterItem', () => {
|
||||
|
||||
it('should render select for string with options', () => {
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />)
|
||||
// Select renders the selected value in the trigger
|
||||
expect(screen.getByText('a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Tag Tests
|
||||
it('should render tag input for tag type', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'tag', tagPlaceholder: { en_US: 'placeholder', zh_Hans: 'placeholder' } })} value={['a']} onChange={onChange} />)
|
||||
expect(screen.getByText('placeholder')).toBeInTheDocument()
|
||||
// Trigger mock tag input
|
||||
fireEvent.click(screen.getByTestId('tag-input'))
|
||||
expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2'])
|
||||
})
|
||||
|
||||
// Boolean tests
|
||||
it('should render boolean radios and update value on click', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'boolean', default: false })} value={true} onChange={onChange} />)
|
||||
@@ -131,7 +150,6 @@ describe('ParameterItem', () => {
|
||||
expect(onChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
// Switch tests
|
||||
it('should call onSwitch with current value when optional switch is toggled off', () => {
|
||||
const onSwitch = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule()} value={0.7} onSwitch={onSwitch} />)
|
||||
@@ -146,7 +164,6 @@ describe('ParameterItem', () => {
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Default Value Fallbacks (rendering without value)
|
||||
it('should use default values if value is undefined', () => {
|
||||
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'float', default: 0.5 })} />)
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(0.5)
|
||||
@@ -158,26 +175,102 @@ describe('ParameterItem', () => {
|
||||
expect(screen.getByText('True')).toBeInTheDocument()
|
||||
expect(screen.getByText('False')).toBeInTheDocument()
|
||||
|
||||
// Without default
|
||||
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />) // min is 0 by default in createRule
|
||||
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />)
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(0)
|
||||
})
|
||||
|
||||
// Input Blur
|
||||
it('should reset input to actual bound value on blur', () => {
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
// change local state (which triggers clamp internally to let's say 1.4 -> 1 but leaves input text, though handleInputChange updates local state)
|
||||
// Actually our test fires a change so localValue = 1, then blur sets it
|
||||
fireEvent.change(input, { target: { value: '5' } })
|
||||
fireEvent.blur(input)
|
||||
expect(input).toHaveValue(1)
|
||||
})
|
||||
|
||||
// Unsupported
|
||||
it('should render no input for unsupported parameter type', () => {
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'unsupported' as unknown as string })} value={0.7} />)
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('workflow variable reference', () => {
|
||||
const mockNodesOutputVars: NodeOutPutVar[] = [
|
||||
{ nodeId: 'node1', title: 'LLM Node', vars: [] },
|
||||
]
|
||||
const mockAvailableNodes: Node[] = [
|
||||
{ id: 'node1', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'LLM Node', type: BlockEnum.LLM } } as Node,
|
||||
{ id: 'start', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } } as Node,
|
||||
]
|
||||
|
||||
it('should build workflowNodesMap and render PromptEditor for string type', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ParameterItem
|
||||
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
|
||||
value="hello {{#node1.output#}}"
|
||||
onChange={onChange}
|
||||
isInWorkflow
|
||||
nodesOutputVars={mockNodesOutputVars}
|
||||
availableNodes={mockAvailableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editor = screen.getByTestId('prompt-editor')
|
||||
expect(editor).toBeInTheDocument()
|
||||
expect(editor).toHaveAttribute('data-has-workflow-vars', 'true')
|
||||
expect(capturedWorkflowNodesMap).toBeDefined()
|
||||
expect(capturedWorkflowNodesMap!.node1.title).toBe('LLM Node')
|
||||
expect(capturedWorkflowNodesMap!.sys.title).toBe('workflow.blocks.start')
|
||||
expect(capturedWorkflowNodesMap!.sys.type).toBe(BlockEnum.Start)
|
||||
|
||||
promptEditorOnChange?.('updated text')
|
||||
expect(onChange).toHaveBeenCalledWith('updated text')
|
||||
})
|
||||
|
||||
it('should build workflowNodesMap and render PromptEditor for text type', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ParameterItem
|
||||
parameterRule={createRule({ type: 'text', name: 'user_prompt' })}
|
||||
value="some long text"
|
||||
onChange={onChange}
|
||||
isInWorkflow
|
||||
nodesOutputVars={mockNodesOutputVars}
|
||||
availableNodes={mockAvailableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editor = screen.getByTestId('prompt-editor')
|
||||
expect(editor).toBeInTheDocument()
|
||||
expect(editor).toHaveAttribute('data-has-workflow-vars', 'true')
|
||||
expect(capturedWorkflowNodesMap).toBeDefined()
|
||||
|
||||
promptEditorOnChange?.('new long text')
|
||||
expect(onChange).toHaveBeenCalledWith('new long text')
|
||||
})
|
||||
|
||||
it('should fall back to plain input when not in workflow mode for string type', () => {
|
||||
render(
|
||||
<ParameterItem
|
||||
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
|
||||
value="plain"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('prompt-editor')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return undefined workflowNodesMap when not in workflow mode', () => {
|
||||
render(
|
||||
<ParameterItem
|
||||
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
|
||||
value="plain"
|
||||
availableNodes={mockAvailableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(capturedWorkflowNodesMap).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
} from '../declarations'
|
||||
import type { ParameterValue } from './parameter-item'
|
||||
import type { TriggerProps } from './trigger'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
@@ -45,6 +49,8 @@ export type ModelParameterModalProps = {
|
||||
readonly?: boolean
|
||||
isInWorkflow?: boolean
|
||||
scope?: string
|
||||
nodesOutputVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
}
|
||||
|
||||
const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
@@ -61,11 +67,18 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
renderTrigger,
|
||||
readonly,
|
||||
isInWorkflow,
|
||||
nodesOutputVars,
|
||||
availableNodes,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const settingsIconRef = useRef<HTMLDivElement>(null)
|
||||
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
|
||||
const {
|
||||
data: parameterRulesData,
|
||||
isPending,
|
||||
isLoading,
|
||||
} = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = isPending || isLoading
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
@@ -191,7 +204,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isLoading
|
||||
isRulesLoading
|
||||
? <div className="py-5"><Loading /></div>
|
||||
: (
|
||||
[
|
||||
@@ -205,6 +218,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
onChange={v => handleParamChange(parameter.name, v)}
|
||||
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
|
||||
isInWorkflow={isInWorkflow}
|
||||
nodesOutputVars={nodesOutputVars}
|
||||
availableNodes={availableNodes}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -213,7 +228,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
!parameterRules.length && isLoading && (
|
||||
!parameterRules.length && isRulesLoading && (
|
||||
<div className="px-4 py-5"><Loading /></div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { ModelParameterRule } from '../declarations'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import TagInput from '@/app/components/base/tag-input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useLanguage } from '../hooks'
|
||||
import { isNullOrUndefined } from '../utils'
|
||||
@@ -18,18 +25,43 @@ type ParameterItemProps = {
|
||||
onChange?: (value: ParameterValue) => void
|
||||
onSwitch?: (checked: boolean, assignValue: ParameterValue) => void
|
||||
isInWorkflow?: boolean
|
||||
nodesOutputVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
}
|
||||
|
||||
function ParameterItem({
|
||||
parameterRule,
|
||||
value,
|
||||
onChange,
|
||||
onSwitch,
|
||||
isInWorkflow,
|
||||
nodesOutputVars,
|
||||
availableNodes = [],
|
||||
}: ParameterItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const numberInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const workflowNodesMap = useMemo(() => {
|
||||
if (!isInWorkflow || !availableNodes.length)
|
||||
return undefined
|
||||
|
||||
return availableNodes.reduce<Record<string, Pick<Node['data'], 'title' | 'type'>>>((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
type: node.data.type,
|
||||
}
|
||||
if (node.data.type === BlockEnum.Start) {
|
||||
acc.sys = {
|
||||
title: t('blocks.start', { ns: 'workflow' }),
|
||||
type: BlockEnum.Start,
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}, [availableNodes, isInWorkflow, t])
|
||||
|
||||
const getDefaultValue = () => {
|
||||
let defaultValue: ParameterValue
|
||||
|
||||
@@ -196,6 +228,25 @@ function ParameterItem({
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
|
||||
if (isInWorkflow && nodesOutputVars) {
|
||||
return (
|
||||
<div className="ml-4 w-[200px] rounded-lg bg-components-input-bg-normal px-2 py-1">
|
||||
<PromptEditor
|
||||
compact
|
||||
className="min-h-[22px] text-[13px]"
|
||||
value={renderValue as string}
|
||||
onChange={(text) => { handleInputChange(text) }}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: nodesOutputVars,
|
||||
workflowNodesMap,
|
||||
}}
|
||||
editable
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none system-sm-regular')}
|
||||
@@ -206,6 +257,25 @@ function ParameterItem({
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'text') {
|
||||
if (isInWorkflow && nodesOutputVars) {
|
||||
return (
|
||||
<div className="ml-4 w-full rounded-lg bg-components-input-bg-normal px-2 py-1">
|
||||
<PromptEditor
|
||||
compact
|
||||
className="min-h-[56px] text-[13px]"
|
||||
value={renderValue as string}
|
||||
onChange={(text) => { handleInputChange(text) }}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: nodesOutputVars,
|
||||
workflowNodesMap,
|
||||
}}
|
||||
editable
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className="ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled system-sm-regular"
|
||||
@@ -215,7 +285,7 @@ function ParameterItem({
|
||||
)
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'string' && !!parameterRule?.options?.length) {
|
||||
if (parameterRule.type === 'string' && !!parameterRule.options?.length) {
|
||||
return (
|
||||
<Select
|
||||
value={renderValue as string}
|
||||
|
||||
@@ -244,7 +244,6 @@ const ModelLoadBalancingModal = ({
|
||||
<Modal
|
||||
isShow={Boolean(model) && open}
|
||||
onClose={onClose}
|
||||
wrapperClassName="z-[1002]"
|
||||
className="w-[640px] max-w-none px-8 pt-8"
|
||||
title={(
|
||||
<div className="pb-3 font-semibold">
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { use } from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@@ -24,14 +23,14 @@ vi.mock('@headlessui/react', () => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const value = React.useMemo(() => ({ open, setOpen }), [open])
|
||||
return (
|
||||
<MenuContext value={value}>
|
||||
<MenuContext.Provider value={value}>
|
||||
{typeof children === 'function' ? children({ open }) : children}
|
||||
</MenuContext>
|
||||
</MenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => {
|
||||
const context = use(MenuContext)
|
||||
const context = React.useContext(MenuContext)
|
||||
const handleClick = () => {
|
||||
context?.setOpen(!context.open)
|
||||
onClick?.()
|
||||
@@ -44,7 +43,7 @@ vi.mock('@headlessui/react', () => {
|
||||
}
|
||||
|
||||
const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => {
|
||||
const context = use(MenuContext)
|
||||
const context = React.useContext(MenuContext)
|
||||
if (!context?.open)
|
||||
return null
|
||||
return (
|
||||
@@ -85,26 +84,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string, children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onClick?.(event)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Nav Component', () => {
|
||||
const mockSetAppDetail = vi.fn()
|
||||
const mockOnCreate = vi.fn()
|
||||
|
||||
@@ -1,532 +0,0 @@
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import type { ToolValue } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { createCustomCollection } from '@/service/tools'
|
||||
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
useInvalidateAllBuiltInTools,
|
||||
useInvalidateAllCustomTools,
|
||||
useInvalidateAllMCPTools,
|
||||
useInvalidateAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { Theme } from '@/types/app'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import ToolPicker from '../tool-picker'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockSetSystemFeatures = vi.fn()
|
||||
const mockInvalidateBuiltInTools = vi.fn()
|
||||
const mockInvalidateCustomTools = vi.fn()
|
||||
const mockInvalidateWorkflowTools = vi.fn()
|
||||
const mockInvalidateMcpTools = vi.fn()
|
||||
const mockCreateCustomCollection = vi.mocked(createCustomCollection)
|
||||
const mockInstallPackageFromMarketPlace = vi.fn()
|
||||
const mockCheckInstalled = vi.fn()
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
const mockUseTags = vi.mocked(useTags)
|
||||
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
|
||||
const mockUseAllBuiltInTools = vi.mocked(useAllBuiltInTools)
|
||||
const mockUseAllCustomTools = vi.mocked(useAllCustomTools)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
const mockUseAllMCPTools = vi.mocked(useAllMCPTools)
|
||||
const mockUseInvalidateAllBuiltInTools = vi.mocked(useInvalidateAllBuiltInTools)
|
||||
const mockUseInvalidateAllCustomTools = vi.mocked(useInvalidateAllCustomTools)
|
||||
const mockUseInvalidateAllWorkflowTools = vi.mocked(useInvalidateAllWorkflowTools)
|
||||
const mockUseInvalidateAllMCPTools = vi.mocked(useInvalidateAllMCPTools)
|
||||
const mockUseFeaturedToolsRecommendations = vi.mocked(useFeaturedToolsRecommendations)
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/plugins/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useTags: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplacePlugins: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
createCustomCollection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFeaturedToolsRecommendations: vi.fn(),
|
||||
useDownloadPlugin: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
})),
|
||||
useInstallPackageFromMarketPlace: () => ({
|
||||
mutateAsync: mockInstallPackageFromMarketPlace,
|
||||
isPending: false,
|
||||
}),
|
||||
usePluginDeclarationFromMarketPlace: () => ({
|
||||
data: undefined,
|
||||
}),
|
||||
usePluginTaskList: () => ({
|
||||
handleRefetch: vi.fn(),
|
||||
}),
|
||||
useUpdatePackageFromMarketPlace: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: vi.fn(),
|
||||
useAllCustomTools: vi.fn(),
|
||||
useAllWorkflowTools: vi.fn(),
|
||||
useAllMCPTools: vi.fn(),
|
||||
useInvalidateAllBuiltInTools: vi.fn(),
|
||||
useInvalidateAllCustomTools: vi.fn(),
|
||||
useInvalidateAllWorkflowTools: vi.fn(),
|
||||
useInvalidateAllMCPTools: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (payload: unknown) => mockNotify(payload),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ theme: Theme.light }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
|
||||
default: ({
|
||||
onAdd,
|
||||
onHide,
|
||||
}: {
|
||||
onAdd: (payload: { name: string }) => Promise<void>
|
||||
onHide: () => void
|
||||
}) => (
|
||||
<div data-testid="edit-custom-tool-modal">
|
||||
<button type="button" onClick={() => onAdd({ name: 'collection-a' })}>submit-custom-tool</button>
|
||||
<button type="button" onClick={onHide}>hide-custom-tool</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => mockCheckInstalled(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
|
||||
default: () => ({
|
||||
canInstall: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({
|
||||
refreshPluginList: mockRefreshPluginList,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: vi.fn().mockResolvedValue({ status: 'success' }),
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: ({
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: {
|
||||
onSuccess: () => void | Promise<void>
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div data-testid="install-from-marketplace">
|
||||
<button type="button" onClick={() => onSuccess()}>complete-featured-install</button>
|
||||
<button type="button" onClick={onClose}>cancel-featured-install</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/utils/var')>()
|
||||
return {
|
||||
...actual,
|
||||
getMarketplaceUrl: () => 'https://marketplace.test/tools',
|
||||
}
|
||||
})
|
||||
|
||||
const createTool = (
|
||||
name: string,
|
||||
label: string,
|
||||
description = `${label} description`,
|
||||
): Tool => ({
|
||||
name,
|
||||
author: 'author',
|
||||
label: {
|
||||
en_US: label,
|
||||
zh_Hans: label,
|
||||
},
|
||||
description: {
|
||||
en_US: description,
|
||||
zh_Hans: description,
|
||||
},
|
||||
parameters: [],
|
||||
labels: [],
|
||||
output_schema: {},
|
||||
})
|
||||
|
||||
const createToolProvider = (
|
||||
overrides: Partial<ToolWithProvider> = {},
|
||||
): ToolWithProvider => ({
|
||||
id: 'provider-1',
|
||||
name: 'provider-one',
|
||||
author: 'Provider Author',
|
||||
description: {
|
||||
en_US: 'Provider description',
|
||||
zh_Hans: 'Provider description',
|
||||
},
|
||||
icon: 'icon',
|
||||
icon_dark: 'icon-dark',
|
||||
label: {
|
||||
en_US: 'Provider One',
|
||||
zh_Hans: 'Provider One',
|
||||
},
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
plugin_id: 'plugin-1',
|
||||
tools: [createTool('tool-a', 'Tool A')],
|
||||
meta: { version: '1.0.0' } as ToolWithProvider['meta'],
|
||||
plugin_unique_identifier: 'plugin-1@1.0.0',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createToolValue = (overrides: Partial<ToolValue> = {}): ToolValue => ({
|
||||
provider_name: 'provider-a',
|
||||
tool_name: 'tool-a',
|
||||
tool_label: 'Tool A',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'org',
|
||||
author: 'author',
|
||||
name: 'Plugin One',
|
||||
plugin_id: 'plugin-1',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'plugin-1@1.0.0',
|
||||
icon: 'icon',
|
||||
verified: true,
|
||||
label: { en_US: 'Plugin One' },
|
||||
brief: { en_US: 'Brief' },
|
||||
description: { en_US: 'Plugin description' },
|
||||
introduction: 'Intro',
|
||||
repository: 'https://example.com',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [{ name: 'tag-a' }],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const builtInTools = [
|
||||
createToolProvider({
|
||||
id: 'built-in-1',
|
||||
name: 'built-in-provider',
|
||||
label: { en_US: 'Built-in Provider', zh_Hans: 'Built-in Provider' },
|
||||
tools: [createTool('built-in-tool', 'Built-in Tool')],
|
||||
}),
|
||||
]
|
||||
|
||||
const customTools = [
|
||||
createToolProvider({
|
||||
id: 'custom-1',
|
||||
name: 'custom-provider',
|
||||
label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' },
|
||||
type: CollectionType.custom,
|
||||
tools: [createTool('weather-tool', 'Weather Tool')],
|
||||
}),
|
||||
]
|
||||
|
||||
const workflowTools = [
|
||||
createToolProvider({
|
||||
id: 'workflow-1',
|
||||
name: 'workflow-provider',
|
||||
label: { en_US: 'Workflow Provider', zh_Hans: 'Workflow Provider' },
|
||||
type: CollectionType.workflow,
|
||||
tools: [createTool('workflow-tool', 'Workflow Tool')],
|
||||
}),
|
||||
]
|
||||
|
||||
const mcpTools = [
|
||||
createToolProvider({
|
||||
id: 'mcp-1',
|
||||
name: 'mcp-provider',
|
||||
label: { en_US: 'MCP Provider', zh_Hans: 'MCP Provider' },
|
||||
type: CollectionType.mcp,
|
||||
tools: [createTool('mcp-tool', 'MCP Tool')],
|
||||
}),
|
||||
]
|
||||
|
||||
const renderToolPicker = (props: Partial<React.ComponentProps<typeof ToolPicker>> = {}) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToolPicker
|
||||
disabled={false}
|
||||
trigger={<button type="button">open-picker</button>}
|
||||
isShow={false}
|
||||
onShowChange={vi.fn()}
|
||||
onSelect={vi.fn()}
|
||||
onSelectMultiple={vi.fn()}
|
||||
selectedTools={[createToolValue()]}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ToolPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
enable_marketplace: true,
|
||||
},
|
||||
setSystemFeatures: mockSetSystemFeatures,
|
||||
}))
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
mockUseTags.mockReturnValue({
|
||||
tags: [{ name: 'weather', label: 'Weather' }],
|
||||
tagsMap: { weather: { name: 'weather', label: 'Weather' } },
|
||||
getTagLabel: (name: string) => name,
|
||||
})
|
||||
mockUseMarketplacePlugins.mockReturnValue({
|
||||
plugins: [],
|
||||
total: 0,
|
||||
resetPlugins: vi.fn(),
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
cancelQueryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
page: 0,
|
||||
} as ReturnType<typeof useMarketplacePlugins>)
|
||||
mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType<typeof useAllBuiltInTools>)
|
||||
mockUseAllCustomTools.mockReturnValue({ data: customTools } as ReturnType<typeof useAllCustomTools>)
|
||||
mockUseAllWorkflowTools.mockReturnValue({ data: workflowTools } as ReturnType<typeof useAllWorkflowTools>)
|
||||
mockUseAllMCPTools.mockReturnValue({ data: mcpTools } as ReturnType<typeof useAllMCPTools>)
|
||||
mockUseInvalidateAllBuiltInTools.mockReturnValue(mockInvalidateBuiltInTools)
|
||||
mockUseInvalidateAllCustomTools.mockReturnValue(mockInvalidateCustomTools)
|
||||
mockUseInvalidateAllWorkflowTools.mockReturnValue(mockInvalidateWorkflowTools)
|
||||
mockUseInvalidateAllMCPTools.mockReturnValue(mockInvalidateMcpTools)
|
||||
mockUseFeaturedToolsRecommendations.mockReturnValue({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useFeaturedToolsRecommendations>)
|
||||
mockCreateCustomCollection.mockResolvedValue(undefined)
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-1',
|
||||
})
|
||||
mockCheckInstalled.mockReturnValue({
|
||||
installedInfo: undefined,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('should request opening when the trigger is clicked unless the picker is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowChange = vi.fn()
|
||||
const disabledOnShowChange = vi.fn()
|
||||
|
||||
renderToolPicker({ onShowChange })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'open-picker' }))
|
||||
expect(onShowChange).toHaveBeenCalledWith(true)
|
||||
|
||||
renderToolPicker({
|
||||
disabled: true,
|
||||
onShowChange: disabledOnShowChange,
|
||||
})
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'open-picker' })[1]!)
|
||||
expect(disabledOnShowChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render real search and tool lists, then forward tool selections', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const onSelectMultiple = vi.fn()
|
||||
const queryPluginsWithDebounced = vi.fn()
|
||||
|
||||
mockUseMarketplacePlugins.mockReturnValue({
|
||||
plugins: [],
|
||||
total: 0,
|
||||
resetPlugins: vi.fn(),
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
page: 0,
|
||||
} as ReturnType<typeof useMarketplacePlugins>)
|
||||
|
||||
renderToolPicker({
|
||||
isShow: true,
|
||||
scope: 'custom',
|
||||
onSelect,
|
||||
onSelectMultiple,
|
||||
selectedTools: [],
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Custom Provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('MCP Provider')).toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'weather')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryPluginsWithDebounced).toHaveBeenLastCalledWith({
|
||||
query: 'weather',
|
||||
tags: [],
|
||||
category: PluginCategoryEnum.tool,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weather Tool')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('Weather Tool'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
provider_name: 'custom-provider',
|
||||
tool_name: 'weather-tool',
|
||||
tool_label: 'Weather Tool',
|
||||
}))
|
||||
|
||||
await user.hover(screen.getByText('Custom Provider'))
|
||||
await user.click(screen.getByText('workflow.tabs.addAll'))
|
||||
|
||||
expect(onSelectMultiple).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
provider_name: 'custom-provider',
|
||||
tool_name: 'weather-tool',
|
||||
tool_label: 'Weather Tool',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should create a custom collection from the add button and refresh custom tools', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderToolPicker({
|
||||
isShow: true,
|
||||
supportAddCustomTool: true,
|
||||
})
|
||||
|
||||
const addCustomToolButton = Array.from(container.querySelectorAll('button')).find((button) => {
|
||||
return button.className.includes('bg-components-button-primary-bg')
|
||||
})
|
||||
|
||||
expect(addCustomToolButton).toBeTruthy()
|
||||
|
||||
await user.click(addCustomToolButton!)
|
||||
expect(screen.getByTestId('edit-custom-tool-modal')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-custom-tool' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateCustomCollection).toHaveBeenCalledWith({ name: 'collection-a' })
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'common.api.actionSuccess',
|
||||
})
|
||||
expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('edit-custom-tool-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should invalidate all tool collections after featured install succeeds', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockUseFeaturedToolsRecommendations.mockReturnValue({
|
||||
plugins: [createPlugin({ plugin_id: 'featured-1', latest_package_identifier: 'featured-1@1.0.0' })],
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useFeaturedToolsRecommendations>)
|
||||
|
||||
renderToolPicker({
|
||||
isShow: true,
|
||||
selectedTools: [],
|
||||
})
|
||||
|
||||
const featuredPluginItem = await screen.findByText('Plugin One')
|
||||
await user.hover(featuredPluginItem)
|
||||
await user.click(screen.getByRole('button', { name: 'plugin.installAction' }))
|
||||
expect(await screen.findByTestId('install-from-marketplace')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete-featured-install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateWorkflowTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateMcpTools).toHaveBeenCalledTimes(1)
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { Node } from '../../types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import DatasetsDetailProvider from '../provider'
|
||||
import { useDatasetsDetailStore } from '../store'
|
||||
|
||||
const mockFetchDatasets = vi.fn()
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
fetchDatasets: (params: unknown) => mockFetchDatasets(params),
|
||||
}))
|
||||
|
||||
const Consumer = () => {
|
||||
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
|
||||
return <div>{`dataset-count:${datasetCount}`}</div>
|
||||
}
|
||||
|
||||
const createWorkflowNode = (datasetIds: string[] = []): Node => ({
|
||||
id: `node-${datasetIds.join('-') || 'empty'}`,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Knowledge',
|
||||
desc: '',
|
||||
type: BlockEnum.KnowledgeRetrieval,
|
||||
dataset_ids: datasetIds,
|
||||
},
|
||||
} as unknown as Node)
|
||||
|
||||
const createDataset = (id: string): DataSet => ({
|
||||
id,
|
||||
name: `Dataset ${id}`,
|
||||
} as DataSet)
|
||||
|
||||
describe('datasets-detail-store provider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchDatasets.mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
it('should provide the datasets detail store without fetching when no knowledge datasets are selected', () => {
|
||||
render(
|
||||
<DatasetsDetailProvider nodes={[
|
||||
{
|
||||
id: 'node-start',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
} as unknown as Node,
|
||||
]}
|
||||
>
|
||||
<Consumer />
|
||||
</DatasetsDetailProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('dataset-count:0')).toBeInTheDocument()
|
||||
expect(mockFetchDatasets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fetch unique dataset details from knowledge retrieval nodes and store them', async () => {
|
||||
mockFetchDatasets.mockResolvedValue({
|
||||
data: [createDataset('dataset-1'), createDataset('dataset-2')],
|
||||
})
|
||||
|
||||
render(
|
||||
<DatasetsDetailProvider nodes={[
|
||||
createWorkflowNode(['dataset-1', 'dataset-2']),
|
||||
createWorkflowNode(['dataset-2']),
|
||||
]}
|
||||
>
|
||||
<Consumer />
|
||||
</DatasetsDetailProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchDatasets).toHaveBeenCalledWith({
|
||||
url: '/datasets',
|
||||
params: {
|
||||
page: 1,
|
||||
ids: ['dataset-1', 'dataset-2'],
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('dataset-count:2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,308 +0,0 @@
|
||||
import type { Shape } from '../../store/workflow'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
import HeaderInNormal from '../header-in-normal'
|
||||
import HeaderInRestoring from '../header-in-restoring'
|
||||
import HeaderInHistory from '../header-in-view-history'
|
||||
|
||||
const mockUseNodes = vi.fn()
|
||||
const mockHandleBackupDraft = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleNodeSelect = vi.fn()
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
const mockCloseAllInputFieldPanels = vi.fn()
|
||||
const mockInvalidAllLastRun = vi.fn()
|
||||
const mockRestoreWorkflow = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
const mockRunAndHistory = vi.fn()
|
||||
const mockViewHistory = vi.fn()
|
||||
|
||||
let mockNodesReadOnly = false
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => mockUseNodes(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly }),
|
||||
useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect }),
|
||||
useWorkflowRun: () => ({
|
||||
handleBackupDraft: mockHandleBackupDraft,
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
}),
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: vi.fn(),
|
||||
}),
|
||||
useWorkflowRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
|
||||
useInputFieldPanel: () => ({
|
||||
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidAllLastRun: () => mockInvalidAllLastRun,
|
||||
useRestoreWorkflow: () => ({
|
||||
mutateAsync: mockRestoreWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/toast', () => ({
|
||||
default: {
|
||||
notify: (payload: unknown) => mockNotify(payload),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../editing-title', () => ({
|
||||
default: () => <div>editing-title</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../scroll-to-selected-node-button', () => ({
|
||||
default: () => <div>scroll-button</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../env-button', () => ({
|
||||
default: ({ disabled }: { disabled: boolean }) => <div data-testid="env-button">{`${disabled}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../global-variable-button', () => ({
|
||||
default: ({ disabled }: { disabled: boolean }) => <div data-testid="global-variable-button">{`${disabled}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../run-and-history', () => ({
|
||||
default: (props: object) => {
|
||||
mockRunAndHistory(props)
|
||||
return <div data-testid="run-and-history" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../version-history-button', () => ({
|
||||
default: ({ onClick }: { onClick: () => void }) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
version-history
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../restoring-title', () => ({
|
||||
default: () => <div>restoring-title</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../running-title', () => ({
|
||||
default: () => <div>running-title</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../view-history', () => ({
|
||||
default: (props: { withText?: boolean }) => {
|
||||
mockViewHistory(props)
|
||||
return <div data-testid="view-history">{props.withText ? 'with-text' : 'icon-only'}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createSelectedNode = (selected = true) => ({
|
||||
id: 'node-selected',
|
||||
data: {
|
||||
selected,
|
||||
},
|
||||
})
|
||||
|
||||
const createBackupDraft = (): NonNullable<Shape['backupDraft']> => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
environmentVariables: [],
|
||||
})
|
||||
|
||||
const createCurrentVersion = (): NonNullable<Shape['currentVersion']> => ({
|
||||
id: 'version-1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Tester',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 0,
|
||||
updated_by: {
|
||||
id: 'user-1',
|
||||
name: 'Tester',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
tool_published: false,
|
||||
environment_variables: [],
|
||||
version: WorkflowVersion.Latest,
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
})
|
||||
|
||||
describe('Header layout components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodesReadOnly = false
|
||||
mockTheme = 'light'
|
||||
mockUseNodes.mockReturnValue([])
|
||||
mockRestoreWorkflow.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('HeaderInNormal', () => {
|
||||
it('should render slots, pass read-only state to action buttons, and start restoring mode', () => {
|
||||
mockNodesReadOnly = true
|
||||
mockUseNodes.mockReturnValue([createSelectedNode()])
|
||||
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInNormal
|
||||
components={{
|
||||
left: <div>left-slot</div>,
|
||||
middle: <div>middle-slot</div>,
|
||||
chatVariableTrigger: <div>chat-trigger</div>,
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
initialStoreState: {
|
||||
showEnvPanel: true,
|
||||
showDebugAndPreviewPanel: true,
|
||||
showVariableInspectPanel: true,
|
||||
showChatVariablePanel: true,
|
||||
showGlobalVariablePanel: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('editing-title')).toBeInTheDocument()
|
||||
expect(screen.getByText('scroll-button')).toBeInTheDocument()
|
||||
expect(screen.getByText('left-slot')).toBeInTheDocument()
|
||||
expect(screen.getByText('middle-slot')).toBeInTheDocument()
|
||||
expect(screen.getByText('chat-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('env-button')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('global-variable-button')).toHaveTextContent('true')
|
||||
expect(mockRunAndHistory).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'version-history' }))
|
||||
|
||||
expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-selected', true)
|
||||
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().isRestoring).toBe(true)
|
||||
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(true)
|
||||
expect(store.getState().showEnvPanel).toBe(false)
|
||||
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
|
||||
expect(store.getState().showVariableInspectPanel).toBe(false)
|
||||
expect(store.getState().showChatVariablePanel).toBe(false)
|
||||
expect(store.getState().showGlobalVariablePanel).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeaderInRestoring', () => {
|
||||
it('should cancel restoring mode and reopen the editor state', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInRestoring />,
|
||||
{
|
||||
initialStoreState: {
|
||||
isRestoring: true,
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowType: FlowType.appFlow,
|
||||
flowId: 'flow-1',
|
||||
fileSettings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exitVersions' }))
|
||||
|
||||
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().isRestoring).toBe(false)
|
||||
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
|
||||
})
|
||||
|
||||
it('should restore the selected version, clear backup state, and forward lifecycle callbacks', async () => {
|
||||
const onRestoreSettled = vi.fn()
|
||||
const deleteAllInspectVars = vi.fn()
|
||||
const currentVersion = createCurrentVersion()
|
||||
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInRestoring onRestoreSettled={onRestoreSettled} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
isRestoring: true,
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
backupDraft: createBackupDraft(),
|
||||
currentVersion,
|
||||
deleteAllInspectVars,
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowType: FlowType.appFlow,
|
||||
flowId: 'flow-1',
|
||||
fileSettings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/flow-1/workflows/version-1/restore')
|
||||
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
|
||||
expect(store.getState().isRestoring).toBe(false)
|
||||
expect(store.getState().backupDraft).toBeUndefined()
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(deleteAllInspectVars).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidAllLastRun).toHaveBeenCalledTimes(1)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'workflow.versionHistory.action.restoreSuccess',
|
||||
})
|
||||
})
|
||||
expect(onRestoreSettled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeaderInHistory', () => {
|
||||
it('should render the history trigger with text and return to edit mode', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInHistory viewHistoryProps={{ historyUrl: '/history' } as never} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'history-1',
|
||||
} as Shape['historyWorkflowData'],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('running-title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('view-history')).toHaveTextContent('with-text')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.goBackToEdit' }))
|
||||
|
||||
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().historyWorkflowData).toBeUndefined()
|
||||
expect(mockViewHistory).toHaveBeenCalledWith(expect.objectContaining({
|
||||
withText: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,106 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Header from '../index'
|
||||
|
||||
let mockPathname = '/apps/demo/workflow'
|
||||
let mockMaximizeCanvas = false
|
||||
let mockWorkflowMode = {
|
||||
normal: true,
|
||||
restoring: false,
|
||||
viewHistory: false,
|
||||
}
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useWorkflowMode: () => mockWorkflowMode,
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: <T,>(selector: (state: { maximizeCanvas: boolean }) => T) => selector({
|
||||
maximizeCanvas: mockMaximizeCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', async () => {
|
||||
const ReactModule = await import('react')
|
||||
|
||||
return {
|
||||
default: (
|
||||
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
|
||||
) => {
|
||||
const DynamicComponent = (props: Record<string, unknown>) => {
|
||||
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
|
||||
|
||||
ReactModule.useEffect(() => {
|
||||
let mounted = true
|
||||
loader().then((mod) => {
|
||||
if (mounted)
|
||||
setLoaded(() => mod.default)
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return Loaded ? <Loaded {...props} /> : null
|
||||
}
|
||||
|
||||
return DynamicComponent
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../header-in-normal', () => ({
|
||||
default: () => <div data-testid="header-normal">normal-layout</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../header-in-view-history', () => ({
|
||||
default: () => <div data-testid="header-history">history-layout</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../header-in-restoring', () => ({
|
||||
default: () => <div data-testid="header-restoring">restoring-layout</div>,
|
||||
}))
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPathname = '/apps/demo/workflow'
|
||||
mockMaximizeCanvas = false
|
||||
mockWorkflowMode = {
|
||||
normal: true,
|
||||
restoring: false,
|
||||
viewHistory: false,
|
||||
}
|
||||
})
|
||||
|
||||
it('should render the normal layout and show the maximize spacer on workflow canvases', () => {
|
||||
mockMaximizeCanvas = true
|
||||
|
||||
const { container } = render(<Header />)
|
||||
|
||||
expect(screen.getByTestId('header-normal')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-history')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-restoring')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.h-14.w-\\[52px\\]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should switch between history and restoring layouts and skip the spacer outside canvas routes', async () => {
|
||||
mockPathname = '/apps/demo/logs'
|
||||
mockWorkflowMode = {
|
||||
normal: false,
|
||||
restoring: true,
|
||||
viewHistory: true,
|
||||
}
|
||||
|
||||
const { container } = render(<Header />)
|
||||
|
||||
expect(await screen.findByTestId('header-history')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('header-restoring')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-normal')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.h-14.w-\\[52px\\]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useContext } from 'react'
|
||||
import { HooksStoreContext, HooksStoreContextProvider } from '../provider'
|
||||
|
||||
const mockRefreshAll = vi.fn()
|
||||
const mockStore = {
|
||||
getState: () => ({
|
||||
refreshAll: mockRefreshAll,
|
||||
}),
|
||||
}
|
||||
|
||||
let mockReactflowState = {
|
||||
d3Selection: null as object | null,
|
||||
d3Zoom: null as object | null,
|
||||
}
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStore: (selector: (state: typeof mockReactflowState) => unknown) => selector(mockReactflowState),
|
||||
}))
|
||||
|
||||
vi.mock('../store', async () => {
|
||||
const actual = await vi.importActual<typeof import('../store')>('../store')
|
||||
return {
|
||||
...actual,
|
||||
createHooksStore: vi.fn(() => mockStore),
|
||||
}
|
||||
})
|
||||
|
||||
const Consumer = () => {
|
||||
const store = useContext(HooksStoreContext)
|
||||
return <div>{store ? 'has-hooks-store' : 'missing-hooks-store'}</div>
|
||||
}
|
||||
|
||||
describe('hooks-store provider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReactflowState = {
|
||||
d3Selection: null,
|
||||
d3Zoom: null,
|
||||
}
|
||||
})
|
||||
|
||||
it('should provide the hooks store context without refreshing when the canvas handles are missing', () => {
|
||||
render(
|
||||
<HooksStoreContextProvider>
|
||||
<Consumer />
|
||||
</HooksStoreContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('has-hooks-store')).toBeInTheDocument()
|
||||
expect(mockRefreshAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should refresh the hooks store when both d3Selection and d3Zoom are available', async () => {
|
||||
const handleRun = vi.fn()
|
||||
mockReactflowState = {
|
||||
d3Selection: {},
|
||||
d3Zoom: {},
|
||||
}
|
||||
|
||||
render(
|
||||
<HooksStoreContextProvider handleRun={handleRun}>
|
||||
<Consumer />
|
||||
</HooksStoreContextProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshAll).toHaveBeenCalledWith({
|
||||
handleRun,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Node as WorkflowNode } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { CUSTOM_NODE } from '../../constants'
|
||||
import { BlockEnum } from '../../types'
|
||||
import CustomNode, { Panel } from '../index'
|
||||
|
||||
vi.mock('../components', () => ({
|
||||
NodeComponentMap: {
|
||||
[BlockEnum.Start]: () => <div>start-node-component</div>,
|
||||
},
|
||||
PanelComponentMap: {
|
||||
[BlockEnum.Start]: () => <div>start-panel-component</div>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../_base/node', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
id,
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
data: { type: BlockEnum }
|
||||
children: ReactElement
|
||||
}) => (
|
||||
<div>
|
||||
<div>{`base-node:${id}:${data.type}`}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/workflow-panel', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
id,
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
data: { type: BlockEnum }
|
||||
children: ReactElement
|
||||
}) => (
|
||||
<div>
|
||||
<div>{`base-panel:${id}:${data.type}`}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createNodeData = (): WorkflowNode['data'] => ({
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
})
|
||||
|
||||
const baseNodeProps = {
|
||||
type: CUSTOM_NODE,
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
isConnectable: true,
|
||||
}
|
||||
|
||||
describe('workflow nodes index', () => {
|
||||
it('should render the mapped node inside the base node shell', () => {
|
||||
render(
|
||||
<CustomNode
|
||||
id="node-1"
|
||||
data={createNodeData()}
|
||||
{...baseNodeProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('base-node:node-1:start')).toBeInTheDocument()
|
||||
expect(screen.getByText('start-node-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the mapped panel inside the base panel shell for custom nodes', () => {
|
||||
render(
|
||||
<Panel
|
||||
type={CUSTOM_NODE}
|
||||
id="node-1"
|
||||
data={createNodeData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('base-panel:node-1:start')).toBeInTheDocument()
|
||||
expect(screen.getByText('start-panel-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null for non-custom panel types', () => {
|
||||
const { container } = render(
|
||||
<Panel
|
||||
type="default"
|
||||
id="node-1"
|
||||
data={createNodeData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -1,226 +0,0 @@
|
||||
import type { UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FileTypeItem from '../file-type-item'
|
||||
import FileUploadSetting from '../file-upload-setting'
|
||||
|
||||
const mockUseFileUploadConfig = vi.mocked(useFileUploadConfig)
|
||||
const mockUseFileSizeLimit = vi.mocked(useFileSizeLimit)
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
|
||||
useFileSizeLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<UploadFileSetting> = {}): UploadFileSetting => ({
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
max_length: 2,
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: ['pdf'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('File upload support components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseFileUploadConfig.mockReturnValue({ data: {} } as ReturnType<typeof useFileUploadConfig>)
|
||||
mockUseFileSizeLimit.mockReturnValue({
|
||||
imgSizeLimit: 10 * 1024 * 1024,
|
||||
docSizeLimit: 20 * 1024 * 1024,
|
||||
audioSizeLimit: 30 * 1024 * 1024,
|
||||
videoSizeLimit: 40 * 1024 * 1024,
|
||||
maxFileUploadLimit: 10,
|
||||
} as ReturnType<typeof useFileSizeLimit>)
|
||||
})
|
||||
|
||||
describe('FileTypeItem', () => {
|
||||
it('should render built-in file types and toggle the selected type on click', () => {
|
||||
const onToggle = vi.fn()
|
||||
|
||||
render(
|
||||
<FileTypeItem
|
||||
type={SupportUploadFileTypes.image}
|
||||
selected={false}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appDebug.variableConfig.file.image.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('JPG, JPEG, PNG, GIF, WEBP, SVG')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('appDebug.variableConfig.file.image.name'))
|
||||
expect(onToggle).toHaveBeenCalledWith(SupportUploadFileTypes.image)
|
||||
})
|
||||
|
||||
it('should render the custom tag editor and emit custom extensions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCustomFileTypesChange = vi.fn()
|
||||
|
||||
render(
|
||||
<FileTypeItem
|
||||
type={SupportUploadFileTypes.custom}
|
||||
selected
|
||||
onToggle={vi.fn()}
|
||||
customFileTypes={['json']}
|
||||
onCustomFileTypesChange={onCustomFileTypesChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
|
||||
await user.type(input, 'csv')
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(screen.getByText('json')).toBeInTheDocument()
|
||||
expect(onCustomFileTypesChange).toHaveBeenCalledWith(['json', 'csv'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileUploadSetting', () => {
|
||||
it('should update file types, upload methods, and upload limits', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.file.image.name'))
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
allowed_file_types: [SupportUploadFileTypes.document, SupportUploadFileTypes.image],
|
||||
}))
|
||||
|
||||
await user.click(screen.getByText('URL'))
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
}))
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '5' } })
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
max_length: 5,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should toggle built-in and custom file type selections', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.file.document.name'))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_types: [],
|
||||
}))
|
||||
|
||||
rerender(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_types: [SupportUploadFileTypes.custom],
|
||||
}))
|
||||
|
||||
rerender(
|
||||
<FileUploadSetting
|
||||
payload={createPayload({
|
||||
allowed_file_types: [SupportUploadFileTypes.custom],
|
||||
})}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_types: [],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should support both upload methods and update custom extensions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.both'))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
}))
|
||||
|
||||
rerender(
|
||||
<FileUploadSetting
|
||||
payload={createPayload({
|
||||
allowed_file_types: [SupportUploadFileTypes.custom],
|
||||
})}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
|
||||
await user.type(input, 'csv')
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_extensions: ['pdf', 'csv'],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render support file types in the feature panel and hide them when requested', () => {
|
||||
const { rerender } = render(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
inFeaturePanel
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appDebug.variableConfig.file.supportFileTypes')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
inFeaturePanel
|
||||
hideSupportFileType
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('appDebug.variableConfig.file.document.name')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,250 +0,0 @@
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus, VarType } from '@/app/components/workflow/types'
|
||||
import DefaultValue from '../default-value'
|
||||
import ErrorHandleOnNode from '../error-handle-on-node'
|
||||
import ErrorHandleOnPanel from '../error-handle-on-panel'
|
||||
import ErrorHandleTip from '../error-handle-tip'
|
||||
import ErrorHandleTypeSelector from '../error-handle-type-selector'
|
||||
import FailBranchCard from '../fail-branch-card'
|
||||
import { useDefaultValue, useErrorHandle } from '../hooks'
|
||||
import { ErrorHandleTypeEnum } from '../types'
|
||||
|
||||
const { mockDocLink } = vi.hoisted(() => ({
|
||||
mockDocLink: vi.fn((path: string) => `https://docs.example.com${path}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useDocLink: () => mockDocLink,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDefaultValue: vi.fn(),
|
||||
useErrorHandle: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../node-handle', () => ({
|
||||
NodeSourceHandle: ({ handleId }: { handleId: string }) => <div className="react-flow__handle" data-handleid={handleId} />,
|
||||
}))
|
||||
|
||||
const mockUseDefaultValue = vi.mocked(useDefaultValue)
|
||||
const mockUseErrorHandle = vi.mocked(useErrorHandle)
|
||||
const originalDOMMatrixReadOnly = window.DOMMatrixReadOnly
|
||||
|
||||
const baseData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
type: 'code' as CommonNodeType['type'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const ErrorHandleNodeHarness = ({ id, data }: NodeProps<CommonNodeType>) => (
|
||||
<ErrorHandleOnNode id={id} data={data} />
|
||||
)
|
||||
|
||||
const renderErrorHandleNode = (data: CommonNodeType) =>
|
||||
renderWorkflowFlowComponent(<div />, {
|
||||
nodes: [createNode({
|
||||
id: 'node-1',
|
||||
type: 'errorHandleNode',
|
||||
data,
|
||||
})],
|
||||
edges: [],
|
||||
reactFlowProps: {
|
||||
nodeTypes: {
|
||||
errorHandleNode: ErrorHandleNodeHarness,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('error-handle path', () => {
|
||||
beforeAll(() => {
|
||||
class MockDOMMatrixReadOnly {
|
||||
inverse() {
|
||||
return this
|
||||
}
|
||||
|
||||
transformPoint(point: { x: number, y: number }) {
|
||||
return point
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'DOMMatrixReadOnly', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MockDOMMatrixReadOnly,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDocLink.mockImplementation((path: string) => `https://docs.example.com${path}`)
|
||||
mockUseDefaultValue.mockReturnValue({
|
||||
handleFormChange: vi.fn(),
|
||||
})
|
||||
mockUseErrorHandle.mockReturnValue({
|
||||
collapsed: false,
|
||||
setCollapsed: vi.fn(),
|
||||
handleErrorHandleTypeChange: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'DOMMatrixReadOnly', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalDOMMatrixReadOnly,
|
||||
})
|
||||
})
|
||||
|
||||
// The error-handle leaf components should expose selectable strategies and contextual help.
|
||||
describe('Leaf Components', () => {
|
||||
it('should render the fail-branch card with the resolved learn-more link', () => {
|
||||
render(<FailBranchCard />)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
|
||||
})
|
||||
|
||||
it('should render string forms and surface array forms in the default value editor', () => {
|
||||
const onFormChange = vi.fn()
|
||||
render(
|
||||
<DefaultValue
|
||||
forms={[
|
||||
{ key: 'message', type: VarType.string, value: 'hello' },
|
||||
{ key: 'items', type: VarType.arrayString, value: '["a"]' },
|
||||
]}
|
||||
onFormChange={onFormChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated' } })
|
||||
|
||||
expect(onFormChange).toHaveBeenCalledWith({
|
||||
key: 'message',
|
||||
type: VarType.string,
|
||||
value: 'updated',
|
||||
})
|
||||
expect(screen.getByText('items')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle the selector popup and report the selected strategy', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelected = vi.fn()
|
||||
render(
|
||||
<ErrorHandleTypeSelector
|
||||
value={ErrorHandleTypeEnum.none}
|
||||
onSelected={onSelected}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.title'))
|
||||
|
||||
expect(onSelected).toHaveBeenCalledWith(ErrorHandleTypeEnum.defaultValue)
|
||||
})
|
||||
|
||||
it('should render the error tip only when a strategy exists', () => {
|
||||
const { rerender, container } = render(<ErrorHandleTip />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.failBranch} />)
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog')).toBeInTheDocument()
|
||||
|
||||
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.defaultValue} />)
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.inLog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// The container components should show the correct branch card or default-value editor and propagate actions.
|
||||
describe('Containers', () => {
|
||||
it('should render the fail-branch panel body when the strategy is active', () => {
|
||||
render(
|
||||
<ErrorHandleOnPanel
|
||||
id="node-1"
|
||||
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the default-value panel body and delegate form updates', () => {
|
||||
const handleFormChange = vi.fn()
|
||||
mockUseDefaultValue.mockReturnValue({ handleFormChange })
|
||||
render(
|
||||
<ErrorHandleOnPanel
|
||||
id="node-1"
|
||||
data={baseData({
|
||||
error_strategy: ErrorHandleTypeEnum.defaultValue,
|
||||
default_value: [{ key: 'answer', type: VarType.string, value: 'draft' }],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'next' } })
|
||||
|
||||
expect(handleFormChange).toHaveBeenCalledWith(
|
||||
{ key: 'answer', type: VarType.string, value: 'next' },
|
||||
expect.objectContaining({ error_strategy: ErrorHandleTypeEnum.defaultValue }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide the panel body when the hook reports a collapsed section', () => {
|
||||
mockUseErrorHandle.mockReturnValue({
|
||||
collapsed: true,
|
||||
setCollapsed: vi.fn(),
|
||||
handleErrorHandleTypeChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<ErrorHandleOnPanel
|
||||
id="node-1"
|
||||
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the default-value node badge', () => {
|
||||
renderWorkflowFlowComponent(
|
||||
<ErrorHandleOnNode
|
||||
id="node-1"
|
||||
data={baseData({
|
||||
error_strategy: ErrorHandleTypeEnum.defaultValue,
|
||||
})}
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the fail-branch node badge when the node throws an exception', () => {
|
||||
const { container } = renderErrorHandleNode(baseData({
|
||||
error_strategy: ErrorHandleTypeEnum.failBranch,
|
||||
_runningStatus: NodeRunningStatus.Exception,
|
||||
}))
|
||||
|
||||
return waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()
|
||||
expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Add from '../add'
|
||||
import InputField from '../index'
|
||||
|
||||
describe('InputField', () => {
|
||||
@@ -15,12 +14,5 @@ describe('InputField', () => {
|
||||
expect(screen.getAllByText('input field')).toHaveLength(2)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the standalone add action button', () => {
|
||||
const { container } = render(<Add />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,47 +1,13 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Box, BoxGroup, BoxGroupField, Field, Group, GroupField } from '../index'
|
||||
import { BoxGroupField, FieldTitle } from '../index'
|
||||
|
||||
describe('layout index', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The layout primitives should preserve their composition contracts and collapse behavior.
|
||||
// The barrel exports should compose the public layout primitives without extra wrappers.
|
||||
describe('Rendering', () => {
|
||||
it('should render Box and Group with optional border styles', () => {
|
||||
render(
|
||||
<div>
|
||||
<Box withBorderBottom className="box-test">Box content</Box>
|
||||
<Group withBorderBottom className="group-test">Group content</Group>
|
||||
</div>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Box content')).toHaveClass('border-b', 'box-test')
|
||||
expect(screen.getByText('Group content')).toHaveClass('border-b', 'group-test')
|
||||
})
|
||||
|
||||
it('should render BoxGroup and GroupField with nested children', () => {
|
||||
render(
|
||||
<div>
|
||||
<BoxGroup>Inside box group</BoxGroup>
|
||||
<GroupField
|
||||
fieldProps={{
|
||||
fieldTitleProps: {
|
||||
title: 'Grouped field',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Group field body
|
||||
</GroupField>
|
||||
</div>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Inside box group')).toBeInTheDocument()
|
||||
expect(screen.getByText('Grouped field')).toBeInTheDocument()
|
||||
expect(screen.getByText('Group field body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render BoxGroupField from the barrel export', () => {
|
||||
render(
|
||||
<BoxGroupField
|
||||
@@ -59,23 +25,10 @@ describe('layout index', () => {
|
||||
expect(screen.getByText('Body content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collapse and expand Field children when supportCollapse is enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Field
|
||||
supportCollapse
|
||||
fieldTitleProps={{ title: 'Advanced' }}
|
||||
>
|
||||
<div>Extra details</div>
|
||||
</Field>,
|
||||
)
|
||||
it('should render FieldTitle from the barrel export', () => {
|
||||
render(<FieldTitle title="Advanced" subTitle="Extra details" />)
|
||||
|
||||
expect(screen.getByText('Extra details')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Advanced'))
|
||||
expect(screen.queryByText('Extra details')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Advanced'))
|
||||
expect(screen.getByText('Advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extra details')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { PromptEditorProps } from '@/app/components/base/prompt-editor'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { render } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import MixedVariableTextInput from '../index'
|
||||
|
||||
let capturedPromptEditorProps: PromptEditorProps[] = []
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: ({
|
||||
editable,
|
||||
value,
|
||||
workflowVariableBlock,
|
||||
onChange,
|
||||
}: PromptEditorProps) => {
|
||||
capturedPromptEditorProps.push({
|
||||
editable,
|
||||
value,
|
||||
onChange,
|
||||
workflowVariableBlock,
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-testid="prompt-editor">
|
||||
<div data-testid="editable-flag">{editable ? 'editable' : 'readonly'}</div>
|
||||
<div data-testid="value-flag">{value || 'empty'}</div>
|
||||
<button type="button" onClick={() => onChange?.('updated text')}>trigger-change</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('MixedVariableTextInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedPromptEditorProps = []
|
||||
})
|
||||
|
||||
it('should pass workflow variable metadata to the prompt editor and include system variables for start nodes', () => {
|
||||
const nodesOutputVars: NodeOutPutVar[] = [{
|
||||
nodeId: 'node-1',
|
||||
title: 'Question Node',
|
||||
vars: [],
|
||||
}]
|
||||
const availableNodes: Node[] = [
|
||||
{
|
||||
id: 'start-node',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Start Node',
|
||||
desc: 'Start description',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'llm-node',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: 'LLM Node',
|
||||
desc: 'LLM description',
|
||||
type: BlockEnum.LLM,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<MixedVariableTextInput
|
||||
nodesOutputVars={nodesOutputVars}
|
||||
availableNodes={availableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
const latestProps = capturedPromptEditorProps.at(-1)
|
||||
|
||||
expect(latestProps?.editable).toBe(true)
|
||||
expect(latestProps?.workflowVariableBlock?.variables).toHaveLength(1)
|
||||
expect(latestProps?.workflowVariableBlock?.workflowNodesMap).toEqual({
|
||||
'start-node': {
|
||||
title: 'Start Node',
|
||||
type: 'start',
|
||||
},
|
||||
'sys': {
|
||||
title: 'workflow.blocks.start',
|
||||
type: 'start',
|
||||
},
|
||||
'llm-node': {
|
||||
title: 'LLM Node',
|
||||
type: 'llm',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should forward read-only state, current value, and change callbacks', async () => {
|
||||
const onChange = vi.fn()
|
||||
const { findByRole, getByTestId } = render(
|
||||
<MixedVariableTextInput
|
||||
readOnly
|
||||
value="seed value"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getByTestId('editable-flag')).toHaveTextContent('readonly')
|
||||
expect(getByTestId('value-flag')).toHaveTextContent('seed value')
|
||||
|
||||
const changeButton = await findByRole('button', { name: 'trigger-change' })
|
||||
changeButton.click()
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('updated text')
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
|
||||
import Placeholder from '../placeholder'
|
||||
|
||||
const mockEditorUpdate = vi.fn((callback: () => void) => callback())
|
||||
const mockDispatchCommand = vi.fn()
|
||||
const mockInsertNodes = vi.fn()
|
||||
const mockTextNode = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
update: mockEditorUpdate,
|
||||
dispatchCommand: mockDispatchCommand,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext', () => ({
|
||||
useLexicalComposerContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('lexical', () => ({
|
||||
$insertNodes: vi.fn(),
|
||||
FOCUS_COMMAND: 'focus-command',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor/plugins/custom-text/node', () => ({
|
||||
CustomTextNode: class MockCustomTextNode {
|
||||
value: string
|
||||
|
||||
constructor(value: string) {
|
||||
this.value = value
|
||||
mockTextNode(value)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Mixed variable placeholder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useLexicalComposerContext).mockReturnValue(lexicalContextValue)
|
||||
vi.mocked($insertNodes).mockImplementation(nodes => mockInsertNodes(nodes))
|
||||
})
|
||||
|
||||
it('should insert an empty text node and focus the editor when the placeholder background is clicked', () => {
|
||||
const parentClick = vi.fn()
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<Placeholder />
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.nodes.tool.insertPlaceholder1'))
|
||||
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
expect(mockTextNode).toHaveBeenCalledWith('')
|
||||
expect(mockInsertNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
|
||||
})
|
||||
|
||||
it('should insert a slash shortcut from the highlighted action and prevent the native mouse down behavior', () => {
|
||||
render(<Placeholder />)
|
||||
|
||||
const shortcut = screen.getByText('workflow.nodes.tool.insertPlaceholder2')
|
||||
const event = createEvent.mouseDown(shortcut)
|
||||
fireEvent(shortcut, event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(mockTextNode).toHaveBeenCalledWith('/')
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
|
||||
})
|
||||
})
|
||||
@@ -1,268 +0,0 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import { FlowType } from '@/types/common'
|
||||
import ChangeBlock from '../change-block'
|
||||
import PanelOperatorPopup from '../panel-operator-popup'
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => (
|
||||
<div>
|
||||
<div>{trigger()}</div>
|
||||
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
|
||||
<div>{`show-start:${String(showStartTab)}`}</div>
|
||||
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
|
||||
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
|
||||
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
|
||||
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodeMetaData: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks-store', () => ({
|
||||
useHooksStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllWorkflowTools: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseHooksStore = vi.mocked(useHooksStore)
|
||||
const mockUseNodes = vi.mocked(useNodes)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
|
||||
describe('panel-operator details', () => {
|
||||
const handleNodeChange = vi.fn()
|
||||
const handleNodeDelete = vi.fn()
|
||||
const handleNodesDuplicate = vi.fn()
|
||||
const handleNodeSelect = vi.fn()
|
||||
const handleNodesCopy = vi.fn()
|
||||
const handleNodeDataUpdate = vi.fn()
|
||||
const handleSyncWorkflowDraft = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [BlockEnum.HttpRequest],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
})),
|
||||
availablePrevBlocks: [BlockEnum.HttpRequest],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
mockUseNodeDataUpdate.mockReturnValue({
|
||||
handleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
})
|
||||
mockUseNodeMetaData.mockReturnValue({
|
||||
isTypeFixed: false,
|
||||
isSingleton: false,
|
||||
isUndeletable: false,
|
||||
description: 'Node description',
|
||||
author: 'Dify',
|
||||
helpLinkUri: 'https://docs.example.com/node',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeChange,
|
||||
handleNodeDelete,
|
||||
handleNodesDuplicate,
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodesSyncDraft.mockReturnValue({
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
} as ReturnType<typeof useNodesSyncDraft>)
|
||||
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
|
||||
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
|
||||
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
|
||||
})
|
||||
|
||||
// The panel operator internals should expose block-change and popup actions using the real workflow popup composition.
|
||||
describe('Internal Actions', () => {
|
||||
it('should select a replacement block through ChangeBlock', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ChangeBlock
|
||||
nodeId="node-1"
|
||||
nodeData={{ type: BlockEnum.Code } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('select-http'))
|
||||
|
||||
expect(screen.getByText('available:http-request')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:')).toBeInTheDocument()
|
||||
expect(screen.getByText('force-start:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('allow-start:false')).toBeInTheDocument()
|
||||
expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
|
||||
})
|
||||
|
||||
it('should expose trigger and start-node specific block selector options', () => {
|
||||
mockUseAvailableBlocks.mockReturnValueOnce({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseIsChatMode.mockReturnValueOnce(true)
|
||||
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
|
||||
mockUseNodes.mockReturnValueOnce([] as any)
|
||||
|
||||
const { rerender } = render(
|
||||
<ChangeBlock
|
||||
nodeId="trigger-node"
|
||||
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('available:http-request')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument()
|
||||
expect(screen.getByText('allow-start:true')).toBeInTheDocument()
|
||||
|
||||
mockUseAvailableBlocks.mockReturnValueOnce({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [BlockEnum.Code],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [BlockEnum.Code],
|
||||
availableNextBlocks: [],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } }))
|
||||
mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
|
||||
|
||||
rerender(
|
||||
<ChangeBlock
|
||||
nodeId="start-node"
|
||||
nodeData={{ type: BlockEnum.Start } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('available:code')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:start-node')).toBeInTheDocument()
|
||||
expect(screen.getByText('force-start:true')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should run, copy, duplicate, delete, and expose the help link in the popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowFlowComponent(
|
||||
<PanelOperatorPopup
|
||||
id="node-1"
|
||||
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.panel.runThisStep'))
|
||||
await user.click(screen.getByText('workflow.common.copy'))
|
||||
await user.click(screen.getByText('workflow.common.duplicate'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
|
||||
expect(screen.getByRole('link', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
|
||||
})
|
||||
|
||||
it('should render workflow-tool and readonly popup variants', () => {
|
||||
mockUseAllWorkflowTools.mockReturnValueOnce({
|
||||
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
|
||||
} as any)
|
||||
|
||||
const { rerender } = renderWorkflowFlowComponent(
|
||||
<PanelOperatorPopup
|
||||
id="node-2"
|
||||
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink={false}
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodeMetaData.mockReturnValueOnce({
|
||||
isTypeFixed: true,
|
||||
isSingleton: true,
|
||||
isUndeletable: true,
|
||||
description: 'Read only node',
|
||||
author: 'Dify',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
|
||||
rerender(
|
||||
<PanelOperatorPopup
|
||||
id="node-3"
|
||||
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SupportVarInput from '../index'
|
||||
|
||||
describe('SupportVarInput', () => {
|
||||
it('should render plain text, highlighted variables, and preserved line breaks', () => {
|
||||
render(<SupportVarInput value={'Hello {{user_name}}\nWorld'} />)
|
||||
|
||||
expect(screen.getByText('World').closest('[title]')).toHaveAttribute('title', 'Hello {{user_name}}\nWorld')
|
||||
expect(screen.getByText('user_name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
expect(screen.getByText('World')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the focused child content and call onFocus when activated', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onFocus = vi.fn()
|
||||
|
||||
render(
|
||||
<SupportVarInput
|
||||
isFocus
|
||||
value="draft"
|
||||
onFocus={onFocus}
|
||||
>
|
||||
<input aria-label="inline-editor" />
|
||||
</SupportVarInput>,
|
||||
)
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: 'inline-editor' })
|
||||
expect(editor).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('draft')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(editor)
|
||||
|
||||
expect(onFocus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should keep the static preview visible when the input is read-only', () => {
|
||||
render(
|
||||
<SupportVarInput
|
||||
isFocus
|
||||
readonly
|
||||
value="readonly content"
|
||||
>
|
||||
<input aria-label="hidden-editor" />
|
||||
</SupportVarInput>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('textbox', { name: 'hidden-editor' })).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('readonly content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user