Compare commits

..
Author SHA1 Message Date
Joel fc9853f938 chore: split components 2025-12-29 16:02:52 +08:00
600 changed files with 2712 additions and 51904 deletions
@@ -28,14 +28,17 @@ import userEvent from '@testing-library/user-event'
// i18n (automatically mocked)
// WHY: Global mock in web/vitest.setup.ts is auto-loaded by Vitest setup
// The global mock provides: useTranslation, Trans, useMixedTranslation, useGetLanguage
// No explicit mock needed for most tests
//
// No explicit mock needed - it returns translation keys as-is
// Override only if custom translations are required:
// import { createReactI18nextMock } from '@/test/i18n-mock'
// vi.mock('react-i18next', () => createReactI18nextMock({
// 'my.custom.key': 'Custom Translation',
// 'button.save': 'Save',
// vi.mock('react-i18next', () => ({
// useTranslation: () => ({
// t: (key: string) => {
// const customTranslations: Record<string, string> = {
// 'my.custom.key': 'Custom Translation',
// }
// return customTranslations[key] || key
// },
// }),
// }))
// Router (if component uses useRouter, usePathname, useSearchParams)
@@ -52,29 +52,23 @@ Modules are not mocked automatically. Use `vi.mock` in test files, or add global
### 1. i18n (Auto-loaded via Global Mock)
A global mock is defined in `web/vitest.setup.ts` and is auto-loaded by Vitest setup.
**No explicit mock needed** for most tests - it returns translation keys as-is.
The global mock provides:
- `useTranslation` - returns translation keys with namespace prefix
- `Trans` component - renders i18nKey and components
- `useMixedTranslation` (from `@/app/components/plugins/marketplace/hooks`)
- `useGetLanguage` (from `@/context/i18n`) - returns `'en-US'`
**Default behavior**: Most tests should use the global mock (no local override needed).
**For custom translations**: Use the helper function from `@/test/i18n-mock`:
For tests requiring custom translations, override the mock:
```typescript
import { createReactI18nextMock } from '@/test/i18n-mock'
vi.mock('react-i18next', () => createReactI18nextMock({
'my.custom.key': 'Custom translation',
'button.save': 'Save',
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'my.custom.key': 'Custom translation',
}
return translations[key] || key
},
}),
}))
```
**Avoid**: Manually defining `useTranslation` mocks that just return the key - the global mock already does this.
### 2. Next.js Router
```typescript
-5
View File
@@ -110,11 +110,6 @@ jobs:
working-directory: ./web
run: pnpm run type-check:tsgo
- name: Web dead code check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run knip
superlinter:
name: SuperLinter
runs-on: ubuntu-latest
@@ -65,7 +65,7 @@ jobs:
- name: Generate i18n translations
if: env.FILES_CHANGED == 'true'
working-directory: ./web
run: pnpm run i18n:gen ${{ env.FILE_ARGS }}
run: pnpm run auto-gen-i18n ${{ env.FILE_ARGS }}
- name: Create Pull Request
if: env.FILES_CHANGED == 'true'
+1 -5
View File
@@ -1,8 +1,4 @@
exclude = [
"migrations/*",
".git",
".git/**",
]
exclude = ["migrations/*"]
line-length = 120
[format]
-58
View File
@@ -1,4 +1,3 @@
import re
import uuid
from typing import Literal
@@ -74,48 +73,6 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc
# XSS prevention: patterns that could lead to XSS attacks
# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc.
_XSS_PATTERNS = [
r"<script[^>]*>.*?</script>", # Script tags
r"<iframe\b[^>]*?(?:/>|>.*?</iframe>)", # Iframe tags (including self-closing)
r"javascript:", # JavaScript protocol
r"<svg[^>]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace)
r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc.
r"<object\b[^>]*(?:\s*/>|>.*?</object\s*>)", # Object tags (opening tag)
r"<embed[^>]*>", # Embed tags (self-closing)
r"<link[^>]*>", # Link tags with javascript
]
def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None:
"""
Validate that a string value doesn't contain potential XSS payloads.
Args:
value: The string value to validate
field_name: Name of the field for error messages
Returns:
The original value if safe
Raises:
ValueError: If the value contains XSS patterns
"""
if value is None:
return None
value_lower = value.lower()
for pattern in _XSS_PATTERNS:
if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE):
raise ValueError(
f"{field_name} contains invalid characters or patterns. "
"HTML tags, JavaScript, and other potentially dangerous content are not allowed."
)
return value
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
@@ -124,11 +81,6 @@ class CreateAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class UpdateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
@@ -139,11 +91,6 @@ class UpdateAppPayload(BaseModel):
use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon")
max_active_requests: int | None = Field(default=None, description="Maximum active requests")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class CopyAppPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied app")
@@ -152,11 +99,6 @@ class CopyAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class AppExportQuery(BaseModel):
include_secret: bool = Field(default=False, description="Include secrets in export")
+4 -9
View File
@@ -124,7 +124,7 @@ class OAuthCallback(Resource):
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}")
try:
account, oauth_new_user = _generate_account(provider, user_info)
account = _generate_account(provider, user_info)
except AccountNotFoundError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.")
except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError):
@@ -159,10 +159,7 @@ class OAuthCallback(Resource):
ip_address=extract_remote_ip(request),
)
base_url = dify_config.CONSOLE_WEB_URL
query_char = "&" if "?" in base_url else "?"
target_url = f"{base_url}{query_char}oauth_new_user={str(oauth_new_user).lower()}"
response = redirect(target_url)
response = redirect(f"{dify_config.CONSOLE_WEB_URL}")
set_access_token_to_cookie(request, response, token_pair.access_token)
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
@@ -180,10 +177,9 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
return account
def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account, bool]:
def _generate_account(provider: str, user_info: OAuthUserInfo):
# Get account by openid or email.
account = _get_account_by_openid_or_email(provider, user_info)
oauth_new_user = False
if account:
tenants = TenantService.get_join_tenants(account)
@@ -197,7 +193,6 @@ def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account,
tenant_was_created.send(new_tenant)
if not account:
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register:
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(user_info.email):
raise AccountRegisterError(
@@ -225,4 +220,4 @@ def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account,
# Link account
AccountService.link_account_integrate(provider, user_info.id, account)
return account, oauth_new_user
return account
@@ -3,12 +3,10 @@ import uuid
from flask import request
from flask_restx import Resource, marshal
from pydantic import BaseModel, Field
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import select
from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import ProviderNotInitializeError
@@ -145,29 +143,7 @@ class DatasetDocumentSegmentListApi(Resource):
query = query.where(DocumentSegment.hit_count >= hit_count_gte)
if keyword:
# Search in both content and keywords fields
# Use database-specific methods for JSON array search
if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql":
# PostgreSQL: Use jsonb_array_elements_text to properly handle Unicode/Chinese text
keywords_condition = func.array_to_string(
func.array(
select(func.jsonb_array_elements_text(cast(DocumentSegment.keywords, JSONB)))
.correlate(DocumentSegment)
.scalar_subquery()
),
",",
).ilike(f"%{keyword}%")
else:
# MySQL: Cast JSON to string for pattern matching
# MySQL stores Chinese text directly in JSON without Unicode escaping
keywords_condition = cast(DocumentSegment.keywords, String).ilike(f"%{keyword}%")
query = query.where(
or_(
DocumentSegment.content.ilike(f"%{keyword}%"),
keywords_condition,
)
)
query = query.where(DocumentSegment.content.ilike(f"%{keyword}%"))
if args.enabled.lower() != "all":
if args.enabled.lower() == "true":
@@ -20,6 +20,7 @@ from controllers.console.wraps import (
)
from core.db.session_factory import session_factory
from core.entities.mcp_provider import MCPAuthentication, MCPConfiguration
from core.helper.tool_provider_cache import ToolProviderListCache
from core.mcp.auth.auth_flow import auth, handle_callback
from core.mcp.error import MCPAuthError, MCPError, MCPRefreshTokenError
from core.mcp.mcp_client import MCPClient
@@ -986,6 +987,9 @@ class ToolProviderMCPApi(Resource):
# Best-effort: if initial fetch fails (e.g., auth required), return created provider as-is
logger.warning("Failed to fetch MCP tools after creation", exc_info=True)
# Final cache invalidation to ensure list views are up to date
ToolProviderListCache.invalidate_cache(tenant_id)
return jsonable_encoder(result)
@console_ns.expect(parser_mcp_put)
@@ -1032,6 +1036,9 @@ class ToolProviderMCPApi(Resource):
validation_result=validation_result,
)
# Invalidate cache AFTER transaction commits to avoid holding locks during Redis operations
ToolProviderListCache.invalidate_cache(current_tenant_id)
return {"result": "success"}
@console_ns.expect(parser_mcp_delete)
@@ -1046,6 +1053,9 @@ class ToolProviderMCPApi(Resource):
service = MCPToolManageService(session=session)
service.delete_provider(tenant_id=current_tenant_id, provider_id=args["provider_id"])
# Invalidate cache AFTER transaction commits to avoid holding locks during Redis operations
ToolProviderListCache.invalidate_cache(current_tenant_id)
return {"result": "success"}
@@ -1096,6 +1106,8 @@ class ToolMCPAuthApi(Resource):
credentials=provider_entity.credentials,
authed=True,
)
# Invalidate cache after updating credentials
ToolProviderListCache.invalidate_cache(tenant_id)
return {"result": "success"}
except MCPAuthError as e:
try:
@@ -1109,16 +1121,22 @@ class ToolMCPAuthApi(Resource):
with Session(db.engine) as session, session.begin():
service = MCPToolManageService(session=session)
response = service.execute_auth_actions(auth_result)
# Invalidate cache after auth actions may have updated provider state
ToolProviderListCache.invalidate_cache(tenant_id)
return response
except MCPRefreshTokenError as e:
with Session(db.engine) as session, session.begin():
service = MCPToolManageService(session=session)
service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id)
# Invalidate cache after clearing credentials
ToolProviderListCache.invalidate_cache(tenant_id)
raise ValueError(f"Failed to refresh token, please try to authorize again: {e}") from e
except (MCPError, ValueError) as e:
with Session(db.engine) as session, session.begin():
service = MCPToolManageService(session=session)
service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id)
# Invalidate cache after clearing credentials
ToolProviderListCache.invalidate_cache(tenant_id)
raise ValueError(f"Failed to connect to MCP server: {e}") from e
+58
View File
@@ -0,0 +1,58 @@
import json
import logging
from typing import Any, cast
from core.tools.entities.api_entities import ToolProviderTypeApiLiteral
from extensions.ext_redis import redis_client, redis_fallback
logger = logging.getLogger(__name__)
class ToolProviderListCache:
"""Cache for tool provider lists"""
CACHE_TTL = 300 # 5 minutes
@staticmethod
def _generate_cache_key(tenant_id: str, typ: ToolProviderTypeApiLiteral = None) -> str:
"""Generate cache key for tool providers list"""
type_filter = typ or "all"
return f"tool_providers:tenant_id:{tenant_id}:type:{type_filter}"
@staticmethod
@redis_fallback(default_return=None)
def get_cached_providers(tenant_id: str, typ: ToolProviderTypeApiLiteral = None) -> list[dict[str, Any]] | None:
"""Get cached tool providers"""
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
cached_data = redis_client.get(cache_key)
if cached_data:
try:
return json.loads(cached_data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
logger.warning("Failed to decode cached tool providers data")
return None
return None
@staticmethod
@redis_fallback()
def set_cached_providers(tenant_id: str, typ: ToolProviderTypeApiLiteral, providers: list[dict[str, Any]]):
"""Cache tool providers"""
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
redis_client.setex(cache_key, ToolProviderListCache.CACHE_TTL, json.dumps(providers))
@staticmethod
@redis_fallback()
def invalidate_cache(tenant_id: str, typ: ToolProviderTypeApiLiteral = None):
"""Invalidate cache for tool providers"""
if typ:
# Invalidate specific type cache
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
redis_client.delete(cache_key)
else:
# Invalidate all caches for this tenant
keys = ["builtin", "model", "api", "workflow", "mcp"]
pipeline = redis_client.pipeline()
for key in keys:
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, cast(ToolProviderTypeApiLiteral, key))
pipeline.delete(cache_key)
pipeline.execute()
+13 -31
View File
@@ -27,44 +27,26 @@ class CleanProcessor:
pattern = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
text = re.sub(pattern, "", text)
# Remove URL but keep Markdown image URLs and link URLs
# Replace the ENTIRE markdown link/image with a single placeholder to protect
# the link text (which might also be a URL) from being removed
markdown_link_pattern = r"\[([^\]]*)\]\((https?://[^)]+)\)"
markdown_image_pattern = r"!\[.*?\]\((https?://[^)]+)\)"
placeholders: list[tuple[str, str, str]] = [] # (type, text, url)
# Remove URL but keep Markdown image URLs
# First, temporarily replace Markdown image URLs with a placeholder
markdown_image_pattern = r"!\[.*?\]\((https?://[^\s)]+)\)"
placeholders: list[str] = []
def replace_markdown_with_placeholder(match, placeholders=placeholders):
link_type = "link"
link_text = match.group(1)
url = match.group(2)
placeholder = f"__MARKDOWN_PLACEHOLDER_{len(placeholders)}__"
placeholders.append((link_type, link_text, url))
return placeholder
def replace_image_with_placeholder(match, placeholders=placeholders):
link_type = "image"
def replace_with_placeholder(match, placeholders=placeholders):
url = match.group(1)
placeholder = f"__MARKDOWN_PLACEHOLDER_{len(placeholders)}__"
placeholders.append((link_type, "image", url))
return placeholder
placeholder = f"__MARKDOWN_IMAGE_URL_{len(placeholders)}__"
placeholders.append(url)
return f"![image]({placeholder})"
# Protect markdown links first
text = re.sub(markdown_link_pattern, replace_markdown_with_placeholder, text)
# Then protect markdown images
text = re.sub(markdown_image_pattern, replace_image_with_placeholder, text)
text = re.sub(markdown_image_pattern, replace_with_placeholder, text)
# Now remove all remaining URLs
url_pattern = r"https?://\S+"
url_pattern = r"https?://[^\s)]+"
text = re.sub(url_pattern, "", text)
# Restore the Markdown links and images
for i, (link_type, text_or_alt, url) in enumerate(placeholders):
placeholder = f"__MARKDOWN_PLACEHOLDER_{i}__"
if link_type == "link":
text = text.replace(placeholder, f"[{text_or_alt}]({url})")
else: # image
text = text.replace(placeholder, f"![{text_or_alt}]({url})")
# Finally, restore the Markdown image URLs
for i, url in enumerate(placeholders):
text = text.replace(f"__MARKDOWN_IMAGE_URL_{i}__", url)
return text
def filter_string(self, text):
+2 -20
View File
@@ -1,5 +1,4 @@
import concurrent.futures
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any
@@ -37,8 +36,6 @@ default_retrieval_model = {
"score_threshold_enabled": False,
}
logger = logging.getLogger(__name__)
class RetrievalService:
# Cache precompiled regular expressions to avoid repeated compilation
@@ -109,12 +106,7 @@ class RetrievalService:
)
)
if futures:
for future in concurrent.futures.as_completed(futures, timeout=3600):
if exceptions:
for f in futures:
f.cancel()
break
concurrent.futures.wait(futures, timeout=3600, return_when=concurrent.futures.ALL_COMPLETED)
if exceptions:
raise ValueError(";\n".join(exceptions))
@@ -218,7 +210,6 @@ class RetrievalService:
)
all_documents.extend(documents)
except Exception as e:
logger.error(e, exc_info=True)
exceptions.append(str(e))
@classmethod
@@ -312,7 +303,6 @@ class RetrievalService:
else:
all_documents.extend(documents)
except Exception as e:
logger.error(e, exc_info=True)
exceptions.append(str(e))
@classmethod
@@ -361,7 +351,6 @@ class RetrievalService:
else:
all_documents.extend(documents)
except Exception as e:
logger.error(e, exc_info=True)
exceptions.append(str(e))
@staticmethod
@@ -674,14 +663,7 @@ class RetrievalService:
document_ids_filter=document_ids_filter,
)
)
# Use as_completed for early error propagation - cancel remaining futures on first error
if futures:
for future in concurrent.futures.as_completed(futures, timeout=300):
if future.exception():
# Cancel remaining futures to avoid unnecessary waiting
for f in futures:
f.cancel()
break
concurrent.futures.wait(futures, timeout=300, return_when=concurrent.futures.ALL_COMPLETED)
if exceptions:
raise ValueError(";\n".join(exceptions))
+2 -2
View File
@@ -112,7 +112,7 @@ class ExtractProcessor:
if file_extension in {".xlsx", ".xls"}:
extractor = ExcelExtractor(file_path)
elif file_extension == ".pdf":
extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by)
extractor = PdfExtractor(file_path)
elif file_extension in {".md", ".markdown", ".mdx"}:
extractor = (
UnstructuredMarkdownExtractor(file_path, unstructured_api_url, unstructured_api_key)
@@ -148,7 +148,7 @@ class ExtractProcessor:
if file_extension in {".xlsx", ".xls"}:
extractor = ExcelExtractor(file_path)
elif file_extension == ".pdf":
extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by)
extractor = PdfExtractor(file_path)
elif file_extension in {".md", ".markdown", ".mdx"}:
extractor = MarkdownExtractor(file_path, autodetect_encoding=True)
elif file_extension in {".htm", ".html"}:
+6 -116
View File
@@ -1,57 +1,25 @@
"""Abstract interface for document loader implementations."""
import contextlib
import io
import logging
import uuid
from collections.abc import Iterator
import pypdfium2
import pypdfium2.raw as pdfium_c
from configs import dify_config
from core.rag.extractor.blob.blob import Blob
from core.rag.extractor.extractor_base import BaseExtractor
from core.rag.models.document import Document
from extensions.ext_database import db
from extensions.ext_storage import storage
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole
from models.model import UploadFile
logger = logging.getLogger(__name__)
class PdfExtractor(BaseExtractor):
"""
PdfExtractor is used to extract text and images from PDF files.
"""Load pdf files.
Args:
file_path: Path to the PDF file.
tenant_id: Workspace ID.
user_id: ID of the user performing the extraction.
file_cache_key: Optional cache key for the extracted text.
file_path: Path to the file to load.
"""
# Magic bytes for image format detection: (magic_bytes, extension, mime_type)
IMAGE_FORMATS = [
(b"\xff\xd8\xff", "jpg", "image/jpeg"),
(b"\x89PNG\r\n\x1a\n", "png", "image/png"),
(b"\x00\x00\x00\x0c\x6a\x50\x20\x20\x0d\x0a\x87\x0a", "jp2", "image/jp2"),
(b"GIF8", "gif", "image/gif"),
(b"BM", "bmp", "image/bmp"),
(b"II*\x00", "tiff", "image/tiff"),
(b"MM\x00*", "tiff", "image/tiff"),
(b"II+\x00", "tiff", "image/tiff"),
(b"MM\x00+", "tiff", "image/tiff"),
]
MAX_MAGIC_LEN = max(len(m) for m, _, _ in IMAGE_FORMATS)
def __init__(self, file_path: str, tenant_id: str, user_id: str, file_cache_key: str | None = None):
"""Initialize PdfExtractor."""
def __init__(self, file_path: str, file_cache_key: str | None = None):
"""Initialize with file path."""
self._file_path = file_path
self._tenant_id = tenant_id
self._user_id = user_id
self._file_cache_key = file_cache_key
def extract(self) -> list[Document]:
@@ -82,6 +50,7 @@ class PdfExtractor(BaseExtractor):
def parse(self, blob: Blob) -> Iterator[Document]:
"""Lazily parse the blob."""
import pypdfium2 # type: ignore
with blob.as_bytes_io() as file_path:
pdf_reader = pypdfium2.PdfDocument(file_path, autoclose=True)
@@ -90,87 +59,8 @@ class PdfExtractor(BaseExtractor):
text_page = page.get_textpage()
content = text_page.get_text_range()
text_page.close()
image_content = self._extract_images(page)
if image_content:
content += "\n" + image_content
page.close()
metadata = {"source": blob.source, "page": page_number}
yield Document(page_content=content, metadata=metadata)
finally:
pdf_reader.close()
def _extract_images(self, page) -> str:
"""
Extract images from a PDF page, save them to storage and database,
and return markdown image links.
Args:
page: pypdfium2 page object.
Returns:
Markdown string containing links to the extracted images.
"""
image_content = []
upload_files = []
base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL
try:
image_objects = page.get_objects(filter=(pdfium_c.FPDF_PAGEOBJ_IMAGE,))
for obj in image_objects:
try:
# Extract image bytes
img_byte_arr = io.BytesIO()
# Extract DCTDecode (JPEG) and JPXDecode (JPEG 2000) images directly
# Fallback to png for other formats
obj.extract(img_byte_arr, fb_format="png")
img_bytes = img_byte_arr.getvalue()
if not img_bytes:
continue
header = img_bytes[: self.MAX_MAGIC_LEN]
image_ext = None
mime_type = None
for magic, ext, mime in self.IMAGE_FORMATS:
if header.startswith(magic):
image_ext = ext
mime_type = mime
break
if not image_ext or not mime_type:
continue
file_uuid = str(uuid.uuid4())
file_key = "image_files/" + self._tenant_id + "/" + file_uuid + "." + image_ext
storage.save(file_key, img_bytes)
# save file to db
upload_file = UploadFile(
tenant_id=self._tenant_id,
storage_type=dify_config.STORAGE_TYPE,
key=file_key,
name=file_key,
size=len(img_bytes),
extension=image_ext,
mime_type=mime_type,
created_by=self._user_id,
created_by_role=CreatorUserRole.ACCOUNT,
created_at=naive_utc_now(),
used=True,
used_by=self._user_id,
used_at=naive_utc_now(),
)
upload_files.append(upload_file)
image_content.append(f"![image]({base_url}/files/{upload_file.id}/file-preview)")
except Exception as e:
logger.warning("Failed to extract image from PDF: %s", e)
continue
except Exception as e:
logger.warning("Failed to get objects from PDF page: %s", e)
if upload_files:
db.session.add_all(upload_files)
db.session.commit()
return "\n".join(image_content)
+34 -70
View File
@@ -516,9 +516,6 @@ class DatasetRetrieval:
].embedding_model_provider
weights["vector_setting"]["embedding_model_name"] = available_datasets[0].embedding_model
with measure_time() as timer:
cancel_event = threading.Event()
thread_exceptions: list[Exception] = []
if query:
query_thread = threading.Thread(
target=self._multiple_retrieve_thread,
@@ -537,8 +534,6 @@ class DatasetRetrieval:
"score_threshold": score_threshold,
"query": query,
"attachment_id": None,
"cancel_event": cancel_event,
"thread_exceptions": thread_exceptions,
},
)
all_threads.append(query_thread)
@@ -562,25 +557,12 @@ class DatasetRetrieval:
"score_threshold": score_threshold,
"query": None,
"attachment_id": attachment_id,
"cancel_event": cancel_event,
"thread_exceptions": thread_exceptions,
},
)
all_threads.append(attachment_thread)
attachment_thread.start()
# Poll threads with short timeout to detect errors quickly (fail-fast)
while any(t.is_alive() for t in all_threads):
for thread in all_threads:
thread.join(timeout=0.1)
if thread_exceptions:
cancel_event.set()
break
if thread_exceptions:
break
if thread_exceptions:
raise thread_exceptions[0]
for thread in all_threads:
thread.join()
self._on_query(query, attachment_ids, dataset_ids, app_id, user_from, user_id)
if all_documents:
@@ -1422,53 +1404,40 @@ class DatasetRetrieval:
score_threshold: float,
query: str | None,
attachment_id: str | None,
cancel_event: threading.Event | None = None,
thread_exceptions: list[Exception] | None = None,
):
try:
with flask_app.app_context():
threads = []
all_documents_item: list[Document] = []
index_type = None
for dataset in available_datasets:
# Check for cancellation signal
if cancel_event and cancel_event.is_set():
break
index_type = dataset.indexing_technique
document_ids_filter = None
if dataset.provider != "external":
if metadata_condition and not metadata_filter_document_ids:
with flask_app.app_context():
threads = []
all_documents_item: list[Document] = []
index_type = None
for dataset in available_datasets:
index_type = dataset.indexing_technique
document_ids_filter = None
if dataset.provider != "external":
if metadata_condition and not metadata_filter_document_ids:
continue
if metadata_filter_document_ids:
document_ids = metadata_filter_document_ids.get(dataset.id, [])
if document_ids:
document_ids_filter = document_ids
else:
continue
if metadata_filter_document_ids:
document_ids = metadata_filter_document_ids.get(dataset.id, [])
if document_ids:
document_ids_filter = document_ids
else:
continue
retrieval_thread = threading.Thread(
target=self._retriever,
kwargs={
"flask_app": flask_app,
"dataset_id": dataset.id,
"query": query,
"top_k": top_k,
"all_documents": all_documents_item,
"document_ids_filter": document_ids_filter,
"metadata_condition": metadata_condition,
"attachment_ids": [attachment_id] if attachment_id else None,
},
)
threads.append(retrieval_thread)
retrieval_thread.start()
# Poll threads with short timeout to respond quickly to cancellation
while any(t.is_alive() for t in threads):
for thread in threads:
thread.join(timeout=0.1)
if cancel_event and cancel_event.is_set():
break
if cancel_event and cancel_event.is_set():
break
retrieval_thread = threading.Thread(
target=self._retriever,
kwargs={
"flask_app": flask_app,
"dataset_id": dataset.id,
"query": query,
"top_k": top_k,
"all_documents": all_documents_item,
"document_ids_filter": document_ids_filter,
"metadata_condition": metadata_condition,
"attachment_ids": [attachment_id] if attachment_id else None,
},
)
threads.append(retrieval_thread)
retrieval_thread.start()
for thread in threads:
thread.join()
if reranking_enable:
# do rerank for searched documents
@@ -1501,8 +1470,3 @@ class DatasetRetrieval:
all_documents_item = all_documents_item[:top_k] if top_k else all_documents_item
if all_documents_item:
all_documents.extend(all_documents_item)
except Exception as e:
if cancel_event:
cancel_event.set()
if thread_exceptions is not None:
thread_exceptions.append(e)
+1 -1
View File
@@ -378,7 +378,7 @@ class ApiBasedToolSchemaParser:
@staticmethod
def auto_parse_to_tool_bundle(
content: str, extra_info: dict | None = None, warning: dict | None = None
) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]:
) -> tuple[list[ApiToolBundle], str]:
"""
auto parse to tool bundle
@@ -4,7 +4,6 @@ import re
def remove_leading_symbols(text: str) -> str:
"""
Remove leading punctuation or symbols from the given text.
Preserves markdown links like [text](url) at the start.
Args:
text (str): The input text to process.
@@ -12,11 +11,6 @@ def remove_leading_symbols(text: str) -> str:
Returns:
str: The text with leading punctuation or symbols removed.
"""
# Check if text starts with a markdown link - preserve it
markdown_link_pattern = r"^\[([^\]]+)\]\((https?://[^)]+)\)"
if re.match(markdown_link_pattern, text):
return text
# Match Unicode ranges for punctuation and symbols
# FIXME this pattern is confused quick fix for #11868 maybe refactor it later
pattern = r'^[\[\]\u2000-\u2025\u2027-\u206F\u2E00-\u2E7F\u3000-\u300F\u3011-\u303F"#$%&\'()*+,./:;<=>?@^_`~]+'
+2 -1
View File
@@ -54,6 +54,7 @@ class WorkflowToolProviderController(ToolProviderController):
raise ValueError("app not found")
user = session.get(Account, db_provider.user_id) if db_provider.user_id else None
controller = WorkflowToolProviderController(
entity=ToolProviderEntity(
identity=ToolProviderIdentity(
@@ -66,7 +67,7 @@ class WorkflowToolProviderController(ToolProviderController):
credentials_schema=[],
plugin_id=None,
),
provider_id=db_provider.id,
provider_id="",
)
controller.tools = [
@@ -60,7 +60,6 @@ class SkipPropagator:
if edge_states["has_taken"]:
# Enqueue node
self._state_manager.enqueue_node(downstream_node_id)
self._state_manager.start_execution(downstream_node_id)
return
# All edges are skipped, propagate skip to this node
+2 -1
View File
@@ -12,8 +12,9 @@ from dify_app import DifyApp
def _get_celery_ssl_options() -> dict[str, Any] | None:
"""Get SSL configuration for Celery broker/backend connections."""
# Use REDIS_USE_SSL for consistency with the main Redis client
# Only apply SSL if we're using Redis as broker/backend
if not dify_config.BROKER_USE_SSL:
if not dify_config.REDIS_USE_SSL:
return None
# Check if Celery is actually using Redis
-5
View File
@@ -16,11 +16,6 @@ celery_redis = Redis(
port=redis_config.get("port") or 6379,
password=redis_config.get("password") or None,
db=int(redis_config.get("virtual_host")) if redis_config.get("virtual_host") else 1,
ssl=bool(dify_config.BROKER_USE_SSL),
ssl_ca_certs=dify_config.REDIS_SSL_CA_CERTS if dify_config.BROKER_USE_SSL else None,
ssl_cert_reqs=getattr(dify_config, "REDIS_SSL_CERT_REQS", None) if dify_config.BROKER_USE_SSL else None,
ssl_certfile=getattr(dify_config, "REDIS_SSL_CERTFILE", None) if dify_config.BROKER_USE_SSL else None,
ssl_keyfile=getattr(dify_config, "REDIS_SSL_KEYFILE", None) if dify_config.BROKER_USE_SSL else None,
)
logger = logging.getLogger(__name__)
+3 -105
View File
@@ -1,4 +1,3 @@
import json
import logging
import os
from collections.abc import Sequence
@@ -32,11 +31,6 @@ class BillingService:
compliance_download_rate_limiter = RateLimiter("compliance_download_rate_limiter", 4, 60)
# Redis key prefix for tenant plan cache
_PLAN_CACHE_KEY_PREFIX = "tenant_plan:"
# Cache TTL: 10 minutes
_PLAN_CACHE_TTL = 600
@classmethod
def get_info(cls, tenant_id: str):
params = {"tenant_id": tenant_id}
@@ -278,110 +272,14 @@ class BillingService:
data = resp.get("data", {})
for tenant_id, plan in data.items():
try:
subscription_plan = subscription_adapter.validate_python(plan)
results[tenant_id] = subscription_plan
except Exception:
logger.exception(
"get_plan_bulk: failed to validate subscription plan for tenant(%s)", tenant_id
)
continue
subscription_plan = subscription_adapter.validate_python(plan)
results[tenant_id] = subscription_plan
except Exception:
logger.exception("get_plan_bulk: failed to fetch billing info batch for tenants: %s", chunk)
logger.exception("Failed to fetch billing info batch for tenants: %s", chunk)
continue
return results
@classmethod
def _make_plan_cache_key(cls, tenant_id: str) -> str:
return f"{cls._PLAN_CACHE_KEY_PREFIX}{tenant_id}"
@classmethod
def get_plan_bulk_with_cache(cls, tenant_ids: Sequence[str]) -> dict[str, SubscriptionPlan]:
"""
Bulk fetch billing subscription plan with cache to reduce billing API loads in batch job scenarios.
NOTE: if you want to high data consistency, use get_plan_bulk instead.
Returns:
Mapping of tenant_id -> {plan: str, expiration_date: int}
"""
tenant_plans: dict[str, SubscriptionPlan] = {}
if not tenant_ids:
return tenant_plans
subscription_adapter = TypeAdapter(SubscriptionPlan)
# Step 1: Batch fetch from Redis cache using mget
redis_keys = [cls._make_plan_cache_key(tenant_id) for tenant_id in tenant_ids]
try:
cached_values = redis_client.mget(redis_keys)
if len(cached_values) != len(tenant_ids):
raise Exception(
"get_plan_bulk_with_cache: unexpected error: redis mget failed: cached values length mismatch"
)
# Map cached values back to tenant_ids
cache_misses: list[str] = []
for tenant_id, cached_value in zip(tenant_ids, cached_values):
if cached_value:
try:
# Redis returns bytes, decode to string and parse JSON
json_str = cached_value.decode("utf-8") if isinstance(cached_value, bytes) else cached_value
plan_dict = json.loads(json_str)
subscription_plan = subscription_adapter.validate_python(plan_dict)
tenant_plans[tenant_id] = subscription_plan
except Exception:
logger.exception(
"get_plan_bulk_with_cache: process tenant(%s) failed, add to cache misses", tenant_id
)
cache_misses.append(tenant_id)
else:
cache_misses.append(tenant_id)
logger.info(
"get_plan_bulk_with_cache: cache hits=%s, cache misses=%s",
len(tenant_plans),
len(cache_misses),
)
except Exception:
logger.exception("get_plan_bulk_with_cache: redis mget failed, falling back to API")
cache_misses = list(tenant_ids)
# Step 2: Fetch missing plans from billing API
if cache_misses:
bulk_plans = BillingService.get_plan_bulk(cache_misses)
if bulk_plans:
plans_to_cache: dict[str, SubscriptionPlan] = {}
for tenant_id, subscription_plan in bulk_plans.items():
tenant_plans[tenant_id] = subscription_plan
plans_to_cache[tenant_id] = subscription_plan
# Step 3: Batch update Redis cache using pipeline
if plans_to_cache:
try:
pipe = redis_client.pipeline()
for tenant_id, subscription_plan in plans_to_cache.items():
redis_key = cls._make_plan_cache_key(tenant_id)
# Serialize dict to JSON string
json_str = json.dumps(subscription_plan)
pipe.setex(redis_key, cls._PLAN_CACHE_TTL, json_str)
pipe.execute()
logger.info(
"get_plan_bulk_with_cache: cached %s new tenant plans to Redis",
len(plans_to_cache),
)
except Exception:
logger.exception("get_plan_bulk_with_cache: redis pipeline failed")
return tenant_plans
@classmethod
def get_expired_subscription_cleanup_whitelist(cls) -> Sequence[str]:
resp = cls._send_request("GET", "/subscription/cleanup/whitelist")
+22 -8
View File
@@ -7,6 +7,7 @@ from httpx import get
from sqlalchemy import select
from core.entities.provider_entities import ProviderConfig
from core.helper.tool_provider_cache import ToolProviderListCache
from core.model_runtime.utils.encoders import jsonable_encoder
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.custom_tool.provider import ApiToolProviderController
@@ -85,9 +86,7 @@ class ApiToolManageService:
raise ValueError(f"invalid schema: {str(e)}")
@staticmethod
def convert_schema_to_tool_bundles(
schema: str, extra_info: dict | None = None
) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]:
def convert_schema_to_tool_bundles(schema: str, extra_info: dict | None = None) -> tuple[list[ApiToolBundle], str]:
"""
convert schema to tool bundles
@@ -105,7 +104,7 @@ class ApiToolManageService:
provider_name: str,
icon: dict,
credentials: dict,
schema_type: ApiProviderSchemaType,
schema_type: str,
schema: str,
privacy_policy: str,
custom_disclaimer: str,
@@ -114,6 +113,9 @@ class ApiToolManageService:
"""
create api tool provider
"""
if schema_type not in [member.value for member in ApiProviderSchemaType]:
raise ValueError(f"invalid schema type {schema}")
provider_name = provider_name.strip()
# check if the provider exists
@@ -176,6 +178,9 @@ class ApiToolManageService:
# update labels
ToolLabelManager.update_tool_labels(provider_controller, labels)
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
return {"result": "success"}
@staticmethod
@@ -240,15 +245,18 @@ class ApiToolManageService:
original_provider: str,
icon: dict,
credentials: dict,
_schema_type: ApiProviderSchemaType,
schema_type: str,
schema: str,
privacy_policy: str | None,
privacy_policy: str,
custom_disclaimer: str,
labels: list[str],
):
"""
update api tool provider
"""
if schema_type not in [member.value for member in ApiProviderSchemaType]:
raise ValueError(f"invalid schema type {schema}")
provider_name = provider_name.strip()
# check if the provider exists
@@ -273,7 +281,7 @@ class ApiToolManageService:
provider.icon = json.dumps(icon)
provider.schema = schema
provider.description = extra_info.get("description", "")
provider.schema_type_str = schema_type
provider.schema_type_str = ApiProviderSchemaType.OPENAPI
provider.tools_str = json.dumps(jsonable_encoder(tool_bundles))
provider.privacy_policy = privacy_policy
provider.custom_disclaimer = custom_disclaimer
@@ -314,6 +322,9 @@ class ApiToolManageService:
# update labels
ToolLabelManager.update_tool_labels(provider_controller, labels)
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
return {"result": "success"}
@staticmethod
@@ -336,6 +347,9 @@ class ApiToolManageService:
db.session.delete(provider)
db.session.commit()
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
return {"result": "success"}
@staticmethod
@@ -352,7 +366,7 @@ class ApiToolManageService:
tool_name: str,
credentials: dict,
parameters: dict,
schema_type: ApiProviderSchemaType,
schema_type: str,
schema: str,
):
"""
@@ -12,6 +12,7 @@ from constants import HIDDEN_VALUE, UNKNOWN_VALUE
from core.helper.name_generator import generate_incremental_name
from core.helper.position_helper import is_filtered
from core.helper.provider_cache import NoOpProviderCredentialCache, ToolProviderCredentialsCache
from core.helper.tool_provider_cache import ToolProviderListCache
from core.plugin.entities.plugin_daemon import CredentialType
from core.tools.builtin_tool.provider import BuiltinToolProviderController
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
@@ -204,6 +205,9 @@ class BuiltinToolManageService:
db_provider.name = name
session.commit()
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
except Exception as e:
session.rollback()
raise ValueError(str(e))
@@ -286,6 +290,8 @@ class BuiltinToolManageService:
session.rollback()
raise ValueError(str(e))
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id, "builtin")
return {"result": "success"}
@staticmethod
@@ -403,6 +409,9 @@ class BuiltinToolManageService:
)
cache.delete()
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
return {"result": "success"}
@staticmethod
@@ -425,6 +434,8 @@ class BuiltinToolManageService:
target_provider.is_default = True
session.commit()
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
return {"result": "success"}
@staticmethod
@@ -1,5 +1,6 @@
import logging
from core.helper.tool_provider_cache import ToolProviderListCache
from core.tools.entities.api_entities import ToolProviderTypeApiLiteral
from core.tools.tool_manager import ToolManager
from services.tools.tools_transform_service import ToolTransformService
@@ -15,6 +16,14 @@ class ToolCommonService:
:return: the list of tool providers
"""
# Try to get from cache first
cached_result = ToolProviderListCache.get_cached_providers(tenant_id, typ)
if cached_result is not None:
logger.debug("Returning cached tool providers for tenant %s, type %s", tenant_id, typ)
return cached_result
# Cache miss - fetch from database
logger.debug("Cache miss for tool providers, fetching from database for tenant %s, type %s", tenant_id, typ)
providers = ToolManager.list_providers_from_api(user_id, tenant_id, typ)
# add icon
@@ -23,4 +32,7 @@ class ToolCommonService:
result = [provider.to_dict() for provider in providers]
# Cache the result
ToolProviderListCache.set_cached_providers(tenant_id, typ, result)
return result
@@ -5,8 +5,9 @@ from datetime import datetime
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from core.helper.tool_provider_cache import ToolProviderListCache
from core.model_runtime.utils.encoders import jsonable_encoder
from core.tools.__base.tool_provider import ToolProviderController
from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
@@ -85,13 +86,17 @@ class WorkflowToolManageService:
except Exception as e:
raise ValueError(str(e))
with Session(db.engine, expire_on_commit=False) as session, session.begin():
with session_factory.create_session() as session, session.begin():
session.add(workflow_tool_provider)
if labels is not None:
ToolLabelManager.update_tool_labels(
ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels
)
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
return {"result": "success"}
@classmethod
@@ -179,6 +184,9 @@ class WorkflowToolManageService:
ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels
)
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
return {"result": "success"}
@classmethod
@@ -241,6 +249,9 @@ class WorkflowToolManageService:
db.session.commit()
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
return {"result": "success"}
@classmethod
@@ -1,365 +0,0 @@
import json
from unittest.mock import patch
import pytest
from extensions.ext_redis import redis_client
from services.billing_service import BillingService
class TestBillingServiceGetPlanBulkWithCache:
"""
Comprehensive integration tests for get_plan_bulk_with_cache using testcontainers.
This test class covers all major scenarios:
- Cache hit/miss scenarios
- Redis operation failures and fallback behavior
- Invalid cache data handling
- TTL expiration handling
- Error recovery and logging
"""
@pytest.fixture(autouse=True)
def setup_redis_cleanup(self, flask_app_with_containers):
"""Clean up Redis cache before and after each test."""
with flask_app_with_containers.app_context():
# Clean up before test
yield
# Clean up after test
# Delete all test cache keys
pattern = f"{BillingService._PLAN_CACHE_KEY_PREFIX}*"
keys = redis_client.keys(pattern)
if keys:
redis_client.delete(*keys)
def _create_test_plan_data(self, plan: str = "sandbox", expiration_date: int = 1735689600):
"""Helper to create test SubscriptionPlan data."""
return {"plan": plan, "expiration_date": expiration_date}
def _set_cache(self, tenant_id: str, plan_data: dict, ttl: int = 600):
"""Helper to set cache data in Redis."""
cache_key = BillingService._make_plan_cache_key(tenant_id)
json_str = json.dumps(plan_data)
redis_client.setex(cache_key, ttl, json_str)
def _get_cache(self, tenant_id: str):
"""Helper to get cache data from Redis."""
cache_key = BillingService._make_plan_cache_key(tenant_id)
value = redis_client.get(cache_key)
if value:
if isinstance(value, bytes):
return value.decode("utf-8")
return value
return None
def test_get_plan_bulk_with_cache_all_cache_hit(self, flask_app_with_containers):
"""Test bulk plan retrieval when all tenants are in cache."""
with flask_app_with_containers.app_context():
# Arrange
tenant_ids = ["tenant-1", "tenant-2", "tenant-3"]
expected_plans = {
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
"tenant-2": self._create_test_plan_data("professional", 1767225600),
"tenant-3": self._create_test_plan_data("team", 1798761600),
}
# Pre-populate cache
for tenant_id, plan_data in expected_plans.items():
self._set_cache(tenant_id, plan_data)
# Act
with patch.object(BillingService, "get_plan_bulk") as mock_get_plan_bulk:
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
# Assert
assert len(result) == 3
assert result["tenant-1"]["plan"] == "sandbox"
assert result["tenant-1"]["expiration_date"] == 1735689600
assert result["tenant-2"]["plan"] == "professional"
assert result["tenant-2"]["expiration_date"] == 1767225600
assert result["tenant-3"]["plan"] == "team"
assert result["tenant-3"]["expiration_date"] == 1798761600
# Verify API was not called
mock_get_plan_bulk.assert_not_called()
def test_get_plan_bulk_with_cache_all_cache_miss(self, flask_app_with_containers):
"""Test bulk plan retrieval when all tenants are not in cache."""
with flask_app_with_containers.app_context():
# Arrange
tenant_ids = ["tenant-1", "tenant-2"]
expected_plans = {
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
"tenant-2": self._create_test_plan_data("professional", 1767225600),
}
# Act
with patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk:
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
# Assert
assert len(result) == 2
assert result["tenant-1"]["plan"] == "sandbox"
assert result["tenant-2"]["plan"] == "professional"
# Verify API was called with correct tenant_ids
mock_get_plan_bulk.assert_called_once_with(tenant_ids)
# Verify data was written to cache
cached_1 = self._get_cache("tenant-1")
cached_2 = self._get_cache("tenant-2")
assert cached_1 is not None
assert cached_2 is not None
# Verify cache content
cached_data_1 = json.loads(cached_1)
cached_data_2 = json.loads(cached_2)
assert cached_data_1 == expected_plans["tenant-1"]
assert cached_data_2 == expected_plans["tenant-2"]
# Verify TTL is set
cache_key_1 = BillingService._make_plan_cache_key("tenant-1")
ttl_1 = redis_client.ttl(cache_key_1)
assert ttl_1 > 0
assert ttl_1 <= 600 # Should be <= 600 seconds
def test_get_plan_bulk_with_cache_partial_cache_hit(self, flask_app_with_containers):
"""Test bulk plan retrieval when some tenants are in cache, some are not."""
with flask_app_with_containers.app_context():
# Arrange
tenant_ids = ["tenant-1", "tenant-2", "tenant-3"]
# Pre-populate cache for tenant-1 and tenant-2
self._set_cache("tenant-1", self._create_test_plan_data("sandbox", 1735689600))
self._set_cache("tenant-2", self._create_test_plan_data("professional", 1767225600))
# tenant-3 is not in cache
missing_plan = {"tenant-3": self._create_test_plan_data("team", 1798761600)}
# Act
with patch.object(BillingService, "get_plan_bulk", return_value=missing_plan) as mock_get_plan_bulk:
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
# Assert
assert len(result) == 3
assert result["tenant-1"]["plan"] == "sandbox"
assert result["tenant-2"]["plan"] == "professional"
assert result["tenant-3"]["plan"] == "team"
# Verify API was called only for missing tenant
mock_get_plan_bulk.assert_called_once_with(["tenant-3"])
# Verify tenant-3 data was written to cache
cached_3 = self._get_cache("tenant-3")
assert cached_3 is not None
cached_data_3 = json.loads(cached_3)
assert cached_data_3 == missing_plan["tenant-3"]
def test_get_plan_bulk_with_cache_redis_mget_failure(self, flask_app_with_containers):
"""Test fallback to API when Redis mget fails."""
with flask_app_with_containers.app_context():
# Arrange
tenant_ids = ["tenant-1", "tenant-2"]
expected_plans = {
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
"tenant-2": self._create_test_plan_data("professional", 1767225600),
}
# Act
with (
patch.object(redis_client, "mget", side_effect=Exception("Redis connection error")),
patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk,
):
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
# Assert
assert len(result) == 2
assert result["tenant-1"]["plan"] == "sandbox"
assert result["tenant-2"]["plan"] == "professional"
# Verify API was called for all tenants (fallback)
mock_get_plan_bulk.assert_called_once_with(tenant_ids)
# Verify data was written to cache after fallback
cached_1 = self._get_cache("tenant-1")
cached_2 = self._get_cache("tenant-2")
assert cached_1 is not None
assert cached_2 is not None
def test_get_plan_bulk_with_cache_invalid_json_in_cache(self, flask_app_with_containers):
"""Test fallback to API when cache contains invalid JSON."""
with flask_app_with_containers.app_context():
# Arrange
tenant_ids = ["tenant-1", "tenant-2", "tenant-3"]
# Set valid cache for tenant-1
self._set_cache("tenant-1", self._create_test_plan_data("sandbox", 1735689600))
# Set invalid JSON for tenant-2
cache_key_2 = BillingService._make_plan_cache_key("tenant-2")
redis_client.setex(cache_key_2, 600, "invalid json {")
# tenant-3 is not in cache
expected_plans = {
"tenant-2": self._create_test_plan_data("professional", 1767225600),
"tenant-3": self._create_test_plan_data("team", 1798761600),
}
# Act
with patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk:
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
# Assert
assert len(result) == 3
assert result["tenant-1"]["plan"] == "sandbox" # From cache
assert result["tenant-2"]["plan"] == "professional" # From API (fallback)
assert result["tenant-3"]["plan"] == "team" # From API
# Verify API was called for tenant-2 and tenant-3
mock_get_plan_bulk.assert_called_once_with(["tenant-2", "tenant-3"])
# Verify tenant-2's invalid JSON was replaced with correct data in cache
cached_2 = self._get_cache("tenant-2")
assert cached_2 is not None
cached_data_2 = json.loads(cached_2)
assert cached_data_2 == expected_plans["tenant-2"]
assert cached_data_2["plan"] == "professional"
assert cached_data_2["expiration_date"] == 1767225600
# Verify tenant-2 cache has correct TTL
cache_key_2_new = BillingService._make_plan_cache_key("tenant-2")
ttl_2 = redis_client.ttl(cache_key_2_new)
assert ttl_2 > 0
assert ttl_2 <= 600
# Verify tenant-3 data was also written to cache
cached_3 = self._get_cache("tenant-3")
assert cached_3 is not None
cached_data_3 = json.loads(cached_3)
assert cached_data_3 == expected_plans["tenant-3"]
def test_get_plan_bulk_with_cache_invalid_plan_data_in_cache(self, flask_app_with_containers):
"""Test fallback to API when cache data doesn't match SubscriptionPlan schema."""
with flask_app_with_containers.app_context():
# Arrange
tenant_ids = ["tenant-1", "tenant-2", "tenant-3"]
# Set valid cache for tenant-1
self._set_cache("tenant-1", self._create_test_plan_data("sandbox", 1735689600))
# Set invalid plan data for tenant-2 (missing expiration_date)
cache_key_2 = BillingService._make_plan_cache_key("tenant-2")
invalid_data = json.dumps({"plan": "professional"}) # Missing expiration_date
redis_client.setex(cache_key_2, 600, invalid_data)
# tenant-3 is not in cache
expected_plans = {
"tenant-2": self._create_test_plan_data("professional", 1767225600),
"tenant-3": self._create_test_plan_data("team", 1798761600),
}
# Act
with patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk:
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
# Assert
assert len(result) == 3
assert result["tenant-1"]["plan"] == "sandbox" # From cache
assert result["tenant-2"]["plan"] == "professional" # From API (fallback)
assert result["tenant-3"]["plan"] == "team" # From API
# Verify API was called for tenant-2 and tenant-3
mock_get_plan_bulk.assert_called_once_with(["tenant-2", "tenant-3"])
def test_get_plan_bulk_with_cache_redis_pipeline_failure(self, flask_app_with_containers):
"""Test that pipeline failure doesn't affect return value."""
with flask_app_with_containers.app_context():
# Arrange
tenant_ids = ["tenant-1", "tenant-2"]
expected_plans = {
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
"tenant-2": self._create_test_plan_data("professional", 1767225600),
}
# Act
with (
patch.object(BillingService, "get_plan_bulk", return_value=expected_plans),
patch.object(redis_client, "pipeline") as mock_pipeline,
):
# Create a mock pipeline that fails on execute
mock_pipe = mock_pipeline.return_value
mock_pipe.execute.side_effect = Exception("Pipeline execution failed")
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
# Assert - Function should still return correct result despite pipeline failure
assert len(result) == 2
assert result["tenant-1"]["plan"] == "sandbox"
assert result["tenant-2"]["plan"] == "professional"
# Verify pipeline was attempted
mock_pipeline.assert_called_once()
def test_get_plan_bulk_with_cache_empty_tenant_ids(self, flask_app_with_containers):
"""Test with empty tenant_ids list."""
with flask_app_with_containers.app_context():
# Act
with patch.object(BillingService, "get_plan_bulk") as mock_get_plan_bulk:
result = BillingService.get_plan_bulk_with_cache([])
# Assert
assert result == {}
assert len(result) == 0
# Verify no API calls
mock_get_plan_bulk.assert_not_called()
# Verify no Redis operations (mget with empty list would return empty list)
# But we should check that mget was not called at all
# Since we can't easily verify this without more mocking, we just verify the result
def test_get_plan_bulk_with_cache_ttl_expired(self, flask_app_with_containers):
"""Test that expired cache keys are treated as cache misses."""
with flask_app_with_containers.app_context():
# Arrange
tenant_ids = ["tenant-1", "tenant-2"]
# Set cache for tenant-1 with very short TTL (1 second) to simulate expiration
self._set_cache("tenant-1", self._create_test_plan_data("sandbox", 1735689600), ttl=1)
# Wait for TTL to expire (key will be deleted by Redis)
import time
time.sleep(2)
# Verify cache is expired (key doesn't exist)
cache_key_1 = BillingService._make_plan_cache_key("tenant-1")
exists = redis_client.exists(cache_key_1)
assert exists == 0 # Key doesn't exist (expired)
# tenant-2 is not in cache
expected_plans = {
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
"tenant-2": self._create_test_plan_data("professional", 1767225600),
}
# Act
with patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk:
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
# Assert
assert len(result) == 2
assert result["tenant-1"]["plan"] == "sandbox"
assert result["tenant-2"]["plan"] == "professional"
# Verify API was called for both tenants (tenant-1 expired, tenant-2 missing)
mock_get_plan_bulk.assert_called_once_with(tenant_ids)
# Verify both were written to cache with correct TTL
cache_key_1_new = BillingService._make_plan_cache_key("tenant-1")
cache_key_2 = BillingService._make_plan_cache_key("tenant-2")
ttl_1_new = redis_client.ttl(cache_key_1_new)
ttl_2 = redis_client.ttl(cache_key_2)
assert ttl_1_new > 0
assert ttl_1_new <= 600
assert ttl_2 > 0
assert ttl_2 <= 600
@@ -1,254 +0,0 @@
"""
Unit tests for XSS prevention in App payloads.
This test module validates that HTML tags, JavaScript, and other potentially
dangerous content are rejected in App names and descriptions.
"""
import pytest
from controllers.console.app.app import CopyAppPayload, CreateAppPayload, UpdateAppPayload
class TestXSSPreventionUnit:
"""Unit tests for XSS prevention in App payloads."""
def test_create_app_valid_names(self):
"""Test CreateAppPayload with valid app names."""
# Normal app names should be valid
valid_names = [
"My App",
"Test App 123",
"App with - dash",
"App with _ underscore",
"App with + plus",
"App with () parentheses",
"App with [] brackets",
"App with {} braces",
"App with ! exclamation",
"App with @ at",
"App with # hash",
"App with $ dollar",
"App with % percent",
"App with ^ caret",
"App with & ampersand",
"App with * asterisk",
"Unicode: 测试应用",
"Emoji: 🤖",
"Mixed: Test 测试 123",
]
for name in valid_names:
payload = CreateAppPayload(
name=name,
mode="chat",
)
assert payload.name == name
def test_create_app_xss_script_tags(self):
"""Test CreateAppPayload rejects script tags."""
xss_payloads = [
"<script>alert(document.cookie)</script>",
"<Script>alert(1)</Script>",
"<SCRIPT>alert('XSS')</SCRIPT>",
"<script>alert(String.fromCharCode(88,83,83))</script>",
"<script src='evil.js'></script>",
"<script>document.location='http://evil.com'</script>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_iframe_tags(self):
"""Test CreateAppPayload rejects iframe tags."""
xss_payloads = [
"<iframe src='evil.com'></iframe>",
"<Iframe srcdoc='<script>alert(1)</script>'></iframe>",
"<IFRAME src='javascript:alert(1)'></iframe>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_javascript_protocol(self):
"""Test CreateAppPayload rejects javascript: protocol."""
xss_payloads = [
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"JavaScript:alert(document.cookie)",
"javascript:void(0)",
"javascript://comment%0Aalert(1)",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_svg_onload(self):
"""Test CreateAppPayload rejects SVG with onload."""
xss_payloads = [
"<svg onload=alert(1)>",
"<SVG ONLOAD=alert(1)>",
"<svg/x/onload=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_event_handlers(self):
"""Test CreateAppPayload rejects HTML event handlers."""
xss_payloads = [
"<div onclick=alert(1)>",
"<img onerror=alert(1)>",
"<body onload=alert(1)>",
"<input onfocus=alert(1)>",
"<a onmouseover=alert(1)>",
"<DIV ONCLICK=alert(1)>",
"<img src=x onerror=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_object_embed(self):
"""Test CreateAppPayload rejects object and embed tags."""
xss_payloads = [
"<object data='evil.swf'></object>",
"<embed src='evil.swf'>",
"<OBJECT data='javascript:alert(1)'></OBJECT>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_link_javascript(self):
"""Test CreateAppPayload rejects link tags with javascript."""
xss_payloads = [
"<link href='javascript:alert(1)'>",
"<LINK HREF='javascript:alert(1)'>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_in_description(self):
"""Test CreateAppPayload rejects XSS in description."""
xss_descriptions = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for description in xss_descriptions:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(
name="Valid Name",
mode="chat",
description=description,
)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_valid_descriptions(self):
"""Test CreateAppPayload with valid descriptions."""
valid_descriptions = [
"A simple description",
"Description with < and > symbols",
"Description with & ampersand",
"Description with 'quotes' and \"double quotes\"",
"Description with / slashes",
"Description with \\ backslashes",
"Description with ; semicolons",
"Unicode: 这是一个描述",
"Emoji: 🎉🚀",
]
for description in valid_descriptions:
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=description,
)
assert payload.description == description
def test_create_app_none_description(self):
"""Test CreateAppPayload with None description."""
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=None,
)
assert payload.description is None
def test_update_app_xss_prevention(self):
"""Test UpdateAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
UpdateAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_update_app_valid_names(self):
"""Test UpdateAppPayload with valid names."""
payload = UpdateAppPayload(name="Valid Updated Name")
assert payload.name == "Valid Updated Name"
def test_copy_app_xss_prevention(self):
"""Test CopyAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
CopyAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_copy_app_valid_names(self):
"""Test CopyAppPayload with valid names."""
payload = CopyAppPayload(name="Valid Copy Name")
assert payload.name == "Valid Copy Name"
def test_copy_app_none_name(self):
"""Test CopyAppPayload with None name (should be allowed)."""
payload = CopyAppPayload(name=None)
assert payload.name is None
def test_edge_case_angle_brackets_content(self):
"""Test that angle brackets with actual content are rejected."""
# Angle brackets without valid HTML-like patterns should be checked
# The regex pattern <.*?on\w+\s*= should catch event handlers
# But let's verify other patterns too
# Valid: angle brackets used as symbols (not matched by our patterns)
# Our patterns specifically look for dangerous constructs
# Invalid: actual HTML tags with event handlers
invalid_names = [
"<div onclick=xss>",
"<img src=x onerror=alert(1)>",
]
for name in invalid_names:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
@@ -171,7 +171,7 @@ class TestOAuthCallback:
):
mock_config.CONSOLE_WEB_URL = "http://localhost:3000"
mock_get_providers.return_value = {"github": oauth_setup["provider"]}
mock_generate_account.return_value = (oauth_setup["account"], True)
mock_generate_account.return_value = oauth_setup["account"]
mock_account_service.login.return_value = oauth_setup["token_pair"]
with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
@@ -179,7 +179,7 @@ class TestOAuthCallback:
oauth_setup["provider"].get_access_token.assert_called_once_with("test_code")
oauth_setup["provider"].get_user_info.assert_called_once_with("access_token")
mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=true")
mock_redirect.assert_called_once_with("http://localhost:3000")
@pytest.mark.parametrize(
("exception", "expected_error"),
@@ -223,7 +223,7 @@ class TestOAuthCallback:
# This documents actual behavior. See test_defensive_check_for_closed_account_status for details
(
AccountStatus.CLOSED.value,
"http://localhost:3000?oauth_new_user=false",
"http://localhost:3000",
),
],
)
@@ -260,7 +260,7 @@ class TestOAuthCallback:
account = MagicMock()
account.status = account_status
account.id = "123"
mock_generate_account.return_value = (account, False)
mock_generate_account.return_value = account
# Mock login for CLOSED status
mock_token_pair = MagicMock()
@@ -296,7 +296,7 @@ class TestOAuthCallback:
mock_account = MagicMock()
mock_account.status = AccountStatus.PENDING
mock_generate_account.return_value = (mock_account, False)
mock_generate_account.return_value = mock_account
mock_token_pair = MagicMock()
mock_token_pair.access_token = "jwt_access_token"
@@ -360,7 +360,7 @@ class TestOAuthCallback:
closed_account.status = AccountStatus.CLOSED
closed_account.id = "123"
closed_account.name = "Closed Account"
mock_generate_account.return_value = (closed_account, False)
mock_generate_account.return_value = closed_account
# Mock successful login (current behavior)
mock_token_pair = MagicMock()
@@ -374,7 +374,7 @@ class TestOAuthCallback:
resource.get("github")
# Verify current behavior: login succeeds (this is NOT ideal)
mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=false")
mock_redirect.assert_called_once_with("http://localhost:3000")
mock_account_service.login.assert_called_once()
# Document expected behavior in comments:
@@ -458,9 +458,8 @@ class TestAccountGeneration:
with pytest.raises(AccountRegisterError):
_generate_account("github", user_info)
else:
result, oauth_new_user = _generate_account("github", user_info)
result = _generate_account("github", user_info)
assert result == mock_account
assert oauth_new_user == should_create
if should_create:
mock_register_service.register.assert_called_once_with(
@@ -491,10 +490,9 @@ class TestAccountGeneration:
mock_tenant_service.create_tenant.return_value = mock_new_tenant
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
result, oauth_new_user = _generate_account("github", user_info)
result = _generate_account("github", user_info)
assert result == mock_account
assert oauth_new_user is False
mock_tenant_service.create_tenant.assert_called_once_with("Test User's Workspace")
mock_tenant_service.create_tenant_member.assert_called_once_with(
mock_new_tenant, mock_account, role="owner"
@@ -41,10 +41,13 @@ def client():
@patch(
"controllers.console.workspace.tool_providers.current_account_with_tenant", return_value=(MagicMock(id="u1"), "t1")
)
@patch("controllers.console.workspace.tool_providers.ToolProviderListCache.invalidate_cache", return_value=None)
@patch("controllers.console.workspace.tool_providers.Session")
@patch("controllers.console.workspace.tool_providers.MCPToolManageService._reconnect_with_url")
@pytest.mark.usefixtures("_mock_cache", "_mock_user_tenant")
def test_create_mcp_provider_populates_tools(mock_reconnect, mock_session, mock_current_account_with_tenant, client):
def test_create_mcp_provider_populates_tools(
mock_reconnect, mock_session, mock_invalidate_cache, mock_current_account_with_tenant, client
):
# Arrange: reconnect returns tools immediately
mock_reconnect.return_value = ReconnectResult(
authed=True,
@@ -0,0 +1,126 @@
import json
from unittest.mock import patch
import pytest
from redis.exceptions import RedisError
from core.helper.tool_provider_cache import ToolProviderListCache
from core.tools.entities.api_entities import ToolProviderTypeApiLiteral
@pytest.fixture
def mock_redis_client():
"""Fixture: Mock Redis client"""
with patch("core.helper.tool_provider_cache.redis_client") as mock:
yield mock
class TestToolProviderListCache:
"""Test class for ToolProviderListCache"""
def test_generate_cache_key(self):
"""Test cache key generation logic"""
# Scenario 1: Specify typ (valid literal value)
tenant_id = "tenant_123"
typ: ToolProviderTypeApiLiteral = "builtin"
expected_key = f"tool_providers:tenant_id:{tenant_id}:type:{typ}"
assert ToolProviderListCache._generate_cache_key(tenant_id, typ) == expected_key
# Scenario 2: typ is None (defaults to "all")
expected_key_all = f"tool_providers:tenant_id:{tenant_id}:type:all"
assert ToolProviderListCache._generate_cache_key(tenant_id) == expected_key_all
def test_get_cached_providers_hit(self, mock_redis_client):
"""Test get cached providers - cache hit and successful decoding"""
tenant_id = "tenant_123"
typ: ToolProviderTypeApiLiteral = "api"
mock_providers = [{"id": "tool", "name": "test_provider"}]
mock_redis_client.get.return_value = json.dumps(mock_providers).encode("utf-8")
result = ToolProviderListCache.get_cached_providers(tenant_id, typ)
mock_redis_client.get.assert_called_once_with(ToolProviderListCache._generate_cache_key(tenant_id, typ))
assert result == mock_providers
def test_get_cached_providers_decode_error(self, mock_redis_client):
"""Test get cached providers - cache hit but decoding failed"""
tenant_id = "tenant_123"
mock_redis_client.get.return_value = b"invalid_json_data"
result = ToolProviderListCache.get_cached_providers(tenant_id)
assert result is None
mock_redis_client.get.assert_called_once()
def test_get_cached_providers_miss(self, mock_redis_client):
"""Test get cached providers - cache miss"""
tenant_id = "tenant_123"
mock_redis_client.get.return_value = None
result = ToolProviderListCache.get_cached_providers(tenant_id)
assert result is None
mock_redis_client.get.assert_called_once()
def test_set_cached_providers(self, mock_redis_client):
"""Test set cached providers"""
tenant_id = "tenant_123"
typ: ToolProviderTypeApiLiteral = "builtin"
mock_providers = [{"id": "tool", "name": "test_provider"}]
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
ToolProviderListCache.set_cached_providers(tenant_id, typ, mock_providers)
mock_redis_client.setex.assert_called_once_with(
cache_key, ToolProviderListCache.CACHE_TTL, json.dumps(mock_providers)
)
def test_invalidate_cache_specific_type(self, mock_redis_client):
"""Test invalidate cache - specific type"""
tenant_id = "tenant_123"
typ: ToolProviderTypeApiLiteral = "workflow"
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
ToolProviderListCache.invalidate_cache(tenant_id, typ)
mock_redis_client.delete.assert_called_once_with(cache_key)
def test_invalidate_cache_all_types(self, mock_redis_client):
"""Test invalidate cache - clear all tenant cache"""
tenant_id = "tenant_123"
mock_keys = [
b"tool_providers:tenant_id:tenant_123:type:all",
b"tool_providers:tenant_id:tenant_123:type:builtin",
]
mock_redis_client.scan_iter.return_value = mock_keys
ToolProviderListCache.invalidate_cache(tenant_id)
def test_invalidate_cache_no_keys(self, mock_redis_client):
"""Test invalidate cache - no cache keys for tenant"""
tenant_id = "tenant_123"
mock_redis_client.scan_iter.return_value = []
ToolProviderListCache.invalidate_cache(tenant_id)
mock_redis_client.delete.assert_not_called()
def test_redis_fallback_default_return(self, mock_redis_client):
"""Test redis_fallback decorator - default return value (Redis error)"""
mock_redis_client.get.side_effect = RedisError("Redis connection error")
result = ToolProviderListCache.get_cached_providers("tenant_123")
assert result is None
mock_redis_client.get.assert_called_once()
def test_redis_fallback_no_default(self, mock_redis_client):
"""Test redis_fallback decorator - no default return value (Redis error)"""
mock_redis_client.setex.side_effect = RedisError("Redis connection error")
try:
ToolProviderListCache.set_cached_providers("tenant_123", "mcp", [])
except RedisError:
pytest.fail("set_cached_providers should not raise RedisError (handled by fallback)")
mock_redis_client.setex.assert_called_once()
@@ -1,213 +0,0 @@
from core.rag.cleaner.clean_processor import CleanProcessor
class TestCleanProcessor:
"""Test cases for CleanProcessor.clean method."""
def test_clean_default_removal_of_invalid_symbols(self):
"""Test default cleaning removes invalid symbols."""
# Test <| replacement
assert CleanProcessor.clean("text<|with<|invalid", None) == "text<with<invalid"
# Test |> replacement
assert CleanProcessor.clean("text|>with|>invalid", None) == "text>with>invalid"
# Test removal of control characters
text_with_control = "normal\x00text\x1fwith\x07control\x7fchars"
expected = "normaltextwithcontrolchars"
assert CleanProcessor.clean(text_with_control, None) == expected
# Test U+FFFE removal
text_with_ufffe = "normal\ufffepadding"
expected = "normalpadding"
assert CleanProcessor.clean(text_with_ufffe, None) == expected
def test_clean_with_none_process_rule(self):
"""Test cleaning with None process_rule - only default cleaning applied."""
text = "Hello<|World\x00"
expected = "Hello<World"
assert CleanProcessor.clean(text, None) == expected
def test_clean_with_empty_process_rule(self):
"""Test cleaning with empty process_rule dict - only default cleaning applied."""
text = "Hello<|World\x00"
expected = "Hello<World"
assert CleanProcessor.clean(text, {}) == expected
def test_clean_with_empty_rules(self):
"""Test cleaning with empty rules - only default cleaning applied."""
text = "Hello<|World\x00"
expected = "Hello<World"
assert CleanProcessor.clean(text, {"rules": {}}) == expected
def test_clean_remove_extra_spaces_enabled(self):
"""Test remove_extra_spaces rule when enabled."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_extra_spaces", "enabled": True}]}}
# Test multiple newlines reduced to two
text = "Line1\n\n\n\n\nLine2"
expected = "Line1\n\nLine2"
assert CleanProcessor.clean(text, process_rule) == expected
# Test various whitespace characters reduced to single space
text = "word1\u2000\u2001\t\t \u3000word2"
expected = "word1 word2"
assert CleanProcessor.clean(text, process_rule) == expected
# Test combination of newlines and spaces
text = "Line1\n\n\n\n \t Line2"
expected = "Line1\n\n Line2"
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_remove_extra_spaces_disabled(self):
"""Test remove_extra_spaces rule when disabled."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_extra_spaces", "enabled": False}]}}
text = "Line1\n\n\n\n\nLine2 with spaces"
# Should only apply default cleaning (no invalid symbols here)
assert CleanProcessor.clean(text, process_rule) == text
def test_clean_remove_urls_emails_enabled(self):
"""Test remove_urls_emails rule when enabled."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
# Test email removal
text = "Contact us at [email protected] for more info"
expected = "Contact us at for more info"
assert CleanProcessor.clean(text, process_rule) == expected
# Test URL removal
text = "Visit https://example.com or http://test.org"
expected = "Visit or "
assert CleanProcessor.clean(text, process_rule) == expected
# Test both email and URL
text = "Email [email protected] and visit https://site.com"
expected = "Email and visit "
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_preserve_markdown_links_and_images(self):
"""Test that markdown links and images are preserved when removing URLs."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
# Test markdown link preservation
text = "Check [Google](https://google.com) for info"
expected = "Check [Google](https://google.com) for info"
assert CleanProcessor.clean(text, process_rule) == expected
# Test markdown image preservation
text = "Image: ![alt](https://example.com/image.png)"
expected = "Image: ![alt](https://example.com/image.png)"
assert CleanProcessor.clean(text, process_rule) == expected
# Test both link and image preservation
text = "[Link](https://link.com) and ![Image](https://image.com/img.jpg)"
expected = "[Link](https://link.com) and ![Image](https://image.com/img.jpg)"
assert CleanProcessor.clean(text, process_rule) == expected
# Test that non-markdown URLs are still removed
text = "Check [Link](https://keep.com) but remove https://remove.com"
expected = "Check [Link](https://keep.com) but remove "
assert CleanProcessor.clean(text, process_rule) == expected
# Test email removal alongside markdown preservation
text = "Email: [email protected], link: [Click](https://site.com)"
expected = "Email: , link: [Click](https://site.com)"
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_remove_urls_emails_disabled(self):
"""Test remove_urls_emails rule when disabled."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": False}]}}
text = "Email [email protected] visit https://example.com"
# Should only apply default cleaning
assert CleanProcessor.clean(text, process_rule) == text
def test_clean_both_rules_enabled(self):
"""Test both pre-processing rules enabled together."""
process_rule = {
"rules": {
"pre_processing_rules": [
{"id": "remove_extra_spaces", "enabled": True},
{"id": "remove_urls_emails", "enabled": True},
]
}
}
text = "Hello\n\n\n\n World [email protected] \n\n\nhttps://example.com"
expected = "Hello\n\n World \n\n"
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_with_markdown_link_and_extra_spaces(self):
"""Test markdown link preservation with extra spaces removal."""
process_rule = {
"rules": {
"pre_processing_rules": [
{"id": "remove_extra_spaces", "enabled": True},
{"id": "remove_urls_emails", "enabled": True},
]
}
}
text = "[Link](https://example.com)\n\n\n\n Text https://remove.com"
expected = "[Link](https://example.com)\n\n Text "
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_unknown_rule_id_ignored(self):
"""Test that unknown rule IDs are silently ignored."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "unknown_rule", "enabled": True}]}}
text = "Hello<|World\x00"
expected = "Hello<World"
# Only default cleaning should be applied
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_empty_text(self):
"""Test cleaning empty text."""
assert CleanProcessor.clean("", None) == ""
assert CleanProcessor.clean("", {}) == ""
assert CleanProcessor.clean("", {"rules": {}}) == ""
def test_clean_text_with_only_invalid_symbols(self):
"""Test text containing only invalid symbols."""
text = "<|<|\x00\x01\x02\ufffe|>|>"
# <| becomes <, |> becomes >, control chars and U+FFFE are removed
assert CleanProcessor.clean(text, None) == "<<>>"
def test_clean_multiple_markdown_links_preserved(self):
"""Test multiple markdown links are all preserved."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
text = "[One](https://one.com) [Two](http://two.org) [Three](https://three.net)"
expected = "[One](https://one.com) [Two](http://two.org) [Three](https://three.net)"
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_markdown_link_text_as_url(self):
"""Test markdown link where the link text itself is a URL."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
# Link text that looks like URL should be preserved
text = "[https://text-url.com](https://actual-url.com)"
expected = "[https://text-url.com](https://actual-url.com)"
assert CleanProcessor.clean(text, process_rule) == expected
# Text URL without markdown should be removed
text = "https://text-url.com https://actual-url.com"
expected = " "
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_complex_markdown_link_content(self):
"""Test markdown links with complex content - known limitation with brackets in link text."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
# Note: The regex pattern [^\]]* cannot handle ] within link text
# This is a known limitation - the pattern stops at the first ]
text = "[Text with [brackets] and (parens)](https://example.com)"
# Actual behavior: only matches up to first ], URL gets removed
expected = "[Text with [brackets] and (parens)]("
assert CleanProcessor.clean(text, process_rule) == expected
# Test that properly formatted markdown links work
text = "[Text with (parens) and symbols](https://example.com)"
expected = "[Text with (parens) and symbols](https://example.com)"
assert CleanProcessor.clean(text, process_rule) == expected
@@ -1,186 +0,0 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import core.rag.extractor.pdf_extractor as pe
@pytest.fixture
def mock_dependencies(monkeypatch):
# Mock storage
saves = []
def save(key, data):
saves.append((key, data))
monkeypatch.setattr(pe, "storage", SimpleNamespace(save=save))
# Mock db
class DummySession:
def __init__(self):
self.added = []
self.committed = False
def add(self, obj):
self.added.append(obj)
def add_all(self, objs):
self.added.extend(objs)
def commit(self):
self.committed = True
db_stub = SimpleNamespace(session=DummySession())
monkeypatch.setattr(pe, "db", db_stub)
# Mock UploadFile
class FakeUploadFile:
DEFAULT_ID = "test_file_id"
def __init__(self, **kwargs):
# Assign id from DEFAULT_ID, allow override via kwargs if needed
self.id = self.DEFAULT_ID
for k, v in kwargs.items():
setattr(self, k, v)
monkeypatch.setattr(pe, "UploadFile", FakeUploadFile)
# Mock config
monkeypatch.setattr(pe.dify_config, "FILES_URL", "http://files.local")
monkeypatch.setattr(pe.dify_config, "INTERNAL_FILES_URL", None)
monkeypatch.setattr(pe.dify_config, "STORAGE_TYPE", "local")
return SimpleNamespace(saves=saves, db=db_stub, UploadFile=FakeUploadFile)
@pytest.mark.parametrize(
("image_bytes", "expected_mime", "expected_ext", "file_id"),
[
(b"\xff\xd8\xff some jpeg", "image/jpeg", "jpg", "test_file_id_jpeg"),
(b"\x89PNG\r\n\x1a\n some png", "image/png", "png", "test_file_id_png"),
],
)
def test_extract_images_formats(mock_dependencies, monkeypatch, image_bytes, expected_mime, expected_ext, file_id):
saves = mock_dependencies.saves
db_stub = mock_dependencies.db
# Customize FakeUploadFile id for this test case.
# Using monkeypatch ensures the class attribute is reset between parameter sets.
monkeypatch.setattr(mock_dependencies.UploadFile, "DEFAULT_ID", file_id)
# Mock page and image objects
mock_page = MagicMock()
mock_image_obj = MagicMock()
def mock_extract(buf, fb_format=None):
buf.write(image_bytes)
mock_image_obj.extract.side_effect = mock_extract
mock_page.get_objects.return_value = [mock_image_obj]
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1")
# We need to handle the import inside _extract_images
with patch("pypdfium2.raw") as mock_raw:
mock_raw.FPDF_PAGEOBJ_IMAGE = 1
result = extractor._extract_images(mock_page)
assert f"![image](http://files.local/files/{file_id}/file-preview)" in result
assert len(saves) == 1
assert saves[0][1] == image_bytes
assert len(db_stub.session.added) == 1
assert db_stub.session.added[0].tenant_id == "t1"
assert db_stub.session.added[0].size == len(image_bytes)
assert db_stub.session.added[0].mime_type == expected_mime
assert db_stub.session.added[0].extension == expected_ext
assert db_stub.session.committed is True
@pytest.mark.parametrize(
("get_objects_side_effect", "get_objects_return_value"),
[
(None, []), # Empty list
(None, None), # None returned
(Exception("Failed to get objects"), None), # Exception raised
],
)
def test_extract_images_get_objects_scenarios(mock_dependencies, get_objects_side_effect, get_objects_return_value):
mock_page = MagicMock()
if get_objects_side_effect:
mock_page.get_objects.side_effect = get_objects_side_effect
else:
mock_page.get_objects.return_value = get_objects_return_value
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1")
with patch("pypdfium2.raw") as mock_raw:
mock_raw.FPDF_PAGEOBJ_IMAGE = 1
result = extractor._extract_images(mock_page)
assert result == ""
def test_extract_calls_extract_images(mock_dependencies, monkeypatch):
# Mock pypdfium2
mock_pdf_doc = MagicMock()
mock_page = MagicMock()
mock_pdf_doc.__iter__.return_value = [mock_page]
# Mock text extraction
mock_text_page = MagicMock()
mock_text_page.get_text_range.return_value = "Page text content"
mock_page.get_textpage.return_value = mock_text_page
with patch("pypdfium2.PdfDocument", return_value=mock_pdf_doc):
# Mock Blob
mock_blob = MagicMock()
mock_blob.source = "test.pdf"
with patch("core.rag.extractor.pdf_extractor.Blob.from_path", return_value=mock_blob):
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1")
# Mock _extract_images to return a known string
monkeypatch.setattr(extractor, "_extract_images", lambda p: "![image](img_url)")
documents = list(extractor.extract())
assert len(documents) == 1
assert "Page text content" in documents[0].page_content
assert "![image](img_url)" in documents[0].page_content
assert documents[0].metadata["page"] == 0
def test_extract_images_failures(mock_dependencies):
saves = mock_dependencies.saves
db_stub = mock_dependencies.db
# Mock page and image objects
mock_page = MagicMock()
mock_image_obj_fail = MagicMock()
mock_image_obj_ok = MagicMock()
# First image raises exception
mock_image_obj_fail.extract.side_effect = Exception("Extraction failure")
# Second image is OK (JPEG)
jpeg_bytes = b"\xff\xd8\xff some image data"
def mock_extract(buf, fb_format=None):
buf.write(jpeg_bytes)
mock_image_obj_ok.extract.side_effect = mock_extract
mock_page.get_objects.return_value = [mock_image_obj_fail, mock_image_obj_ok]
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1")
with patch("pypdfium2.raw") as mock_raw:
mock_raw.FPDF_PAGEOBJ_IMAGE = 1
result = extractor._extract_images(mock_page)
# Should have one success
assert "![image](http://files.local/files/test_file_id/file-preview)" in result
assert len(saves) == 1
assert saves[0][1] == jpeg_bytes
assert db_stub.session.committed is True
@@ -421,18 +421,7 @@ class TestRetrievalService:
# In real code, this waits for all futures to complete
# In tests, futures complete immediately, so wait is a no-op
with patch("core.rag.datasource.retrieval_service.concurrent.futures.wait"):
# Mock concurrent.futures.as_completed for early error propagation
# In real code, this yields futures as they complete
# In tests, we yield all futures immediately since they're already done
def mock_as_completed(futures_list, timeout=None):
"""Mock as_completed that yields futures immediately."""
yield from futures_list
with patch(
"core.rag.datasource.retrieval_service.concurrent.futures.as_completed",
side_effect=mock_as_completed,
):
yield mock_executor
yield mock_executor
# ==================== Vector Search Tests ====================
@@ -1 +0,0 @@
"""Tests for graph traversal components."""
@@ -1,307 +0,0 @@
"""Unit tests for skip propagator."""
from unittest.mock import MagicMock, create_autospec
from core.workflow.graph import Edge, Graph
from core.workflow.graph_engine.graph_state_manager import GraphStateManager
from core.workflow.graph_engine.graph_traversal.skip_propagator import SkipPropagator
class TestSkipPropagator:
"""Test suite for SkipPropagator."""
def test_propagate_skip_from_edge_with_unknown_edges_stops_processing(self) -> None:
"""When there are unknown incoming edges, propagation should stop."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create a mock edge
mock_edge = MagicMock(spec=Edge)
mock_edge.id = "edge_1"
mock_edge.head = "node_2"
# Setup graph edges dict
mock_graph.edges = {"edge_1": mock_edge}
# Setup incoming edges
incoming_edges = [MagicMock(spec=Edge), MagicMock(spec=Edge)]
mock_graph.get_incoming_edges.return_value = incoming_edges
# Setup state manager to return has_unknown=True
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": True,
"has_taken": False,
"all_skipped": False,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert
mock_graph.get_incoming_edges.assert_called_once_with("node_2")
mock_state_manager.analyze_edge_states.assert_called_once_with(incoming_edges)
# Should not call any other state manager methods
mock_state_manager.enqueue_node.assert_not_called()
mock_state_manager.start_execution.assert_not_called()
mock_state_manager.mark_node_skipped.assert_not_called()
def test_propagate_skip_from_edge_with_taken_edge_enqueues_node(self) -> None:
"""When there is at least one taken edge, node should be enqueued."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create a mock edge
mock_edge = MagicMock(spec=Edge)
mock_edge.id = "edge_1"
mock_edge.head = "node_2"
mock_graph.edges = {"edge_1": mock_edge}
incoming_edges = [MagicMock(spec=Edge)]
mock_graph.get_incoming_edges.return_value = incoming_edges
# Setup state manager to return has_taken=True
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": True,
"all_skipped": False,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert
mock_state_manager.enqueue_node.assert_called_once_with("node_2")
mock_state_manager.start_execution.assert_called_once_with("node_2")
mock_state_manager.mark_node_skipped.assert_not_called()
def test_propagate_skip_from_edge_with_all_skipped_propagates_to_node(self) -> None:
"""When all incoming edges are skipped, should propagate skip to node."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create a mock edge
mock_edge = MagicMock(spec=Edge)
mock_edge.id = "edge_1"
mock_edge.head = "node_2"
mock_graph.edges = {"edge_1": mock_edge}
incoming_edges = [MagicMock(spec=Edge)]
mock_graph.get_incoming_edges.return_value = incoming_edges
# Setup state manager to return all_skipped=True
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": False,
"all_skipped": True,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert
mock_state_manager.mark_node_skipped.assert_called_once_with("node_2")
mock_state_manager.enqueue_node.assert_not_called()
mock_state_manager.start_execution.assert_not_called()
def test_propagate_skip_to_node_marks_node_and_outgoing_edges_skipped(self) -> None:
"""_propagate_skip_to_node should mark node and all outgoing edges as skipped."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create outgoing edges
edge1 = MagicMock(spec=Edge)
edge1.id = "edge_2"
edge1.head = "node_downstream_1" # Set head for propagate_skip_from_edge
edge2 = MagicMock(spec=Edge)
edge2.id = "edge_3"
edge2.head = "node_downstream_2"
# Setup graph edges dict for propagate_skip_from_edge
mock_graph.edges = {"edge_2": edge1, "edge_3": edge2}
mock_graph.get_outgoing_edges.return_value = [edge1, edge2]
# Setup get_incoming_edges to return empty list to stop recursion
mock_graph.get_incoming_edges.return_value = []
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Use mock to call private method
# Act
propagator._propagate_skip_to_node("node_1")
# Assert
mock_state_manager.mark_node_skipped.assert_called_once_with("node_1")
mock_state_manager.mark_edge_skipped.assert_any_call("edge_2")
mock_state_manager.mark_edge_skipped.assert_any_call("edge_3")
assert mock_state_manager.mark_edge_skipped.call_count == 2
# Should recursively propagate from each edge
# Since propagate_skip_from_edge is called, we need to verify it was called
# But we can't directly verify due to recursion. We'll trust the logic.
def test_skip_branch_paths_marks_unselected_edges_and_propagates(self) -> None:
"""skip_branch_paths should mark all unselected edges as skipped and propagate."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create unselected edges
edge1 = MagicMock(spec=Edge)
edge1.id = "edge_1"
edge1.head = "node_downstream_1"
edge2 = MagicMock(spec=Edge)
edge2.id = "edge_2"
edge2.head = "node_downstream_2"
unselected_edges = [edge1, edge2]
# Setup graph edges dict
mock_graph.edges = {"edge_1": edge1, "edge_2": edge2}
# Setup get_incoming_edges to return empty list to stop recursion
mock_graph.get_incoming_edges.return_value = []
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.skip_branch_paths(unselected_edges)
# Assert
mock_state_manager.mark_edge_skipped.assert_any_call("edge_1")
mock_state_manager.mark_edge_skipped.assert_any_call("edge_2")
assert mock_state_manager.mark_edge_skipped.call_count == 2
# propagate_skip_from_edge should be called for each edge
# We can't directly verify due to the mock, but the logic is covered
def test_propagate_skip_from_edge_recursively_propagates_through_graph(self) -> None:
"""Skip propagation should recursively propagate through the graph."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create edge chain: edge_1 -> node_2 -> edge_3 -> node_4
edge1 = MagicMock(spec=Edge)
edge1.id = "edge_1"
edge1.head = "node_2"
edge3 = MagicMock(spec=Edge)
edge3.id = "edge_3"
edge3.head = "node_4"
mock_graph.edges = {"edge_1": edge1, "edge_3": edge3}
# Setup get_incoming_edges to return different values based on node
def get_incoming_edges_side_effect(node_id):
if node_id == "node_2":
return [edge1]
elif node_id == "node_4":
return [edge3]
return []
mock_graph.get_incoming_edges.side_effect = get_incoming_edges_side_effect
# Setup get_outgoing_edges to return different values based on node
def get_outgoing_edges_side_effect(node_id):
if node_id == "node_2":
return [edge3]
elif node_id == "node_4":
return [] # No outgoing edges, stops recursion
return []
mock_graph.get_outgoing_edges.side_effect = get_outgoing_edges_side_effect
# Setup state manager to return all_skipped for both nodes
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": False,
"all_skipped": True,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert
# Should mark node_2 as skipped
mock_state_manager.mark_node_skipped.assert_any_call("node_2")
# Should mark edge_3 as skipped
mock_state_manager.mark_edge_skipped.assert_any_call("edge_3")
# Should propagate to node_4
mock_state_manager.mark_node_skipped.assert_any_call("node_4")
assert mock_state_manager.mark_node_skipped.call_count == 2
def test_propagate_skip_from_edge_with_mixed_edge_states_handles_correctly(self) -> None:
"""Test with mixed edge states (some unknown, some taken, some skipped)."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
mock_edge = MagicMock(spec=Edge)
mock_edge.id = "edge_1"
mock_edge.head = "node_2"
mock_graph.edges = {"edge_1": mock_edge}
incoming_edges = [MagicMock(spec=Edge), MagicMock(spec=Edge), MagicMock(spec=Edge)]
mock_graph.get_incoming_edges.return_value = incoming_edges
# Test 1: has_unknown=True, has_taken=False, all_skipped=False
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": True,
"has_taken": False,
"all_skipped": False,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert - should stop processing
mock_state_manager.enqueue_node.assert_not_called()
mock_state_manager.mark_node_skipped.assert_not_called()
# Reset mocks for next test
mock_state_manager.reset_mock()
mock_graph.reset_mock()
# Test 2: has_unknown=False, has_taken=True, all_skipped=False
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": True,
"all_skipped": False,
}
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert - should enqueue node
mock_state_manager.enqueue_node.assert_called_once_with("node_2")
mock_state_manager.start_execution.assert_called_once_with("node_2")
# Reset mocks for next test
mock_state_manager.reset_mock()
mock_graph.reset_mock()
# Test 3: has_unknown=False, has_taken=False, all_skipped=True
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": False,
"all_skipped": True,
}
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert - should propagate skip
mock_state_manager.mark_node_skipped.assert_called_once_with("node_2")
@@ -8,12 +8,11 @@ class TestCelerySSLConfiguration:
"""Test suite for Celery SSL configuration."""
def test_get_celery_ssl_options_when_ssl_disabled(self):
"""Test SSL options when BROKER_USE_SSL is False."""
from configs import DifyConfig
"""Test SSL options when REDIS_USE_SSL is False."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = False
dify_config = DifyConfig(CELERY_BROKER_URL="redis://localhost:6379/0")
with patch("extensions.ext_celery.dify_config", dify_config):
with patch("extensions.ext_celery.dify_config", mock_config):
from extensions.ext_celery import _get_celery_ssl_options
result = _get_celery_ssl_options()
@@ -22,6 +21,7 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_when_broker_not_redis(self):
"""Test SSL options when broker is not Redis."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "amqp://localhost:5672"
with patch("extensions.ext_celery.dify_config", mock_config):
@@ -33,6 +33,7 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_with_cert_none(self):
"""Test SSL options with CERT_NONE requirement."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.REDIS_SSL_CERT_REQS = "CERT_NONE"
mock_config.REDIS_SSL_CA_CERTS = None
@@ -52,6 +53,7 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_with_cert_required(self):
"""Test SSL options with CERT_REQUIRED and certificates."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "rediss://localhost:6380/0"
mock_config.REDIS_SSL_CERT_REQS = "CERT_REQUIRED"
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt"
@@ -71,6 +73,7 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_with_cert_optional(self):
"""Test SSL options with CERT_OPTIONAL requirement."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.REDIS_SSL_CERT_REQS = "CERT_OPTIONAL"
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt"
@@ -88,6 +91,7 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_with_invalid_cert_reqs(self):
"""Test SSL options with invalid cert requirement defaults to CERT_NONE."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.REDIS_SSL_CERT_REQS = "INVALID_VALUE"
mock_config.REDIS_SSL_CA_CERTS = None
@@ -104,6 +108,7 @@ class TestCelerySSLConfiguration:
def test_celery_init_applies_ssl_to_broker_and_backend(self):
"""Test that SSL options are applied to both broker and backend when using Redis."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.CELERY_BACKEND = "redis"
mock_config.CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
@@ -1294,42 +1294,6 @@ class TestBillingServiceSubscriptionOperations:
# Assert
assert result == {}
def test_get_plan_bulk_with_invalid_tenant_plan_skipped(self, mock_send_request):
"""Test bulk plan retrieval when one tenant has invalid plan data (should skip that tenant)."""
# Arrange
tenant_ids = ["tenant-valid-1", "tenant-invalid", "tenant-valid-2"]
# Response with one invalid tenant plan (missing expiration_date) and two valid ones
mock_send_request.return_value = {
"data": {
"tenant-valid-1": {"plan": "sandbox", "expiration_date": 1735689600},
"tenant-invalid": {"plan": "professional"}, # Missing expiration_date field
"tenant-valid-2": {"plan": "team", "expiration_date": 1767225600},
}
}
# Act
with patch("services.billing_service.logger") as mock_logger:
result = BillingService.get_plan_bulk(tenant_ids)
# Assert - should only contain valid tenants
assert len(result) == 2
assert "tenant-valid-1" in result
assert "tenant-valid-2" in result
assert "tenant-invalid" not in result
# Verify valid tenants have correct data
assert result["tenant-valid-1"]["plan"] == "sandbox"
assert result["tenant-valid-1"]["expiration_date"] == 1735689600
assert result["tenant-valid-2"]["plan"] == "team"
assert result["tenant-valid-2"]["expiration_date"] == 1767225600
# Verify exception was logged for the invalid tenant
mock_logger.exception.assert_called_once()
log_call_args = mock_logger.exception.call_args[0]
assert "get_plan_bulk: failed to validate subscription plan for tenant" in log_call_args[0]
assert "tenant-invalid" in log_call_args[1]
def test_get_expired_subscription_cleanup_whitelist_success(self, mock_send_request):
"""Test successful retrieval of expired subscription cleanup whitelist."""
# Arrange
@@ -15,11 +15,6 @@ from core.tools.utils.text_processing_utils import remove_leading_symbols
("", ""),
(" ", " "),
("【测试】", "【测试】"),
# Markdown link preservation - should be preserved if text starts with a markdown link
("[Google](https://google.com) is a search engine", "[Google](https://google.com) is a search engine"),
("[Example](http://example.com) some text", "[Example](http://example.com) some text"),
# Leading symbols before markdown link are removed, including the opening bracket [
("@[Test](https://example.com)", "Test](https://example.com)"),
],
)
def test_remove_leading_symbols(input_text, expected_output):
+1 -2
View File
@@ -1,7 +1,6 @@
import type { Plan, UsagePlanInfo } from '@/app/components/billing/type'
import type { ProviderContextState } from '@/context/provider-context'
import { merge } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { merge, noop } from 'es-toolkit/compat'
import { defaultPlan } from '@/app/components/billing/config'
// Avoid being mocked in tests
+1 -1
View File
@@ -3,7 +3,7 @@ import path from 'node:path'
import vm from 'node:vm'
import { transpile } from 'typescript'
describe('i18n:check script functionality', () => {
describe('check-i18n script functionality', () => {
const testDir = path.join(__dirname, '../i18n-test')
const testEnDir = path.join(testDir, 'en-US')
const testZhDir = path.join(testDir, 'zh-Hans')
@@ -64,6 +64,7 @@ vi.mock('i18next', () => ({
// Mock the useConfig hook
vi.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
__esModule: true,
default: () => ({
inputs: {
is_parallel: true,
@@ -4,11 +4,11 @@ import type { FC } from 'react'
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import { RiCalendarLine } from '@remixicon/react'
import dayjs from 'dayjs'
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback } from 'react'
import Picker from '@/app/components/base/date-and-time-picker/date-picker'
import { useLocale } from '@/context/i18n'
import { useI18N } from '@/context/i18n'
import { cn } from '@/utils/classnames'
import { formatToLocalTime } from '@/utils/format'
@@ -26,7 +26,7 @@ const DatePicker: FC<Props> = ({
onStartChange,
onEndChange,
}) => {
const locale = useLocale()
const { locale } = useI18N()
const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => {
return (
@@ -7,7 +7,7 @@ import dayjs from 'dayjs'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { HourglassShape } from '@/app/components/base/icons/src/vender/other'
import { useLocale } from '@/context/i18n'
import { useI18N } from '@/context/i18n'
import { formatToLocalTime } from '@/utils/format'
import DatePicker from './date-picker'
import RangeSelector from './range-selector'
@@ -27,7 +27,7 @@ const TimeRangePicker: FC<Props> = ({
onSelect,
queryDateFormat,
}) => {
const locale = useLocale()
const { locale } = useI18N()
const [isCustomRange, setIsCustomRange] = useState(false)
const [start, setStart] = useState<Dayjs>(today)
@@ -3,12 +3,12 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown'
import { useLocale } from '@/context/i18n'
import I18NContext from '@/context/i18n'
import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common'
export default function CheckCode() {
@@ -19,7 +19,7 @@ export default function CheckCode() {
const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false)
const locale = useLocale()
const { locale } = useContext(I18NContext)
const verify = async () => {
try {
@@ -1,17 +1,17 @@
'use client'
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import I18NContext from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common'
@@ -22,7 +22,7 @@ export default function CheckCode() {
const router = useRouter()
const [email, setEmail] = useState('')
const [loading, setIsLoading] = useState(false)
const locale = useLocale()
const { locale } = useContext(I18NContext)
const handleGetEMailVerificationCode = async () => {
try {
@@ -4,12 +4,12 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown'
import { useLocale } from '@/context/i18n'
import I18NContext from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
import { fetchAccessToken } from '@/service/share'
@@ -23,7 +23,7 @@ export default function CheckCode() {
const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false)
const locale = useLocale()
const { locale } = useContext(I18NContext)
const codeInputRef = useRef<HTMLInputElement>(null)
const redirectUrl = searchParams.get('redirect_url')
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
@@ -1,13 +1,14 @@
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import I18NContext from '@/context/i18n'
import { sendWebAppEMailLoginCode } from '@/service/common'
export default function MailAndCodeAuth() {
@@ -17,7 +18,7 @@ export default function MailAndCodeAuth() {
const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink)
const [loading, setIsLoading] = useState(false)
const locale = useLocale()
const { locale } = useContext(I18NContext)
const handleGetEMailVerificationCode = async () => {
try {
@@ -1,14 +1,15 @@
'use client'
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import I18NContext from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
import { webAppLogin } from '@/service/common'
import { fetchAccessToken } from '@/service/share'
@@ -20,7 +21,7 @@ type MailAndPasswordAuthProps = {
export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) {
const { t } = useTranslation()
const locale = useLocale()
const { locale } = useContext(I18NContext)
const router = useRouter()
const searchParams = useSearchParams()
const [showPassword, setShowPassword] = useState(false)
@@ -1,6 +1,6 @@
import type { ResponseError } from '@/service/fetch'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
@@ -214,8 +214,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
<div className="body-md-medium text-text-warning">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="account.changeEmail.content1"
ns="common"
i18nKey="common.account.changeEmail.content1"
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
@@ -245,8 +244,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
<div className="space-y-0.5 pb-2 pt-1">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="account.changeEmail.content2"
ns="common"
i18nKey="common.account.changeEmail.content2"
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
@@ -335,8 +333,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
<div className="space-y-0.5 pb-2 pt-1">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="account.changeEmail.content4"
ns="common"
i18nKey="common.account.changeEmail.content4"
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email: mail }}
/>
+1 -37
View File
@@ -1,18 +1,14 @@
'use client'
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { fetchSetupStatus } from '@/service/common'
import { sendGAEvent } from '@/utils/gtag'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
type AppInitializerProps = {
children: ReactNode
@@ -26,10 +22,6 @@ export const AppInitializer = ({
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [oauthNewUser, setOauthNewUser] = useQueryState(
'oauth_new_user',
parseAsString.withOptions({ history: 'replace' }),
)
const isSetupFinished = useCallback(async () => {
try {
@@ -53,34 +45,6 @@ export const AppInitializer = ({
(async () => {
const action = searchParams.get('action')
if (oauthNewUser === 'true') {
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
utmInfo = JSON.parse(utmInfoStr)
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
}
// Track registration event with UTM params
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
// Clean up: remove utm_info cookie and URL params
Cookies.remove('utm_info')
setOauthNewUser(null)
}
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
@@ -103,7 +67,7 @@ export const AppInitializer = ({
router.replace('/signin')
}
})()
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser, setOauthNewUser])
}, [isSetupFinished, router, pathname, searchParams])
return init ? children : null
}
@@ -132,6 +132,7 @@ vi.mock('@/hooks/use-knowledge', () => ({
}))
vi.mock('@/app/components/datasets/rename-modal', () => ({
__esModule: true,
default: ({
show,
onClose,
@@ -13,6 +13,7 @@ vi.mock('next/navigation', () => ({
// Mock classnames utility
vi.mock('@/utils/classnames', () => ({
__esModule: true,
default: (...classes: any[]) => classes.filter(Boolean).join(' '),
}))
@@ -10,6 +10,7 @@ vi.mock('@/context/provider-context', () => ({
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: vi.fn(args => mockToastNotify(args)),
},
@@ -1,8 +1,7 @@
import type { Mock } from 'vitest'
import type { Locale } from '@/i18n-config'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { useLocale } from '@/context/i18n'
import I18nContext from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import CSVDownload from './csv-downloader'
@@ -18,13 +17,17 @@ vi.mock('react-papaparse', () => ({
})),
}))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => 'en-US'),
}))
const renderWithLocale = (locale: Locale) => {
;(useLocale as Mock).mockReturnValue(locale)
return render(<CSVDownload />)
return render(
<I18nContext.Provider value={{
locale,
i18n: {},
setLocaleOnClient: vi.fn().mockResolvedValue(undefined),
}}
>
<CSVDownload />
</I18nContext.Provider>,
)
}
describe('CSVDownload', () => {
@@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next'
import {
useCSVDownloader,
} from 'react-papaparse'
import { useContext } from 'use-context-selector'
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
import { useLocale } from '@/context/i18n'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
const CSV_TEMPLATE_QA_EN = [
@@ -24,7 +24,7 @@ const CSV_TEMPLATE_QA_CN = [
const CSVDownload: FC = () => {
const { t } = useTranslation()
const locale = useLocale()
const { locale } = useContext(I18n)
const { CSVDownloader, Type } = useCSVDownloader()
const getTemplate = () => {
@@ -8,6 +8,7 @@ import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/ser
import BatchModal, { ProcessStatus } from './index'
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: vi.fn(),
},
@@ -23,12 +24,14 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('./csv-downloader', () => ({
__esModule: true,
default: () => <div data-testid="csv-downloader-stub" />,
}))
let lastUploadedFile: File | undefined
vi.mock('./csv-uploader', () => ({
__esModule: true,
default: ({ file, updateFile }: { file?: File, updateFile: (file?: File) => void }) => (
<div>
<button
@@ -46,6 +49,7 @@ vi.mock('./csv-uploader', () => ({
}))
vi.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -26,6 +26,7 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: () => '2023-12-01 10:30:00',
}),
@@ -34,6 +35,7 @@ vi.mock('@/hooks/use-timestamp', () => ({
// Note: i18n is automatically mocked by Vitest via web/vitest.setup.ts
vi.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))
@@ -1,11 +1,10 @@
import type { ComponentProps } from 'react'
import type { Mock } from 'vitest'
import type { AnnotationItemBasic } from '../type'
import type { Locale } from '@/i18n-config'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useLocale } from '@/context/i18n'
import I18NContext from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import HeaderOptions from './index'
@@ -160,21 +159,16 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => LanguagesSupported[0]),
}))
type HeaderOptionsProps = ComponentProps<typeof HeaderOptions>
const renderComponent = (
props: Partial<HeaderOptionsProps> = {},
locale: Locale = LanguagesSupported[0],
) => {
;(useLocale as Mock).mockReturnValue(locale)
const defaultProps: HeaderOptionsProps = {
appId: 'test-app-id',
onAdd: vi.fn(),
@@ -183,7 +177,17 @@ const renderComponent = (
...props,
}
return render(<HeaderOptions {...defaultProps} />)
return render(
<I18NContext.Provider
value={{
locale,
i18n: {},
setLocaleOnClient: vi.fn(),
}}
>
<HeaderOptions {...defaultProps} />
</I18NContext.Provider>,
)
}
const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) => {
@@ -436,12 +440,20 @@ describe('HeaderOptions', () => {
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1))
view.rerender(
<HeaderOptions
appId="test-app-id"
onAdd={vi.fn()}
onAdded={vi.fn()}
controlUpdateList={1}
/>,
<I18NContext.Provider
value={{
locale: LanguagesSupported[0],
i18n: {},
setLocaleOnClient: vi.fn(),
}}
>
<HeaderOptions
appId="test-app-id"
onAdd={vi.fn()}
onAdded={vi.fn()}
controlUpdateList={1}
/>
</I18NContext.Provider>,
)
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2))
@@ -13,14 +13,15 @@ import { useTranslation } from 'react-i18next'
import {
useCSVDownloader,
} from 'react-papaparse'
import { useContext } from 'use-context-selector'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import CustomPopover from '@/app/components/base/popover'
import { useLocale } from '@/context/i18n'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import { cn } from '@/utils/classnames'
import Button from '../../../base/button'
import AddAnnotationModal from '../add-annotation-modal'
import BatchAddModal from '../batch-add-annotation-modal'
@@ -43,7 +44,7 @@ const HeaderOptions: FC<Props> = ({
controlUpdateList,
}) => {
const { t } = useTranslation()
const locale = useLocale()
const { locale } = useContext(I18n)
const { CSVDownloader, Type } = useCSVDownloader()
const [list, setList] = useState<AnnotationItemBasic[]>([])
const annotationUnavailable = list.length === 0
@@ -18,6 +18,7 @@ import Annotation from './index'
import { JobStatus } from './type'
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: { notify: vi.fn() },
}))
@@ -6,6 +6,7 @@ import List from './list'
const mockFormatTime = vi.fn(() => 'formatted-time')
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: mockFormatTime,
}),
@@ -8,6 +8,7 @@ import ViewAnnotationModal from './index'
const mockFormatTime = vi.fn(() => 'formatted-time')
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: mockFormatTime,
}),
@@ -23,6 +24,7 @@ vi.mock('../edit-annotation-modal/edit-item', () => {
Answer: 'answer',
}
return {
__esModule: true,
default: ({ type, content, onSave }: { type: string, content: string, onSave: (value: string) => void }) => (
<div>
<div data-testid={`content-${type}`}>{content}</div>
@@ -4,7 +4,7 @@ import {
RiAddLine,
RiEditLine,
} from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
@@ -3,6 +3,7 @@ import * as React from 'react'
import ConfirmAddVar from './index'
vi.mock('../../base/var-highlight', () => ({
__esModule: true,
default: ({ name }: { name: string }) => <span data-testid="var-highlight">{name}</span>,
}))
@@ -4,6 +4,7 @@ import * as React from 'react'
import EditModal from './edit-modal'
vi.mock('@/app/components/base/modal', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
@@ -8,6 +8,7 @@ vi.mock('@/context/i18n', () => ({
}))
vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({
__esModule: true,
default: ({ onClick }: { onClick: () => void }) => (
<button type="button" data-testid="edit-button" onClick={onClick}>
edit
@@ -16,6 +17,7 @@ vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({
}))
vi.mock('@/app/components/app/configuration/base/feature-panel', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
@@ -31,6 +31,7 @@ const defaultPromptVariables: PromptVariable[] = [
let mockSimplePromptInputProps: IPromptProps | null = null
vi.mock('./simple-prompt-input', () => ({
__esModule: true,
default: (props: IPromptProps) => {
mockSimplePromptInputProps = props
return (
@@ -66,6 +67,7 @@ type AdvancedMessageInputProps = {
}
vi.mock('./advanced-prompt-input', () => ({
__esModule: true,
default: (props: AdvancedMessageInputProps) => {
return (
<div
@@ -4,7 +4,7 @@ import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import type { GenRes } from '@/service/debug'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import * as React from 'react'
import { useState } from 'react'
@@ -7,24 +7,19 @@ export const jsonObjectWrap = {
export const jsonConfigPlaceHolder = JSON.stringify(
{
type: 'object',
properties: {
foo: {
type: 'string',
},
bar: {
type: 'object',
properties: {
sub: {
type: 'number',
},
foo: {
type: 'string',
},
bar: {
type: 'object',
properties: {
sub: {
type: 'number',
},
required: [],
additionalProperties: true,
},
required: [],
additionalProperties: true,
},
required: [],
additionalProperties: true,
},
null,
2,
@@ -28,7 +28,7 @@ import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInpu
import ConfigSelect from '../config-select'
import ConfigString from '../config-string'
import ModalFoot from '../modal-foot'
import { jsonConfigPlaceHolder } from './config'
import { jsonConfigPlaceHolder, jsonObjectWrap } from './config'
import Field from './field'
import TypeSelector from './type-select'
@@ -78,12 +78,13 @@ const ConfigModal: FC<IConfigModalProps> = ({
const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore(state => state.appDetail)
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
const isSupportJSON = false
const jsonSchemaStr = useMemo(() => {
const isJsonObject = type === InputVarType.jsonObject
if (!isJsonObject || !tempPayload.json_schema)
return ''
try {
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
return JSON.stringify(JSON.parse(tempPayload.json_schema).properties, null, 2)
}
catch {
return ''
@@ -128,14 +129,13 @@ const ConfigModal: FC<IConfigModalProps> = ({
}, [])
const handleJSONSchemaChange = useCallback((value: string) => {
const isEmpty = value == null || value.trim() === ''
if (isEmpty) {
handlePayloadChange('json_schema')(undefined)
return null
}
try {
const v = JSON.parse(value)
handlePayloadChange('json_schema')(JSON.stringify(v, null, 2))
const res = {
...jsonObjectWrap,
properties: v,
}
handlePayloadChange('json_schema')(JSON.stringify(res, null, 2))
}
catch {
return null
@@ -175,7 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
},
]
: []),
...((!isBasicApp)
...((!isBasicApp && isSupportJSON)
? [{
name: t('variableConfig.json', { ns: 'appDebug' }),
value: InputVarType.jsonObject,
@@ -233,28 +233,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
if (value === null || value === undefined) {
return true
}
if (typeof value !== 'string') {
return false
}
const trimmed = value.trim()
return trimmed === ''
}
const handleConfirm = () => {
const jsonSchemaValue = tempPayload.json_schema
const isSchemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
const normalizedJsonSchema = isSchemaEmpty ? undefined : jsonSchemaValue
// if the input type is jsonObject and the schema is empty as determined by `isJsonSchemaEmpty`,
// remove the `json_schema` field from the payload by setting its value to `undefined`.
const payloadToSave = tempPayload.type === InputVarType.jsonObject && isSchemaEmpty
? { ...tempPayload, json_schema: undefined }
: tempPayload
const moreInfo = tempPayload.variable === payload?.variable
? undefined
: {
@@ -271,7 +250,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
return
}
if (isStringInput || type === InputVarType.number) {
onConfirm(payloadToSave, moreInfo)
onConfirm(tempPayload, moreInfo)
}
else if (type === InputVarType.select) {
if (options?.length === 0) {
@@ -291,7 +270,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }) })
return
}
onConfirm(payloadToSave, moreInfo)
onConfirm(tempPayload, moreInfo)
}
else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
if (tempPayload.allowed_file_types?.length === 0) {
@@ -304,26 +283,10 @@ const ConfigModal: FC<IConfigModalProps> = ({
Toast.notify({ type: 'error', message: errorMessages })
return
}
onConfirm(payloadToSave, moreInfo)
}
else if (type === InputVarType.jsonObject) {
if (!isSchemaEmpty && typeof normalizedJsonSchema === 'string') {
try {
const schema = JSON.parse(normalizedJsonSchema)
if (schema?.type !== 'object') {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }) })
return
}
}
catch {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }) })
return
}
}
onConfirm(payloadToSave, moreInfo)
onConfirm(tempPayload, moreInfo)
}
else {
onConfirm(payloadToSave, moreInfo)
onConfirm(tempPayload, moreInfo)
}
}
@@ -1,394 +0,0 @@
import type { ReactNode } from 'react'
import type { IConfigVarProps } from './index'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import DebugConfigurationContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index'
const notifySpy = vi.spyOn(Toast, 'notify').mockImplementation(vi.fn())
const setShowExternalDataToolModal = vi.fn()
type SubscriptionEvent = {
type: string
payload: ExternalDataTool
}
let subscriptionCallback: ((event: SubscriptionEvent) => void) | null = null
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: (callback: (event: SubscriptionEvent) => void) => {
subscriptionCallback = callback
},
},
}),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalDataToolModal,
}),
}))
type SortableItem = {
id: string
variable: PromptVariable
}
type SortableProps = {
list: SortableItem[]
setList: (list: SortableItem[]) => void
children: ReactNode
}
let latestSortableProps: SortableProps | null = null
vi.mock('react-sortablejs', () => ({
ReactSortable: (props: SortableProps) => {
latestSortableProps = props
return <div data-testid="sortable">{props.children}</div>
},
}))
type DebugConfigurationState = React.ComponentProps<typeof DebugConfigurationContext.Provider>['value']
const defaultDebugConfigValue = {
mode: AppModeEnum.CHAT,
dataSets: [],
modelConfig: {
model_id: 'test-model',
},
} as unknown as DebugConfigurationState
const createDebugConfigValue = (overrides: Partial<DebugConfigurationState> = {}): DebugConfigurationState => ({
...defaultDebugConfigValue,
...overrides,
} as unknown as DebugConfigurationState)
let variableIndex = 0
const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVariable => {
variableIndex += 1
return {
key: `var_${variableIndex}`,
name: `Variable ${variableIndex}`,
type: 'string',
required: false,
...overrides,
}
}
const renderConfigVar = (props: Partial<IConfigVarProps> = {}, debugOverrides: Partial<DebugConfigurationState> = {}) => {
const defaultProps: IConfigVarProps = {
promptVariables: [],
readonly: false,
onPromptVariablesChange: vi.fn(),
}
const mergedProps = {
...defaultProps,
...props,
}
return render(
<DebugConfigurationContext.Provider value={createDebugConfigValue(debugOverrides)}>
<ConfigVar {...mergedProps} />
</DebugConfigurationContext.Provider>,
)
}
describe('ConfigVar', () => {
// Rendering behavior for empty and populated states.
describe('ConfigVar Rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
})
it('should show empty state when no variables exist', () => {
renderConfigVar({ promptVariables: [] })
expect(screen.getByText('appDebug.notSetVar')).toBeInTheDocument()
})
it('should render variable items and allow reordering via sortable list', () => {
const onPromptVariablesChange = vi.fn()
const firstVar = createPromptVariable({ key: 'first', name: 'First' })
const secondVar = createPromptVariable({ key: 'second', name: 'Second' })
renderConfigVar({
promptVariables: [firstVar, secondVar],
onPromptVariablesChange,
})
expect(screen.getByText('first')).toBeInTheDocument()
expect(screen.getByText('second')).toBeInTheDocument()
act(() => {
latestSortableProps?.setList([
{ id: 'second', variable: secondVar },
{ id: 'first', variable: firstVar },
])
})
expect(onPromptVariablesChange).toHaveBeenCalledWith([secondVar, firstVar])
})
})
// Variable creation flows using the add menu.
describe('ConfigVar Add Variable', () => {
beforeEach(() => {
vi.clearAllMocks()
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
})
it('should add a text variable when selecting the string option', async () => {
const onPromptVariablesChange = vi.fn()
renderConfigVar({ promptVariables: [], onPromptVariablesChange })
fireEvent.click(screen.getByText('common.operation.add'))
fireEvent.click(await screen.findByText('appDebug.variableConfig.string'))
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
const [nextVariables] = onPromptVariablesChange.mock.calls[0]
expect(nextVariables).toHaveLength(1)
expect(nextVariables[0].type).toBe('string')
})
it('should open the external data tool modal when adding an api variable', async () => {
const onPromptVariablesChange = vi.fn()
renderConfigVar({ promptVariables: [], onPromptVariablesChange })
fireEvent.click(screen.getByText('common.operation.add'))
fireEvent.click(await screen.findByText('appDebug.variableConfig.apiBasedVar'))
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
expect(setShowExternalDataToolModal).toHaveBeenCalledTimes(1)
const modalState = setShowExternalDataToolModal.mock.calls[0][0]
expect(modalState.payload.type).toBe('api')
act(() => {
modalState.onCancelCallback?.()
})
expect(onPromptVariablesChange).toHaveBeenLastCalledWith([])
})
it('should restore previous variables when cancelling api variable with existing items', async () => {
const onPromptVariablesChange = vi.fn()
const existingVar = createPromptVariable({ key: 'existing', name: 'Existing' })
renderConfigVar({ promptVariables: [existingVar], onPromptVariablesChange })
fireEvent.click(screen.getByText('common.operation.add'))
fireEvent.click(await screen.findByText('appDebug.variableConfig.apiBasedVar'))
const modalState = setShowExternalDataToolModal.mock.calls[0][0]
act(() => {
modalState.onCancelCallback?.()
})
expect(onPromptVariablesChange).toHaveBeenCalledTimes(2)
const [addedVariables] = onPromptVariablesChange.mock.calls[0]
expect(addedVariables).toHaveLength(2)
expect(addedVariables[0]).toBe(existingVar)
expect(addedVariables[1].type).toBe('api')
expect(onPromptVariablesChange).toHaveBeenLastCalledWith([existingVar])
})
})
// Editing flows for variables through the modal.
describe('ConfigVar Edit Variable', () => {
beforeEach(() => {
vi.clearAllMocks()
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
})
it('should save updates when editing a basic variable', async () => {
const onPromptVariablesChange = vi.fn()
const variable = createPromptVariable({ key: 'name', name: 'Name' })
renderConfigVar({
promptVariables: [variable],
onPromptVariablesChange,
})
const item = screen.getByTitle('name · Name')
const itemContainer = item.closest('div.group')
expect(itemContainer).not.toBeNull()
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
expect(actionButtons).toHaveLength(2)
fireEvent.click(actionButtons[0])
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
fireEvent.click(saveButton)
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
})
it('should show error when variable key is duplicated', async () => {
const onPromptVariablesChange = vi.fn()
const firstVar = createPromptVariable({ key: 'first', name: 'First' })
const secondVar = createPromptVariable({ key: 'second', name: 'Second' })
renderConfigVar({
promptVariables: [firstVar, secondVar],
onPromptVariablesChange,
})
const item = screen.getByTitle('first · First')
const itemContainer = item.closest('div.group')
expect(itemContainer).not.toBeNull()
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
expect(actionButtons).toHaveLength(2)
fireEvent.click(actionButtons[0])
const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder')
fireEvent.change(inputs[0], { target: { value: 'second' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(Toast.notify).toHaveBeenCalled()
expect(onPromptVariablesChange).not.toHaveBeenCalled()
})
it('should show error when variable label is duplicated', async () => {
const onPromptVariablesChange = vi.fn()
const firstVar = createPromptVariable({ key: 'first', name: 'First' })
const secondVar = createPromptVariable({ key: 'second', name: 'Second' })
renderConfigVar({
promptVariables: [firstVar, secondVar],
onPromptVariablesChange,
})
const item = screen.getByTitle('first · First')
const itemContainer = item.closest('div.group')
expect(itemContainer).not.toBeNull()
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
expect(actionButtons).toHaveLength(2)
fireEvent.click(actionButtons[0])
const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder')
fireEvent.change(inputs[1], { target: { value: 'Second' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(Toast.notify).toHaveBeenCalled()
expect(onPromptVariablesChange).not.toHaveBeenCalled()
})
})
// Removal behavior including confirm modal branch.
describe('ConfigVar Remove Variable', () => {
beforeEach(() => {
vi.clearAllMocks()
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
})
it('should remove variable directly when context confirmation is not required', () => {
const onPromptVariablesChange = vi.fn()
const variable = createPromptVariable({ key: 'name', name: 'Name' })
renderConfigVar({
promptVariables: [variable],
onPromptVariablesChange,
})
const removeBtn = screen.getByTestId('var-item-delete-btn')
fireEvent.click(removeBtn)
expect(onPromptVariablesChange).toHaveBeenCalledWith([])
})
it('should require confirmation when removing context variable with datasets in completion mode', () => {
const onPromptVariablesChange = vi.fn()
const variable = createPromptVariable({
key: 'context',
name: 'Context',
is_context_var: true,
})
renderConfigVar(
{
promptVariables: [variable],
onPromptVariablesChange,
},
{
mode: AppModeEnum.COMPLETION,
dataSets: [{ id: 'dataset-1' } as DebugConfigurationState['dataSets'][number]],
},
)
const deleteBtn = screen.getByTestId('var-item-delete-btn')
fireEvent.click(deleteBtn)
// confirmation modal should show up
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(onPromptVariablesChange).toHaveBeenCalledWith([])
})
})
// Event subscription support for external data tools.
describe('ConfigVar External Data Tool Events', () => {
beforeEach(() => {
vi.clearAllMocks()
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
})
it('should append external data tool variables from event emitter', () => {
const onPromptVariablesChange = vi.fn()
renderConfigVar({
promptVariables: [],
onPromptVariablesChange,
})
act(() => {
subscriptionCallback?.({
type: ADD_EXTERNAL_DATA_TOOL,
payload: {
variable: 'api_var',
label: 'API Var',
enabled: true,
type: 'api',
config: {},
icon: 'icon',
icon_background: 'bg',
},
})
})
expect(onPromptVariablesChange).toHaveBeenCalledWith([
expect.objectContaining({
key: 'api_var',
name: 'API Var',
required: true,
type: 'api',
}),
])
})
})
})
@@ -3,11 +3,10 @@ import type { FC } from 'react'
import type { InputVar } from '@/app/components/workflow/types'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { useBoolean } from 'ahooks'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { useContext } from 'use-context-selector'
@@ -34,55 +33,11 @@ type ExternalDataToolParams = {
type: string
index: number
name: string
config?: PromptVariable['config']
config?: Record<string, any>
icon?: string
icon_background?: string
}
const BASIC_INPUT_TYPES = new Set(['string', 'paragraph', 'select', 'number', 'checkbox'])
const toInputVar = (item: PromptVariable): InputVar => ({
...item,
label: item.name,
variable: item.key,
type: (item.type === 'string' ? InputVarType.textInput : item.type) as InputVarType,
required: item.required ?? false,
})
const buildPromptVariableFromInput = (payload: InputVar): PromptVariable => {
const { variable, label, type, ...rest } = payload
const nextType = type === InputVarType.textInput ? 'string' : type
const nextItem: PromptVariable = {
...rest,
type: nextType,
key: variable,
name: label as string,
}
if (payload.type === InputVarType.textInput)
nextItem.max_length = nextItem.max_length || DEFAULT_VALUE_MAX_LEN
if (payload.type !== InputVarType.select)
delete nextItem.options
return nextItem
}
const getDuplicateError = (list: PromptVariable[]) => {
if (hasDuplicateStr(list.map(item => item.key))) {
return {
errorMsgKey: 'varKeyError.keyAlreadyExists',
typeName: 'variableConfig.varName',
}
}
if (hasDuplicateStr(list.map(item => item.name as string))) {
return {
errorMsgKey: 'varKeyError.keyAlreadyExists',
typeName: 'variableConfig.labelName',
}
}
return null
}
export type IConfigVarProps = {
promptVariables: PromptVariable[]
readonly?: boolean
@@ -100,31 +55,61 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
const hasVar = promptVariables.length > 0
const [currIndex, setCurrIndex] = useState<number>(-1)
const currItem = currIndex !== -1 ? promptVariables[currIndex] : null
const currItemToEdit = useMemo(() => {
const currItemToEdit: InputVar | null = (() => {
if (!currItem)
return null
return toInputVar(currItem)
}, [currItem])
const updatePromptVariableItem = useCallback((payload: InputVar) => {
return {
...currItem,
label: currItem.name,
variable: currItem.key,
type: currItem.type === 'string' ? InputVarType.textInput : currItem.type,
} as InputVar
})()
const updatePromptVariableItem = (payload: InputVar) => {
const newPromptVariables = produce(promptVariables, (draft) => {
draft[currIndex] = buildPromptVariableFromInput(payload)
const { variable, label, type, ...rest } = payload
draft[currIndex] = {
...rest,
type: type === InputVarType.textInput ? 'string' : type,
key: variable,
name: label as string,
}
if (payload.type === InputVarType.textInput)
draft[currIndex].max_length = draft[currIndex].max_length || DEFAULT_VALUE_MAX_LEN
if (payload.type !== InputVarType.select)
delete draft[currIndex].options
})
const duplicateError = getDuplicateError(newPromptVariables)
if (duplicateError) {
const newList = newPromptVariables
let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = ''
let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = ''
if (hasDuplicateStr(newList.map(item => item.key))) {
errorMsgKey = 'varKeyError.keyAlreadyExists'
typeName = 'variableConfig.varName'
}
else if (hasDuplicateStr(newList.map(item => item.name as string))) {
errorMsgKey = 'varKeyError.keyAlreadyExists'
typeName = 'variableConfig.labelName'
}
if (errorMsgKey && typeName) {
Toast.notify({
type: 'error',
message: t(duplicateError.errorMsgKey as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug', key: t(duplicateError.typeName as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug' }) }) as string,
message: t(errorMsgKey, { ns: 'appDebug', key: t(typeName, { ns: 'appDebug' }) }),
})
return false
}
onPromptVariablesChange?.(newPromptVariables)
return true
}, [currIndex, onPromptVariablesChange, promptVariables, t])
}
const { setShowExternalDataToolModal } = useModalContext()
const handleOpenExternalDataToolModal = useCallback((
const handleOpenExternalDataToolModal = (
{ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams,
oldPromptVariables: PromptVariable[],
) => {
@@ -172,9 +157,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
return true
},
})
}, [onPromptVariablesChange, promptVariables, setShowExternalDataToolModal, t])
}
const handleAddVar = useCallback((type: string) => {
const handleAddVar = (type: string) => {
const newVar = getNewVar('', type)
const newPromptVariables = [...promptVariables, newVar]
onPromptVariablesChange?.(newPromptVariables)
@@ -187,9 +172,8 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
index: promptVariables.length,
}, newPromptVariables)
}
}, [handleOpenExternalDataToolModal, onPromptVariablesChange, promptVariables])
}
// eslint-disable-next-line ts/no-explicit-any
eventEmitter?.useSubscription((v: any) => {
if (v.type === ADD_EXTERNAL_DATA_TOOL) {
const payload = v.payload
@@ -211,11 +195,11 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
const [isShowDeleteContextVarModal, { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }] = useBoolean(false)
const [removeIndex, setRemoveIndex] = useState<number | null>(null)
const didRemoveVar = useCallback((index: number) => {
const didRemoveVar = (index: number) => {
onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index))
}, [onPromptVariablesChange, promptVariables])
}
const handleRemoveVar = useCallback((index: number) => {
const handleRemoveVar = (index: number) => {
const removeVar = promptVariables[index]
if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) {
@@ -224,20 +208,21 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
return
}
didRemoveVar(index)
}, [dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal])
}
// const [currKey, setCurrKey] = useState<string | null>(null)
const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false)
const handleConfig = useCallback(({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => {
const handleConfig = ({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => {
// setCurrKey(key)
setCurrIndex(index)
if (!BASIC_INPUT_TYPES.has(type)) {
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number' && type !== 'checkbox') {
handleOpenExternalDataToolModal({ key, type, index, name, config, icon, icon_background }, promptVariables)
return
}
showEditModal()
}, [handleOpenExternalDataToolModal, promptVariables, showEditModal])
}
const promptVariablesWithIds = useMemo(() => promptVariables.map((item) => {
return {
@@ -65,7 +65,6 @@ const VarItem: FC<ItemProps> = ({
<RiEditLine className="h-4 w-4 text-text-tertiary" />
</div>
<div
data-testid="var-item-delete-btn"
className="flex h-6 w-6 cursor-pointer items-center justify-center text-text-tertiary hover:text-text-destructive"
onClick={onRemove}
onMouseOver={() => setIsDeleting(true)}
@@ -5,8 +5,18 @@ import * as React from 'react'
import { AgentStrategy } from '@/types/app'
import AgentSettingButton from './agent-setting-button'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}))
let latestAgentSettingProps: any
vi.mock('./agent/agent-setting', () => ({
__esModule: true,
default: (props: any) => {
latestAgentSettingProps = props
return (
@@ -76,6 +76,7 @@ const ToolPickerMock = (props: ToolPickerProps) => (
</div>
)
vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
__esModule: true,
default: (props: ToolPickerProps) => <ToolPickerMock {...props} />,
}))
@@ -95,6 +96,7 @@ const SettingBuiltInToolMock = (props: SettingBuiltInToolProps) => {
)
}
vi.mock('./setting-built-in-tool', () => ({
__esModule: true,
default: (props: SettingBuiltInToolProps) => <SettingBuiltInToolMock {...props} />,
}))
@@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { CollectionType } from '@/app/components/tools/types'
import I18n from '@/context/i18n'
import SettingBuiltInTool from './setting-built-in-tool'
const fetchModelToolList = vi.fn()
@@ -35,6 +36,7 @@ const FormMock = ({ value, onChange }: MockFormProps) => {
)
}
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
__esModule: true,
default: (props: MockFormProps) => <FormMock {...props} />,
}))
@@ -54,10 +56,6 @@ vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({
ReadmeEntrance: ({ className }: { className?: string }) => <div className={className}>readme</div>,
}))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => 'en-US'),
}))
const createParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({
name: 'settingParam',
label: {
@@ -131,16 +129,18 @@ const renderComponent = (props?: Partial<React.ComponentProps<typeof SettingBuil
const onSave = vi.fn()
const onAuthorizationItemClick = vi.fn()
const utils = render(
<SettingBuiltInTool
collection={baseCollection as any}
toolName="search"
isModel
setting={{ settingParam: 'value' }}
onHide={onHide}
onSave={onSave}
onAuthorizationItemClick={onAuthorizationItemClick}
{...props}
/>,
<I18n.Provider value={{ locale: 'en-US', i18n: {}, setLocaleOnClient: vi.fn() as any }}>
<SettingBuiltInTool
collection={baseCollection as any}
toolName="search"
isModel
setting={{ settingParam: 'value' }}
onHide={onHide}
onSave={onSave}
onAuthorizationItemClick={onAuthorizationItemClick}
{...props}
/>
</I18n.Provider>,
)
return {
...utils,
@@ -9,6 +9,7 @@ import {
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import Drawer from '@/app/components/base/drawer'
@@ -25,7 +26,7 @@ import {
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { CollectionType } from '@/app/components/tools/types'
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { useLocale } from '@/context/i18n'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools'
import { cn } from '@/utils/classnames'
@@ -57,7 +58,7 @@ const SettingBuiltInTool: FC<Props> = ({
credentialId,
onAuthorizationItemClick,
}) => {
const locale = useLocale()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const { t } = useTranslation()
const passedTools = (collection as ToolWithProvider).tools
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { ExternalDataTool } from '@/models/common'
import copy from 'copy-to-clipboard'
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@@ -15,6 +15,15 @@ vi.mock('use-context-selector', async (importOriginal) => {
}
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}))
const mockUseFeatures = vi.fn()
const mockUseFeaturesStore = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
@@ -17,11 +17,13 @@ vi.mock('use-context-selector', async (importOriginal) => {
const mockFormattingDispatcher = vi.fn()
vi.mock('../debug/hooks', () => ({
__esModule: true,
useFormattingChangedDispatcher: () => mockFormattingDispatcher,
}))
let latestConfigPromptProps: any
vi.mock('@/app/components/app/configuration/config-prompt', () => ({
__esModule: true,
default: (props: any) => {
latestConfigPromptProps = props
return <div data-testid="config-prompt" />
@@ -30,6 +32,7 @@ vi.mock('@/app/components/app/configuration/config-prompt', () => ({
let latestConfigVarProps: any
vi.mock('@/app/components/app/configuration/config-var', () => ({
__esModule: true,
default: (props: any) => {
latestConfigVarProps = props
return <div data-testid="config-var" />
@@ -37,27 +40,33 @@ vi.mock('@/app/components/app/configuration/config-var', () => ({
}))
vi.mock('../dataset-config', () => ({
__esModule: true,
default: () => <div data-testid="dataset-config" />,
}))
vi.mock('./agent/agent-tools', () => ({
__esModule: true,
default: () => <div data-testid="agent-tools" />,
}))
vi.mock('../config-vision', () => ({
__esModule: true,
default: () => <div data-testid="config-vision" />,
}))
vi.mock('./config-document', () => ({
__esModule: true,
default: () => <div data-testid="config-document" />,
}))
vi.mock('./config-audio', () => ({
__esModule: true,
default: () => <div data-testid="config-audio" />,
}))
let latestHistoryPanelProps: any
vi.mock('../config-prompt/conversation-history/history-panel', () => ({
__esModule: true,
default: (props: any) => {
latestHistoryPanelProps = props
return <div data-testid="history-panel" />
@@ -11,6 +11,7 @@ import { RETRIEVE_METHOD } from '@/types/app'
import Item from './index'
vi.mock('../settings-modal', () => ({
__esModule: true,
default: ({ onSave, onCancel, currentDataset }: any) => (
<div>
<div>Mock settings modal</div>
@@ -23,6 +24,7 @@ vi.mock('../settings-modal', () => ({
vi.mock('@/hooks/use-breakpoints', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks/use-breakpoints')>()
return {
__esModule: true,
...actual,
default: vi.fn(() => actual.MediaType.pc),
}
@@ -80,6 +80,7 @@ vi.mock('uuid', () => ({
// Mock child components
vi.mock('./card-item', () => ({
__esModule: true,
default: ({ config, onRemove, onSave, editable }: any) => (
<div data-testid={`card-item-${config.id}`}>
<span>{config.name}</span>
@@ -90,6 +91,7 @@ vi.mock('./card-item', () => ({
}))
vi.mock('./params-config', () => ({
__esModule: true,
default: ({ disabled, selectedDatasets }: any) => (
<button data-testid="params-config" disabled={disabled}>
Params (
@@ -100,6 +102,7 @@ vi.mock('./params-config', () => ({
}))
vi.mock('./context-var', () => ({
__esModule: true,
default: ({ value, options, onChange }: any) => (
<select data-testid="context-var" value={value} onChange={e => onChange(e.target.value)}>
<option value="">Select context variable</option>
@@ -111,6 +114,7 @@ vi.mock('./context-var', () => ({
}))
vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter', () => ({
__esModule: true,
default: ({
metadataList,
metadataFilterMode,
@@ -194,6 +198,7 @@ const mockConfigContext: any = {
}
vi.mock('@/context/debug-configuration', () => ({
__esModule: true,
default: ({ children }: any) => (
<div data-testid="config-context-provider">
{children}
@@ -30,11 +30,13 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
)
return {
__esModule: true,
default: MockModelSelector,
}
})
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div data-testid="model-parameter-modal" />,
}))
@@ -65,11 +65,13 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
)
return {
__esModule: true,
default: MockModelSelector,
}
})
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div data-testid="model-parameter-modal" />,
}))
@@ -1,4 +1,4 @@
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/base/slider'
@@ -9,6 +9,7 @@ import { RETRIEVE_METHOD } from '@/types/app'
import SelectDataSet from './index'
vi.mock('@/i18n-config/i18next-config', () => ({
__esModule: true,
default: {
changeLanguage: vi.fn(),
addResourceBundle: vi.fn(),
@@ -33,6 +33,7 @@ vi.mock('ky', () => {
})
vi.mock('@/app/components/datasets/create/step-two', () => ({
__esModule: true,
IndexingType: {
QUALIFIED: 'high_quality',
ECONOMICAL: 'economy',
@@ -44,6 +45,7 @@ vi.mock('@/service/datasets', () => ({
}))
vi.mock('@/service/use-common', async () => ({
__esModule: true,
...(await vi.importActual('@/service/use-common')),
useMembers: vi.fn(),
}))
@@ -84,6 +86,7 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
__esModule: true,
useModelList: (...args: unknown[]) => mockUseModelList(...args),
useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
@@ -92,6 +95,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
__esModule: true,
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div data-testid="model-selector">
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
@@ -3,7 +3,7 @@ import type { Member } from '@/models/common'
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'es-toolkit/predicate'
import { isEqual } from 'es-toolkit/compat'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -34,6 +34,7 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
__esModule: true,
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args),
useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
@@ -42,6 +43,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
__esModule: true,
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div data-testid="model-selector">
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
@@ -50,6 +52,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
}))
vi.mock('@/app/components/datasets/create/step-two', () => ({
__esModule: true,
IndexingType: {
QUALIFIED: 'high_quality',
ECONOMICAL: 'economy',
@@ -1,7 +1,7 @@
'use client'
import type { ModelAndParameter } from '../types'
import { noop } from 'es-toolkit/function'
import { noop } from 'es-toolkit/compat'
import { createContext, useContext } from 'use-context-selector'
export type DebugWithMultipleModelContextType = {
@@ -52,22 +52,27 @@ const mockFiles: FileEntity[] = [
]
vi.mock('@/context/debug-configuration', () => ({
__esModule: true,
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
__esModule: true,
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeaturesSelector(selector),
}))
vi.mock('@/context/event-emitter', () => ({
__esModule: true,
useEventEmitterContextContext: () => mockUseEventEmitterContext(),
}))
vi.mock('@/app/components/app/store', () => ({
__esModule: true,
useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => mockUseAppStoreSelector(selector),
}))
vi.mock('./debug-item', () => ({
__esModule: true,
default: ({
modelAndParameter,
className,
@@ -90,6 +95,7 @@ vi.mock('./debug-item', () => ({
}))
vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({
__esModule: true,
default: (props: MockChatInputAreaProps) => {
capturedChatInputProps = props
return (
@@ -4,8 +4,7 @@ import type {
OnSend,
TextGenerationConfig,
} from '@/app/components/base/text-generation/types'
import { noop } from 'es-toolkit/function'
import { cloneDeep } from 'es-toolkit/object'
import { cloneDeep, noop } from 'es-toolkit/compat'
import { memo } from 'react'
import TextGeneration from '@/app/components/app/text-generate/item'
import { TransferMethod } from '@/app/components/base/chat/types'
@@ -403,6 +403,7 @@ vi.mock('@/app/components/base/toast', () => ({
// Mock hooks/use-timestamp
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: vi.fn(() => ({
formatTime: vi.fn((timestamp: number) => new Date(timestamp).toLocaleString()),
})),
@@ -6,7 +6,7 @@ import type {
ChatConfig,
ChatItem,
} from '@/app/components/base/chat/types'
import { cloneDeep } from 'es-toolkit/object'
import { cloneDeep } from 'es-toolkit/compat'
import {
useCallback,
useRef,
@@ -11,8 +11,7 @@ import {
RiSparklingFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { cloneDeep } from 'es-toolkit/object'
import { cloneDeep, noop } from 'es-toolkit/compat'
import { produce, setAutoFreeze } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -1,6 +1,6 @@
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ChatPromptConfig, CompletionPromptConfig, ConversationHistoriesRole, PromptItem } from '@/models/debug'
import { clone } from 'es-toolkit/object'
import { clone } from 'es-toolkit/compat'
import { produce } from 'immer'
import { useState } from 'react'
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock, PRE_PROMPT_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants'
@@ -20,8 +20,7 @@ import type {
import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { isEqual } from 'es-toolkit/predicate'
import { clone, isEqual } from 'es-toolkit/compat'
import { produce } from 'immer'
import { usePathname } from 'next/navigation'
import * as React from 'react'

Some files were not shown because too many files have changed in this diff Show More