Compare commits

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

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

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

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

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

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

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

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

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

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

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

This prevents KeyError crashes and returns proper HTTP error codes
(403/404) instead of 500 errors.
2026-03-04 19:55:03 -08:00
135 changed files with 2142 additions and 12401 deletions
-6
View File
@@ -25,10 +25,6 @@ updates:
interval: "weekly"
open-pull-requests-limit: 2
groups:
lexical:
patterns:
- "lexical"
- "@lexical/*"
storybook:
patterns:
- "storybook"
@@ -37,7 +33,5 @@ updates:
patterns:
- "*"
exclude-patterns:
- "lexical"
- "@lexical/*"
- "storybook"
- "@storybook/*"
+1 -1
View File
@@ -133,7 +133,7 @@ Star Dify on GitHub and be instantly notified of new releases.
### Custom configurations
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker-compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
#### Customizing Suggested Questions
+55
View File
@@ -1,16 +1,38 @@
import logging
import time
from flask import request
from opentelemetry.trace import get_current_span
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
from configs import dify_config
from contexts.wrapper import RecyclableContextVar
from controllers.console.error import UnauthorizedAndForceLogout
from core.logging.context import init_request_context
from dify_app import DifyApp
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import LicenseStatus
logger = logging.getLogger(__name__)
# Console bootstrap APIs exempt from license check:
# - system-features: license status for expiry UI (GlobalPublicStoreProvider)
# - setup: install/setup status check (AppInitializer)
# - init: init password validation for fresh install (InitPasswordPopup)
# - login: auto-login after setup completion (InstallForm)
# - version: version check (AppContextProvider)
# - activate/check: invitation link validation (signin page)
# Without these exemptions, the signin page triggers location.reload()
# on unauthorized_and_force_logout, causing an infinite loop.
_CONSOLE_EXEMPT_PREFIXES = (
"/console/api/system-features",
"/console/api/setup",
"/console/api/init",
"/console/api/login",
"/console/api/version",
"/console/api/activate/check",
)
# ----------------------------
# Application Factory Function
@@ -31,6 +53,39 @@ def create_flask_app_with_configs() -> DifyApp:
init_request_context()
RecyclableContextVar.increment_thread_recycles()
# Enterprise license validation for API endpoints (both console and webapp)
# When license expires, block all API access except bootstrap endpoints needed
# for the frontend to load the license expiration page without infinite reloads.
if dify_config.ENTERPRISE_ENABLED:
is_console_api = request.path.startswith("/console/api/")
is_webapp_api = request.path.startswith("/api/")
if is_console_api or is_webapp_api:
if is_console_api:
is_exempt = any(request.path.startswith(p) for p in _CONSOLE_EXEMPT_PREFIXES)
else: # webapp API
is_exempt = request.path.startswith("/api/system-features")
if not is_exempt:
try:
# Check license status (cached — see EnterpriseService for TTL details)
license_status = EnterpriseService.get_cached_license_status()
if license_status in (LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST):
raise UnauthorizedAndForceLogout(
f"Enterprise license is {license_status}. Please contact your administrator."
)
if license_status is None:
raise UnauthorizedAndForceLogout(
"Unable to verify enterprise license. Please contact your administrator."
)
except UnauthorizedAndForceLogout:
raise
except Exception:
logger.exception("Failed to check enterprise license status")
raise UnauthorizedAndForceLogout(
"Unable to verify enterprise license. Please contact your administrator."
)
# add after request hook for injecting trace headers from OpenTelemetry span context
# Only adds headers when OTEL is enabled and has valid context
@dify_app.after_request
+11 -80
View File
@@ -30,7 +30,6 @@ from extensions.ext_redis import redis_client
from extensions.ext_storage import storage
from extensions.storage.opendal_storage import OpenDALStorage
from extensions.storage.storage_type import StorageType
from libs.datetime_utils import naive_utc_now
from libs.db_migration_lock import DbMigrationAutoRenewLock
from libs.helper import email as email_validate
from libs.password import hash_password, password_pattern, valid_password
@@ -2599,29 +2598,15 @@ def migrate_oss(
@click.option(
"--start-from",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
required=False,
default=None,
required=True,
help="Lower bound (inclusive) for created_at.",
)
@click.option(
"--end-before",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
required=False,
default=None,
required=True,
help="Upper bound (exclusive) for created_at.",
)
@click.option(
"--from-days-ago",
type=int,
default=None,
help="Relative lower bound in days ago (inclusive). Must be used with --before-days.",
)
@click.option(
"--before-days",
type=int,
default=None,
help="Relative upper bound in days ago (exclusive). Required for relative mode.",
)
@click.option("--batch-size", default=1000, show_default=True, help="Batch size for selecting messages.")
@click.option(
"--graceful-period",
@@ -2633,10 +2618,8 @@ def migrate_oss(
def clean_expired_messages(
batch_size: int,
graceful_period: int,
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
from_days_ago: int | None,
before_days: int | None,
start_from: datetime.datetime,
end_before: datetime.datetime,
dry_run: bool,
):
"""
@@ -2647,70 +2630,18 @@ def clean_expired_messages(
start_at = time.perf_counter()
try:
abs_mode = start_from is not None and end_before is not None
rel_mode = before_days is not None
if abs_mode and rel_mode:
raise click.UsageError(
"Options are mutually exclusive: use either (--start-from,--end-before) "
"or (--from-days-ago,--before-days)."
)
if from_days_ago is not None and before_days is None:
raise click.UsageError("--from-days-ago must be used together with --before-days.")
if (start_from is None) ^ (end_before is None):
raise click.UsageError("Both --start-from and --end-before are required when using absolute time range.")
if not abs_mode and not rel_mode:
raise click.UsageError(
"You must provide either (--start-from,--end-before) or (--before-days [--from-days-ago])."
)
if rel_mode:
assert before_days is not None
if before_days < 0:
raise click.UsageError("--before-days must be >= 0.")
if from_days_ago is not None:
if from_days_ago < 0:
raise click.UsageError("--from-days-ago must be >= 0.")
if from_days_ago <= before_days:
raise click.UsageError("--from-days-ago must be greater than --before-days.")
# Create policy based on billing configuration
# NOTE: graceful_period will be ignored when billing is disabled.
policy = create_message_clean_policy(graceful_period_days=graceful_period)
# Create and run the cleanup service
if abs_mode:
assert start_from is not None
assert end_before is not None
service = MessagesCleanService.from_time_range(
policy=policy,
start_from=start_from,
end_before=end_before,
batch_size=batch_size,
dry_run=dry_run,
)
elif from_days_ago is None:
assert before_days is not None
service = MessagesCleanService.from_days(
policy=policy,
days=before_days,
batch_size=batch_size,
dry_run=dry_run,
)
else:
assert before_days is not None
assert from_days_ago is not None
now = naive_utc_now()
service = MessagesCleanService.from_time_range(
policy=policy,
start_from=now - datetime.timedelta(days=from_days_ago),
end_before=now - datetime.timedelta(days=before_days),
batch_size=batch_size,
dry_run=dry_run,
)
service = MessagesCleanService.from_time_range(
policy=policy,
start_from=start_from,
end_before=end_before,
batch_size=batch_size,
dry_run=dry_run,
)
stats = service.run()
end_at = time.perf_counter()
+1 -1
View File
@@ -239,7 +239,7 @@ class MessageSuggestedQuestionApi(WebApiResource):
def get(self, app_model, end_user, message_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
raise NotCompletionAppError()
message_id = str(message_id)
@@ -1,6 +1,7 @@
import hashlib
import logging
from threading import Thread, Timer
import time
from threading import Thread
from typing import Union
from flask import Flask, current_app
@@ -95,9 +96,9 @@ class MessageCycleManager:
if auto_generate_conversation_name and is_first_message:
# start generate thread
# time.sleep not block other logic
thread = Timer(
1,
self._generate_conversation_name_worker,
time.sleep(1)
thread = Thread(
target=self._generate_conversation_name_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"conversation_id": conversation_id,
@@ -37,7 +37,6 @@ VARIABLE_TO_PARAMETER_TYPE_MAPPING = {
VariableEntityType.CHECKBOX: ToolParameter.ToolParameterType.BOOLEAN,
VariableEntityType.FILE: ToolParameter.ToolParameterType.FILE,
VariableEntityType.FILE_LIST: ToolParameter.ToolParameterType.FILES,
VariableEntityType.JSON_OBJECT: ToolParameter.ToolParameterType.OBJECT,
}
@@ -4,7 +4,6 @@ import json
import logging
import os
import tempfile
import zipfile
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any
@@ -83,18 +82,8 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
value = variable.value
inputs = {"variable_selector": variable_selector}
if isinstance(value, list):
value = list(filter(lambda x: x, value))
process_data = {"documents": value if isinstance(value, list) else [value]}
if not value:
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=inputs,
process_data=process_data,
outputs={"text": ArrayStringSegment(value=[])},
)
try:
if isinstance(value, list):
extracted_text_list = [
@@ -122,7 +111,6 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
else:
raise DocumentExtractorError(f"Unsupported variable type: {type(value)}")
except DocumentExtractorError as e:
logger.warning(e, exc_info=True)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=str(e),
@@ -397,32 +385,6 @@ def parser_docx_part(block, doc: Document, content_items, i):
content_items.append((i, "table", Table(block, doc)))
def _normalize_docx_zip(file_content: bytes) -> bytes:
"""
Some DOCX files (e.g. exported by Evernote on Windows) are malformed:
ZIP entry names use backslash (\\) as path separator instead of the forward
slash (/) required by both the ZIP spec and OOXML. On Linux/Mac the entry
"word\\document.xml" is never found when python-docx looks for
"word/document.xml", which triggers a KeyError about a missing relationship.
This function rewrites the ZIP in-memory, normalizing all entry names to
use forward slashes without touching any actual document content.
"""
try:
with zipfile.ZipFile(io.BytesIO(file_content), "r") as zin:
out_buf = io.BytesIO()
with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
# Normalize backslash path separators to forward slash
item.filename = item.filename.replace("\\", "/")
zout.writestr(item, data)
return out_buf.getvalue()
except zipfile.BadZipFile:
# Not a valid zip — return as-is and let python-docx report the real error
return file_content
def _extract_text_from_docx(file_content: bytes) -> str:
"""
Extract text from a DOCX file.
@@ -430,15 +392,7 @@ def _extract_text_from_docx(file_content: bytes) -> str:
"""
try:
doc_file = io.BytesIO(file_content)
try:
doc = docx.Document(doc_file)
except Exception as e:
logger.warning("Failed to parse DOCX, attempting to normalize ZIP entry paths: %s", e)
# Some DOCX files exported by tools like Evernote on Windows use
# backslash path separators in ZIP entries and/or single-quoted XML
# attributes, both of which break python-docx on Linux. Normalize and retry.
file_content = _normalize_docx_zip(file_content)
doc = docx.Document(io.BytesIO(file_content))
doc = docx.Document(doc_file)
text = []
# Keep track of paragraph and table positions
@@ -23,11 +23,7 @@ from dify_graph.variables import (
)
from dify_graph.variables.segments import ArrayObjectSegment
from .entities import (
Condition,
KnowledgeRetrievalNodeData,
MetadataFilteringCondition,
)
from .entities import KnowledgeRetrievalNodeData
from .exc import (
KnowledgeRetrievalNodeError,
RateLimitExceededError,
@@ -175,12 +171,6 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
if node_data.metadata_filtering_mode is not None:
metadata_filtering_mode = node_data.metadata_filtering_mode
resolved_metadata_conditions = (
self._resolve_metadata_filtering_conditions(node_data.metadata_filtering_conditions)
if node_data.metadata_filtering_conditions
else None
)
if str(node_data.retrieval_mode) == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE and query:
# fetch model config
if node_data.single_retrieval_config is None:
@@ -199,7 +189,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
model_mode=model.mode,
model_name=model.name,
metadata_model_config=node_data.metadata_model_config,
metadata_filtering_conditions=resolved_metadata_conditions,
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
metadata_filtering_mode=metadata_filtering_mode,
query=query,
)
@@ -257,7 +247,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
weights=weights,
reranking_enable=node_data.multiple_retrieval_config.reranking_enable,
metadata_model_config=node_data.metadata_model_config,
metadata_filtering_conditions=resolved_metadata_conditions,
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
metadata_filtering_mode=metadata_filtering_mode,
attachment_ids=[attachment.related_id for attachment in attachments] if attachments else None,
)
@@ -266,48 +256,6 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
usage = self._rag_retrieval.llm_usage
return retrieval_resource_list, usage
def _resolve_metadata_filtering_conditions(
self, conditions: MetadataFilteringCondition
) -> MetadataFilteringCondition:
if conditions.conditions is None:
return MetadataFilteringCondition(
logical_operator=conditions.logical_operator,
conditions=None,
)
variable_pool = self.graph_runtime_state.variable_pool
resolved_conditions: list[Condition] = []
for cond in conditions.conditions or []:
value = cond.value
if isinstance(value, str):
segment_group = variable_pool.convert_template(value)
if len(segment_group.value) == 1:
resolved_value = segment_group.value[0].to_object()
else:
resolved_value = segment_group.text
elif isinstance(value, Sequence) and all(isinstance(v, str) for v in value):
resolved_values = []
for v in value: # type: ignore
segment_group = variable_pool.convert_template(v)
if len(segment_group.value) == 1:
resolved_values.append(segment_group.value[0].to_object())
else:
resolved_values.append(segment_group.text)
resolved_value = resolved_values
else:
resolved_value = value
resolved_conditions.append(
Condition(
name=cond.name,
comparison_operator=cond.comparison_operator,
value=resolved_value,
)
)
return MetadataFilteringCondition(
logical_operator=conditions.logical_operator or "and",
conditions=resolved_conditions,
)
@classmethod
def _extract_variable_selector_to_variable_mapping(
cls,
-4
View File
@@ -21,10 +21,6 @@ celery_redis = Redis(
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,
# Add conservative socket timeouts and health checks to avoid long-lived half-open sockets
socket_timeout=5,
socket_connect_timeout=5,
health_check_interval=30,
)
logger = logging.getLogger(__name__)
+7 -13
View File
@@ -3,7 +3,6 @@ import math
import time
from collections.abc import Iterable, Sequence
from celery import group
from sqlalchemy import ColumnElement, and_, func, or_, select
from sqlalchemy.engine.row import Row
from sqlalchemy.orm import Session
@@ -86,25 +85,20 @@ def trigger_provider_refresh() -> None:
lock_keys: list[str] = build_trigger_refresh_lock_keys(subscriptions)
acquired: list[bool] = _acquire_locks(keys=lock_keys, ttl_seconds=lock_ttl)
if not any(acquired):
continue
jobs = [
trigger_subscription_refresh.s(tenant_id=tenant_id, subscription_id=subscription_id)
for (tenant_id, subscription_id), is_locked in zip(subscriptions, acquired)
if is_locked
]
result = group(jobs).apply_async()
enqueued = len(jobs)
enqueued: int = 0
for (tenant_id, subscription_id), is_locked in zip(subscriptions, acquired):
if not is_locked:
continue
trigger_subscription_refresh.delay(tenant_id=tenant_id, subscription_id=subscription_id)
enqueued += 1
logger.info(
"Trigger refresh page %d/%d: scanned=%d locks_acquired=%d enqueued=%d result=%s",
"Trigger refresh page %d/%d: scanned=%d locks_acquired=%d enqueued=%d",
page + 1,
pages,
len(subscriptions),
sum(1 for x in acquired if x),
enqueued,
result,
)
logger.info("Trigger refresh scan done: due=%d", total_due)
+19 -15
View File
@@ -1,6 +1,6 @@
import logging
from celery import current_app, group, shared_task
from celery import group, shared_task
from sqlalchemy import and_, select
from sqlalchemy.orm import Session, sessionmaker
@@ -29,27 +29,31 @@ def poll_workflow_schedules() -> None:
with session_factory() as session:
total_dispatched = 0
# Process in batches until we've handled all due schedules or hit the limit
while True:
due_schedules = _fetch_due_schedules(session)
if not due_schedules:
break
with current_app.producer_or_acquire() as producer: # type: ignore
dispatched_count = _process_schedules(session, due_schedules, producer)
total_dispatched += dispatched_count
dispatched_count = _process_schedules(session, due_schedules)
total_dispatched += dispatched_count
logger.debug("Batch processed: %d dispatched", dispatched_count)
logger.debug("Batch processed: %d dispatched", dispatched_count)
# Circuit breaker: check if we've hit the per-tick limit (if enabled)
if (
dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK > 0
and total_dispatched >= dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK
):
logger.warning(
"Circuit breaker activated: reached dispatch limit (%d), will continue next tick",
dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK,
)
break
# Circuit breaker: check if we've hit the per-tick limit (if enabled)
if 0 < dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK <= total_dispatched:
logger.warning(
"Circuit breaker activated: reached dispatch limit (%d), will continue next tick",
dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK,
)
break
if total_dispatched > 0:
logger.info("Total processed: %d workflow schedule(s) dispatched", total_dispatched)
logger.info("Total processed: %d dispatched", total_dispatched)
def _fetch_due_schedules(session: Session) -> list[WorkflowSchedulePlan]:
@@ -86,7 +90,7 @@ def _fetch_due_schedules(session: Session) -> list[WorkflowSchedulePlan]:
return list(due_schedules)
def _process_schedules(session: Session, schedules: list[WorkflowSchedulePlan], producer=None) -> int:
def _process_schedules(session: Session, schedules: list[WorkflowSchedulePlan]) -> int:
"""Process schedules: check quota, update next run time and dispatch to Celery in parallel."""
if not schedules:
return 0
@@ -103,7 +107,7 @@ def _process_schedules(session: Session, schedules: list[WorkflowSchedulePlan],
if tasks_to_dispatch:
job = group(run_schedule_trigger.s(schedule_id) for schedule_id in tasks_to_dispatch)
job.apply_async(producer=producer)
job.apply_async()
logger.debug("Dispatched %d tasks in parallel", len(tasks_to_dispatch))
+50 -3
View File
@@ -6,6 +6,13 @@ from typing import Any
import httpx
from core.helper.trace_id_helper import generate_traceparent_header
from services.errors.enterprise import (
EnterpriseAPIBadRequestError,
EnterpriseAPIError,
EnterpriseAPIForbiddenError,
EnterpriseAPINotFoundError,
EnterpriseAPIUnauthorizedError,
)
logger = logging.getLogger(__name__)
@@ -41,7 +48,6 @@ class BaseRequest:
params: Mapping[str, Any] | None = None,
*,
timeout: float | httpx.Timeout | None = None,
raise_for_status: bool = False,
) -> Any:
headers = {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key}
url = f"{cls.base_url}{endpoint}"
@@ -64,10 +70,51 @@ class BaseRequest:
request_kwargs["timeout"] = timeout
response = client.request(method, url, **request_kwargs)
if raise_for_status:
response.raise_for_status()
# Validate HTTP status and raise domain-specific errors
if not response.is_success:
cls._handle_error_response(response)
return response.json()
@classmethod
def _handle_error_response(cls, response: httpx.Response) -> None:
"""
Handle non-2xx HTTP responses by raising appropriate domain errors.
Attempts to extract error message from JSON response body,
falls back to status text if parsing fails.
"""
error_message = f"Enterprise API request failed: {response.status_code} {response.reason_phrase}"
# Try to extract error message from JSON response
try:
error_data = response.json()
if isinstance(error_data, dict):
# Common error response formats:
# {"error": "...", "message": "..."}
# {"message": "..."}
# {"detail": "..."}
error_message = (
error_data.get("message") or error_data.get("error") or error_data.get("detail") or error_message
)
except Exception:
# If JSON parsing fails, use the default message
logger.debug(
"Failed to parse error response from enterprise API (status=%s)", response.status_code, exc_info=True
)
# Raise specific error based on status code
if response.status_code == 400:
raise EnterpriseAPIBadRequestError(error_message)
elif response.status_code == 401:
raise EnterpriseAPIUnauthorizedError(error_message)
elif response.status_code == 403:
raise EnterpriseAPIForbiddenError(error_message)
elif response.status_code == 404:
raise EnterpriseAPINotFoundError(error_message)
else:
raise EnterpriseAPIError(error_message, status_code=response.status_code)
class EnterpriseRequest(BaseRequest):
base_url = os.environ.get("ENTERPRISE_API_URL", "ENTERPRISE_API_URL")
+73 -2
View File
@@ -1,15 +1,26 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field, model_validator
from configs import dify_config
from extensions.ext_redis import redis_client
from services.enterprise.base import EnterpriseRequest
if TYPE_CHECKING:
from services.feature_service import LicenseStatus
logger = logging.getLogger(__name__)
DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS = 1.0
# License status cache configuration
LICENSE_STATUS_CACHE_KEY = "enterprise:license:status"
VALID_LICENSE_CACHE_TTL = 600 # 10 minutes — valid licenses are stable
INVALID_LICENSE_CACHE_TTL = 30 # 30 seconds — short so admin fixes are picked up quickly
class WebAppSettings(BaseModel):
@@ -52,7 +63,7 @@ class DefaultWorkspaceJoinResult(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
@model_validator(mode="after")
def _check_workspace_id_when_joined(self) -> "DefaultWorkspaceJoinResult":
def _check_workspace_id_when_joined(self) -> DefaultWorkspaceJoinResult:
if self.joined and not self.workspace_id:
raise ValueError("workspace_id must be non-empty when joined is True")
return self
@@ -115,7 +126,6 @@ class EnterpriseService:
"/default-workspace/members",
json={"account_id": account_id},
timeout=DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS,
raise_for_status=True,
)
if not isinstance(data, dict):
raise ValueError("Invalid response format from enterprise default workspace API")
@@ -223,3 +233,64 @@ class EnterpriseService:
params = {"appId": app_id}
EnterpriseRequest.send_request("DELETE", "/webapp/clean", params=params)
@classmethod
def get_cached_license_status(cls) -> LicenseStatus | None:
"""Get enterprise license status with Redis caching to reduce HTTP calls.
Caches valid statuses (active/expiring) for 10 minutes and invalid statuses
(inactive/expired/lost) for 30 seconds. The shorter TTL for invalid statuses
balances prompt license-fix detection against DoS mitigation — without
caching, every request on an expired license would hit the enterprise API.
Returns:
LicenseStatus enum value, or None if enterprise is disabled / unreachable.
"""
if not dify_config.ENTERPRISE_ENABLED:
return None
cached = cls._read_cached_license_status()
if cached is not None:
return cached
return cls._fetch_and_cache_license_status()
@classmethod
def _read_cached_license_status(cls) -> LicenseStatus | None:
"""Read license status from Redis cache, returning None on miss or failure."""
from services.feature_service import LicenseStatus
try:
raw = redis_client.get(LICENSE_STATUS_CACHE_KEY)
if raw:
value = raw.decode("utf-8") if isinstance(raw, bytes) else raw
return LicenseStatus(value)
except Exception:
logger.warning("Failed to read license status from cache", exc_info=True)
return None
@classmethod
def _fetch_and_cache_license_status(cls) -> LicenseStatus | None:
"""Fetch license status from enterprise API and cache the result."""
from services.feature_service import LicenseStatus
try:
info = cls.get_info()
license_info = info.get("License")
if not license_info:
return None
status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
ttl = (
VALID_LICENSE_CACHE_TTL
if status in (LicenseStatus.ACTIVE, LicenseStatus.EXPIRING)
else INVALID_LICENSE_CACHE_TTL
)
try:
redis_client.setex(LICENSE_STATUS_CACHE_KEY, ttl, status)
except Exception:
logger.warning("Failed to cache license status", exc_info=True)
return status
except Exception:
logger.exception("Failed to get enterprise license status")
return None
+2
View File
@@ -7,6 +7,7 @@ from . import (
conversation,
dataset,
document,
enterprise,
file,
index,
message,
@@ -21,6 +22,7 @@ __all__ = [
"conversation",
"dataset",
"document",
"enterprise",
"file",
"index",
"message",
+45
View File
@@ -0,0 +1,45 @@
"""Enterprise service errors."""
from services.errors.base import BaseServiceError
class EnterpriseServiceError(BaseServiceError):
"""Base exception for enterprise service errors."""
def __init__(self, description: str | None = None, status_code: int | None = None):
super().__init__(description)
self.status_code = status_code
class EnterpriseAPIError(EnterpriseServiceError):
"""Generic enterprise API error (non-2xx response)."""
pass
class EnterpriseAPINotFoundError(EnterpriseServiceError):
"""Enterprise API returned 404 Not Found."""
def __init__(self, description: str | None = None):
super().__init__(description, status_code=404)
class EnterpriseAPIForbiddenError(EnterpriseServiceError):
"""Enterprise API returned 403 Forbidden."""
def __init__(self, description: str | None = None):
super().__init__(description, status_code=403)
class EnterpriseAPIUnauthorizedError(EnterpriseServiceError):
"""Enterprise API returned 401 Unauthorized."""
def __init__(self, description: str | None = None):
super().__init__(description, status_code=401)
class EnterpriseAPIBadRequestError(EnterpriseServiceError):
"""Enterprise API returned 400 Bad Request."""
def __init__(self, description: str | None = None):
super().__init__(description, status_code=400)
+11 -6
View File
@@ -379,14 +379,19 @@ class FeatureService:
)
features.webapp_auth.sso_config.protocol = enterprise_info.get("SSOEnforcedForWebProtocol", "")
if is_authenticated and (license_info := enterprise_info.get("License")):
# SECURITY NOTE: Only license *status* is exposed to unauthenticated callers
# so the login page can detect an expired/inactive license after force-logout.
# All other license details (expiry date, workspace usage) remain auth-gated.
# This behavior reflects prior internal review of information-leakage risks.
if license_info := enterprise_info.get("License"):
features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
features.license.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
features.license.workspaces.enabled = workspaces_info.get("enabled", False)
features.license.workspaces.limit = workspaces_info.get("limit", 0)
features.license.workspaces.size = workspaces_info.get("used", 0)
if is_authenticated:
features.license.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
features.license.workspaces.enabled = workspaces_info.get("enabled", False)
features.license.workspaces.limit = workspaces_info.get("limit", 0)
features.license.workspaces.size = workspaces_info.get("used", 0)
if "PluginInstallationPermission" in enterprise_info:
plugin_installation_info = enterprise_info["PluginInstallationPermission"]
@@ -12,7 +12,6 @@ from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from models.model import (
App,
AppAnnotationHitHistory,
@@ -143,7 +142,7 @@ class MessagesCleanService:
if batch_size <= 0:
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
end_before = naive_utc_now() - datetime.timedelta(days=days)
end_before = datetime.datetime.now() - datetime.timedelta(days=days)
logger.info(
"clean_messages: days=%s, end_before=%s, batch_size=%s, policy=%s",
+14 -25
View File
@@ -1,10 +1,9 @@
import logging
import time
from collections.abc import Sequence
from typing import Any, Protocol
from collections.abc import Callable, Sequence
import click
from celery import current_app, shared_task
from celery import shared_task
from configs import dify_config
from core.db.session_factory import session_factory
@@ -20,12 +19,6 @@ from tasks.generate_summary_index_task import generate_summary_index_task
logger = logging.getLogger(__name__)
class CeleryTaskLike(Protocol):
def delay(self, *args: Any, **kwargs: Any) -> Any: ...
def apply_async(self, *args: Any, **kwargs: Any) -> Any: ...
@shared_task(queue="dataset")
def document_indexing_task(dataset_id: str, document_ids: list):
"""
@@ -186,8 +179,8 @@ def _document_indexing(dataset_id: str, document_ids: Sequence[str]):
def _document_indexing_with_tenant_queue(
tenant_id: str, dataset_id: str, document_ids: Sequence[str], task_func: CeleryTaskLike
) -> None:
tenant_id: str, dataset_id: str, document_ids: Sequence[str], task_func: Callable[[str, str, Sequence[str]], None]
):
try:
_document_indexing(dataset_id, document_ids)
except Exception:
@@ -208,20 +201,16 @@ def _document_indexing_with_tenant_queue(
logger.info("document indexing tenant isolation queue %s next tasks: %s", tenant_id, next_tasks)
if next_tasks:
with current_app.producer_or_acquire() as producer: # type: ignore
for next_task in next_tasks:
document_task = DocumentTask(**next_task)
# Keep the flag set to indicate a task is running
tenant_isolated_task_queue.set_task_waiting_time()
task_func.apply_async(
kwargs={
"tenant_id": document_task.tenant_id,
"dataset_id": document_task.dataset_id,
"document_ids": document_task.document_ids,
},
producer=producer,
)
for next_task in next_tasks:
document_task = DocumentTask(**next_task)
# Process the next waiting task
# Keep the flag set to indicate a task is running
tenant_isolated_task_queue.set_task_waiting_time()
task_func.delay( # type: ignore
tenant_id=document_task.tenant_id,
dataset_id=document_task.dataset_id,
document_ids=document_task.document_ids,
)
else:
# No more waiting tasks, clear the flag
tenant_isolated_task_queue.delete_task_key()
+12 -26
View File
@@ -3,13 +3,12 @@ import json
import logging
import time
import uuid
from collections.abc import Mapping, Sequence
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from itertools import islice
from typing import Any
import click
from celery import group, shared_task
from celery import shared_task # type: ignore
from flask import current_app, g
from sqlalchemy.orm import Session, sessionmaker
@@ -28,11 +27,6 @@ from services.file_service import FileService
logger = logging.getLogger(__name__)
def chunked(iterable: Sequence, size: int):
it = iter(iterable)
return iter(lambda: list(islice(it, size)), [])
@shared_task(queue="pipeline")
def rag_pipeline_run_task(
rag_pipeline_invoke_entities_file_id: str,
@@ -89,24 +83,16 @@ def rag_pipeline_run_task(
logger.info("rag pipeline tenant isolation queue %s next files: %s", tenant_id, next_file_ids)
if next_file_ids:
for batch in chunked(next_file_ids, 100):
jobs = []
for next_file_id in batch:
tenant_isolated_task_queue.set_task_waiting_time()
file_id = (
next_file_id.decode("utf-8") if isinstance(next_file_id, (bytes, bytearray)) else next_file_id
)
jobs.append(
rag_pipeline_run_task.s(
rag_pipeline_invoke_entities_file_id=file_id,
tenant_id=tenant_id,
)
)
if jobs:
group(jobs).apply_async()
for next_file_id in next_file_ids:
# Process the next waiting task
# Keep the flag set to indicate a task is running
tenant_isolated_task_queue.set_task_waiting_time()
rag_pipeline_run_task.delay( # type: ignore
rag_pipeline_invoke_entities_file_id=next_file_id.decode("utf-8")
if isinstance(next_file_id, bytes)
else next_file_id,
tenant_id=tenant_id,
)
else:
# No more waiting tasks, clear the flag
tenant_isolated_task_queue.delete_task_key()
@@ -358,10 +358,9 @@ class TestFeatureService:
assert result is not None
assert isinstance(result, SystemFeatureModel)
# --- 1. Verify Response Payload Optimization (Data Minimization) ---
# Ensure only essential UI flags are returned to unauthenticated clients
# to keep the payload lightweight and adhere to architectural boundaries.
assert result.license.status == LicenseStatus.NONE
# --- 1. Verify only license *status* is exposed to unauthenticated clients ---
# Detailed license info (expiry, workspaces) remains auth-gated.
assert result.license.status == LicenseStatus.ACTIVE
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
assert result.license.workspaces.limit == 0
@@ -322,14 +322,11 @@ class TestDatasetIndexingTaskIntegration:
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
# Assert
# apply_async is used by implementation; assert it was called once with expected kwargs
assert task_dispatch_spy.apply_async.call_count == 1
call_kwargs = task_dispatch_spy.apply_async.call_args.kwargs.get("kwargs", {})
assert call_kwargs == {
"tenant_id": next_task["tenant_id"],
"dataset_id": next_task["dataset_id"],
"document_ids": next_task["document_ids"],
}
task_dispatch_spy.delay.assert_called_once_with(
tenant_id=next_task["tenant_id"],
dataset_id=next_task["dataset_id"],
document_ids=next_task["document_ids"],
)
set_waiting_spy.assert_called_once()
delete_key_spy.assert_not_called()
@@ -355,7 +352,7 @@ class TestDatasetIndexingTaskIntegration:
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
# Assert
task_dispatch_spy.apply_async.assert_not_called()
task_dispatch_spy.delay.assert_not_called()
delete_key_spy.assert_called_once()
def test_validation_failure_sets_error_status_when_vector_space_at_limit(
@@ -450,7 +447,7 @@ class TestDatasetIndexingTaskIntegration:
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
# Assert
task_dispatch_spy.apply_async.assert_called_once()
task_dispatch_spy.delay.assert_called_once()
def test_sessions_close_on_successful_indexing(
self,
@@ -537,7 +534,7 @@ class TestDatasetIndexingTaskIntegration:
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
# Assert
assert task_dispatch_spy.apply_async.call_count == concurrency_limit
assert task_dispatch_spy.delay.call_count == concurrency_limit
assert set_waiting_spy.call_count == concurrency_limit
def test_task_queue_fifo_ordering(self, db_session_with_containers, patched_external_dependencies):
@@ -568,10 +565,9 @@ class TestDatasetIndexingTaskIntegration:
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
# Assert
assert task_dispatch_spy.apply_async.call_count == 3
assert task_dispatch_spy.delay.call_count == 3
for index, expected_task in enumerate(ordered_tasks):
call_kwargs = task_dispatch_spy.apply_async.call_args_list[index].kwargs.get("kwargs", {})
assert call_kwargs.get("document_ids") == expected_task["document_ids"]
assert task_dispatch_spy.delay.call_args_list[index].kwargs["document_ids"] == expected_task["document_ids"]
def test_billing_disabled_skips_limit_checks(self, db_session_with_containers, patched_external_dependencies):
"""Skip limit checks when billing feature is disabled."""
@@ -762,12 +762,11 @@ class TestDocumentIndexingTasks:
mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once()
# Verify task function was called for each waiting task
assert mock_task_func.apply_async.call_count == 1
assert mock_task_func.delay.call_count == 1
# Verify correct parameters for each call
calls = mock_task_func.apply_async.call_args_list
sent_kwargs = calls[0][1]["kwargs"]
assert sent_kwargs == {"tenant_id": tenant_id, "dataset_id": dataset_id, "document_ids": ["waiting-doc-1"]}
calls = mock_task_func.delay.call_args_list
assert calls[0][1] == {"tenant_id": tenant_id, "dataset_id": dataset_id, "document_ids": ["waiting-doc-1"]}
# Verify queue is empty after processing (tasks were pulled)
remaining_tasks = queue.pull_tasks(count=10) # Pull more than we added
@@ -831,15 +830,11 @@ class TestDocumentIndexingTasks:
assert updated_document.processing_started_at is not None
# Verify waiting task was still processed despite core processing error
mock_task_func.apply_async.assert_called_once()
mock_task_func.delay.assert_called_once()
# Verify correct parameters for the call
call = mock_task_func.apply_async.call_args
assert call[1]["kwargs"] == {
"tenant_id": tenant_id,
"dataset_id": dataset_id,
"document_ids": ["waiting-doc-1"],
}
call = mock_task_func.delay.call_args
assert call[1] == {"tenant_id": tenant_id, "dataset_id": dataset_id, "document_ids": ["waiting-doc-1"]}
# Verify queue is empty after processing (task was pulled)
remaining_tasks = queue.pull_tasks(count=10)
@@ -901,13 +896,9 @@ class TestDocumentIndexingTasks:
mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once()
# Verify only tenant1's waiting task was processed
mock_task_func.apply_async.assert_called_once()
call = mock_task_func.apply_async.call_args
assert call[1]["kwargs"] == {
"tenant_id": tenant1_id,
"dataset_id": dataset1_id,
"document_ids": ["tenant1-doc-1"],
}
mock_task_func.delay.assert_called_once()
call = mock_task_func.delay.call_args
assert call[1] == {"tenant_id": tenant1_id, "dataset_id": dataset1_id, "document_ids": ["tenant1-doc-1"]}
# Verify tenant1's queue is empty
remaining_tasks1 = queue1.pull_tasks(count=10)
@@ -1,6 +1,6 @@
import json
import uuid
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from faker import Faker
@@ -388,10 +388,8 @@ class TestRagPipelineRunTasks:
# Set the task key to indicate there are waiting tasks (legacy behavior)
redis_client.set(legacy_task_key, 1, ex=60 * 60)
# Mock the Celery group scheduling used by the implementation
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
mock_group.return_value.apply_async = MagicMock()
# Mock the task function calls
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
# Act: Execute the priority task with new code but legacy queue data
rag_pipeline_run_task(file_id, tenant.id)
@@ -400,14 +398,13 @@ class TestRagPipelineRunTasks:
mock_file_service["delete_file"].assert_called_once_with(file_id)
assert mock_pipeline_generator.call_count == 1
# Verify waiting tasks were processed via group, pull 1 task a time by default
assert mock_group.return_value.apply_async.called
# Verify waiting tasks were processed, pull 1 task a time by default
assert mock_delay.call_count == 1
# Verify correct parameters for the first scheduled job signature
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
first_kwargs = jobs[0].kwargs if jobs else {}
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == legacy_file_ids[0]
assert first_kwargs.get("tenant_id") == tenant.id
# Verify correct parameters for the call
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == legacy_file_ids[0]
assert call_kwargs.get("tenant_id") == tenant.id
# Verify that new code can process legacy queue entries
# The new TenantIsolatedTaskQueue should be able to read from the legacy format
@@ -449,10 +446,8 @@ class TestRagPipelineRunTasks:
waiting_file_ids = [str(uuid.uuid4()) for _ in range(3)]
queue.push_tasks(waiting_file_ids)
# Mock the Celery group scheduling used by the implementation
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
mock_group.return_value.apply_async = MagicMock()
# Mock the task function calls
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
# Act: Execute the regular task
rag_pipeline_run_task(file_id, tenant.id)
@@ -461,14 +456,13 @@ class TestRagPipelineRunTasks:
mock_file_service["delete_file"].assert_called_once_with(file_id)
assert mock_pipeline_generator.call_count == 1
# Verify waiting tasks were processed via group.apply_async
assert mock_group.return_value.apply_async.called
# Verify waiting tasks were processed, pull 1 task a time by default
assert mock_delay.call_count == 1
# Verify correct parameters for the first scheduled job signature
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
first_kwargs = jobs[0].kwargs if jobs else {}
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_ids[0]
assert first_kwargs.get("tenant_id") == tenant.id
# Verify correct parameters for the call
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_ids[0]
assert call_kwargs.get("tenant_id") == tenant.id
# Verify queue still has remaining tasks (only 1 was pulled)
remaining_tasks = queue.pull_tasks(count=10)
@@ -563,10 +557,8 @@ class TestRagPipelineRunTasks:
waiting_file_id = str(uuid.uuid4())
queue.push_tasks([waiting_file_id])
# Mock the Celery group scheduling used by the implementation
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
mock_group.return_value.apply_async = MagicMock()
# Mock the task function calls
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
# Act: Execute the regular task (should not raise exception)
rag_pipeline_run_task(file_id, tenant.id)
@@ -577,13 +569,12 @@ class TestRagPipelineRunTasks:
assert mock_pipeline_generator.call_count == 1
# Verify waiting task was still processed despite core processing error
assert mock_group.return_value.apply_async.called
mock_delay.assert_called_once()
# Verify correct parameters for the first scheduled job signature
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
first_kwargs = jobs[0].kwargs if jobs else {}
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id
assert first_kwargs.get("tenant_id") == tenant.id
# Verify correct parameters for the call
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id
assert call_kwargs.get("tenant_id") == tenant.id
# Verify queue is empty after processing (task was pulled)
remaining_tasks = queue.pull_tasks(count=10)
@@ -693,10 +684,8 @@ class TestRagPipelineRunTasks:
queue1.push_tasks([waiting_file_id1])
queue2.push_tasks([waiting_file_id2])
# Mock the Celery group scheduling used by the implementation
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
mock_group.return_value.apply_async = MagicMock()
# Mock the task function calls
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
# Act: Execute the regular task for tenant1 only
rag_pipeline_run_task(file_id1, tenant1.id)
@@ -705,12 +694,11 @@ class TestRagPipelineRunTasks:
assert mock_file_service["delete_file"].call_count == 1
assert mock_pipeline_generator.call_count == 1
# Verify only tenant1's waiting task was processed (via group)
assert mock_group.return_value.apply_async.called
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
first_kwargs = jobs[0].kwargs if jobs else {}
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id1
assert first_kwargs.get("tenant_id") == tenant1.id
# Verify only tenant1's waiting task was processed
mock_delay.assert_called_once()
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id1
assert call_kwargs.get("tenant_id") == tenant1.id
# Verify tenant1's queue is empty
remaining_tasks1 = queue1.pull_tasks(count=10)
@@ -925,10 +913,8 @@ class TestRagPipelineRunTasks:
waiting_file_id = str(uuid.uuid4())
queue.push_tasks([waiting_file_id])
# Mock the Celery group scheduling used by the implementation
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
mock_group.return_value.apply_async = MagicMock()
# Mock the task function calls
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
# Act & Assert: Execute the regular task (should raise Exception)
with pytest.raises(Exception, match="File not found"):
rag_pipeline_run_task(file_id, tenant.id)
@@ -938,13 +924,12 @@ class TestRagPipelineRunTasks:
mock_pipeline_generator.assert_not_called()
# Verify waiting task was still processed despite file error
assert mock_group.return_value.apply_async.called
mock_delay.assert_called_once()
# Verify correct parameters for the first scheduled job signature
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
first_kwargs = jobs[0].kwargs if jobs else {}
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id
assert first_kwargs.get("tenant_id") == tenant.id
# Verify correct parameters for the call
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id
assert call_kwargs.get("tenant_id") == tenant.id
# Verify queue is empty after processing (task was pulled)
remaining_tasks = queue.pull_tasks(count=10)
@@ -105,26 +105,18 @@ def app_model(
class MockCeleryGroup:
"""Mock for celery group() function that collects dispatched tasks.
Matches the Celery group API loosely, accepting arbitrary kwargs on apply_async
(e.g. producer) so production code can pass broker-related options without
breaking tests.
"""
"""Mock for celery group() function that collects dispatched tasks."""
def __init__(self) -> None:
self.collected: list[dict[str, Any]] = []
self._applied = False
self.last_apply_async_kwargs: dict[str, Any] | None = None
def __call__(self, items: Any) -> MockCeleryGroup:
self.collected = list(items)
return self
def apply_async(self, **kwargs: Any) -> None:
# Accept arbitrary kwargs like producer to be compatible with Celery
def apply_async(self) -> None:
self._applied = True
self.last_apply_async_kwargs = kwargs
@property
def applied(self) -> bool:
@@ -1,181 +0,0 @@
import datetime
import re
from unittest.mock import MagicMock, patch
import click
import pytest
from commands import clean_expired_messages
def _mock_service() -> MagicMock:
service = MagicMock()
service.run.return_value = {
"batches": 1,
"total_messages": 10,
"filtered_messages": 5,
"total_deleted": 5,
}
return service
def test_absolute_mode_calls_from_time_range():
policy = object()
service = _mock_service()
start_from = datetime.datetime(2024, 1, 1, 0, 0, 0)
end_before = datetime.datetime(2024, 2, 1, 0, 0, 0)
with (
patch("commands.create_message_clean_policy", return_value=policy),
patch("commands.MessagesCleanService.from_time_range", return_value=service) as mock_from_time_range,
patch("commands.MessagesCleanService.from_days") as mock_from_days,
):
clean_expired_messages.callback(
batch_size=200,
graceful_period=21,
start_from=start_from,
end_before=end_before,
from_days_ago=None,
before_days=None,
dry_run=True,
)
mock_from_time_range.assert_called_once_with(
policy=policy,
start_from=start_from,
end_before=end_before,
batch_size=200,
dry_run=True,
)
mock_from_days.assert_not_called()
def test_relative_mode_before_days_only_calls_from_days():
policy = object()
service = _mock_service()
with (
patch("commands.create_message_clean_policy", return_value=policy),
patch("commands.MessagesCleanService.from_days", return_value=service) as mock_from_days,
patch("commands.MessagesCleanService.from_time_range") as mock_from_time_range,
):
clean_expired_messages.callback(
batch_size=500,
graceful_period=14,
start_from=None,
end_before=None,
from_days_ago=None,
before_days=30,
dry_run=False,
)
mock_from_days.assert_called_once_with(
policy=policy,
days=30,
batch_size=500,
dry_run=False,
)
mock_from_time_range.assert_not_called()
def test_relative_mode_with_from_days_ago_calls_from_time_range():
policy = object()
service = _mock_service()
fixed_now = datetime.datetime(2024, 8, 20, 12, 0, 0)
with (
patch("commands.create_message_clean_policy", return_value=policy),
patch("commands.MessagesCleanService.from_time_range", return_value=service) as mock_from_time_range,
patch("commands.MessagesCleanService.from_days") as mock_from_days,
patch("commands.naive_utc_now", return_value=fixed_now),
):
clean_expired_messages.callback(
batch_size=1000,
graceful_period=21,
start_from=None,
end_before=None,
from_days_ago=60,
before_days=30,
dry_run=False,
)
mock_from_time_range.assert_called_once_with(
policy=policy,
start_from=fixed_now - datetime.timedelta(days=60),
end_before=fixed_now - datetime.timedelta(days=30),
batch_size=1000,
dry_run=False,
)
mock_from_days.assert_not_called()
@pytest.mark.parametrize(
("kwargs", "message"),
[
(
{
"start_from": datetime.datetime(2024, 1, 1),
"end_before": datetime.datetime(2024, 2, 1),
"from_days_ago": None,
"before_days": 30,
},
"mutually exclusive",
),
(
{
"start_from": datetime.datetime(2024, 1, 1),
"end_before": None,
"from_days_ago": None,
"before_days": None,
},
"Both --start-from and --end-before are required",
),
(
{
"start_from": None,
"end_before": None,
"from_days_ago": 10,
"before_days": None,
},
"--from-days-ago must be used together with --before-days",
),
(
{
"start_from": None,
"end_before": None,
"from_days_ago": None,
"before_days": -1,
},
"--before-days must be >= 0",
),
(
{
"start_from": None,
"end_before": None,
"from_days_ago": 30,
"before_days": 30,
},
"--from-days-ago must be greater than --before-days",
),
(
{
"start_from": None,
"end_before": None,
"from_days_ago": None,
"before_days": None,
},
"You must provide either (--start-from,--end-before) or (--before-days [--from-days-ago])",
),
],
)
def test_invalid_inputs_raise_usage_error(kwargs: dict, message: str):
with pytest.raises(click.UsageError, match=re.escape(message)):
clean_expired_messages.callback(
batch_size=1000,
graceful_period=21,
start_from=kwargs["start_from"],
end_before=kwargs["end_before"],
from_days_ago=kwargs["from_days_ago"],
before_days=kwargs["before_days"],
dry_run=False,
)
@@ -1,70 +0,0 @@
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
RemoteFileUploadError,
TooManyFilesError,
UnsupportedFileTypeError,
)
class TestFilenameNotExistsError:
def test_defaults(self):
error = FilenameNotExistsError()
assert error.code == 400
assert error.description == "The specified filename does not exist."
class TestRemoteFileUploadError:
def test_defaults(self):
error = RemoteFileUploadError()
assert error.code == 400
assert error.description == "Error uploading remote file."
class TestFileTooLargeError:
def test_defaults(self):
error = FileTooLargeError()
assert error.code == 413
assert error.error_code == "file_too_large"
assert error.description == "File size exceeded. {message}"
class TestUnsupportedFileTypeError:
def test_defaults(self):
error = UnsupportedFileTypeError()
assert error.code == 415
assert error.error_code == "unsupported_file_type"
assert error.description == "File type not allowed."
class TestBlockedFileExtensionError:
def test_defaults(self):
error = BlockedFileExtensionError()
assert error.code == 400
assert error.error_code == "file_extension_blocked"
assert error.description == "The file extension is blocked for security reasons."
class TestTooManyFilesError:
def test_defaults(self):
error = TooManyFilesError()
assert error.code == 400
assert error.error_code == "too_many_files"
assert error.description == "Only one file is allowed."
class TestNoFileUploadedError:
def test_defaults(self):
error = NoFileUploadedError()
assert error.code == 400
assert error.error_code == "no_file_uploaded"
assert error.description == "Please upload your file."
@@ -1,95 +1,22 @@
from flask import Response
from controllers.common.file_response import (
_normalize_mime_type,
enforce_download_for_html,
is_html_content,
)
from controllers.common.file_response import enforce_download_for_html, is_html_content
class TestNormalizeMimeType:
def test_returns_empty_string_for_none(self):
assert _normalize_mime_type(None) == ""
def test_returns_empty_string_for_empty_string(self):
assert _normalize_mime_type("") == ""
def test_normalizes_mime_type(self):
assert _normalize_mime_type("Text/HTML; Charset=UTF-8") == "text/html"
class TestIsHtmlContent:
def test_detects_html_via_mime_type(self):
class TestFileResponseHelpers:
def test_is_html_content_detects_mime_type(self):
mime_type = "text/html; charset=UTF-8"
result = is_html_content(
mime_type=mime_type,
filename="file.txt",
extension="txt",
)
result = is_html_content(mime_type, filename="file.txt", extension="txt")
assert result is True
def test_detects_html_via_extension_argument(self):
result = is_html_content(
mime_type="text/plain",
filename=None,
extension="html",
)
def test_is_html_content_detects_extension(self):
result = is_html_content("text/plain", filename="report.html", extension=None)
assert result is True
def test_detects_html_via_filename_extension(self):
result = is_html_content(
mime_type="text/plain",
filename="report.html",
extension=None,
)
assert result is True
def test_returns_false_when_no_html_detected_anywhere(self):
"""
Missing negative test:
- MIME type is not HTML
- filename has no HTML extension
- extension argument is not HTML
"""
result = is_html_content(
mime_type="application/json",
filename="data.json",
extension="json",
)
assert result is False
def test_returns_false_when_all_inputs_are_none(self):
result = is_html_content(
mime_type=None,
filename=None,
extension=None,
)
assert result is False
class TestEnforceDownloadForHtml:
def test_sets_attachment_when_filename_missing(self):
response = Response("payload", mimetype="text/html")
updated = enforce_download_for_html(
response,
mime_type="text/html",
filename=None,
extension="html",
)
assert updated is True
assert response.headers["Content-Disposition"] == "attachment"
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_sets_headers_when_filename_present(self):
def test_enforce_download_for_html_sets_headers(self):
response = Response("payload", mimetype="text/html")
updated = enforce_download_for_html(
@@ -100,12 +27,11 @@ class TestEnforceDownloadForHtml:
)
assert updated is True
assert response.headers["Content-Disposition"].startswith("attachment")
assert "unsafe.html" in response.headers["Content-Disposition"]
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_does_not_modify_response_for_non_html_content(self):
def test_enforce_download_for_html_no_change_for_non_html(self):
response = Response("payload", mimetype="text/plain")
updated = enforce_download_for_html(
@@ -1,188 +0,0 @@
from uuid import UUID
import httpx
import pytest
from controllers.common import helpers
from controllers.common.helpers import FileInfo, guess_file_info_from_response
def make_response(
url="https://example.com/file.txt",
headers=None,
content=None,
):
return httpx.Response(
200,
request=httpx.Request("GET", url),
headers=headers or {},
content=content or b"",
)
class TestGuessFileInfoFromResponse:
def test_filename_from_url(self):
response = make_response(
url="https://example.com/test.pdf",
content=b"Hello World",
)
info = guess_file_info_from_response(response)
assert info.filename == "test.pdf"
assert info.extension == ".pdf"
assert info.mimetype == "application/pdf"
def test_filename_from_content_disposition(self):
headers = {
"Content-Disposition": "attachment; filename=myfile.csv",
"Content-Type": "text/csv",
}
response = make_response(
url="https://example.com/",
headers=headers,
content=b"Hello World",
)
info = guess_file_info_from_response(response)
assert info.filename == "myfile.csv"
assert info.extension == ".csv"
assert info.mimetype == "text/csv"
@pytest.mark.parametrize(
("magic_available", "expected_ext"),
[
(True, "txt"),
(False, "bin"),
],
)
def test_generated_filename_when_missing(self, monkeypatch, magic_available, expected_ext):
if magic_available:
if helpers.magic is None:
pytest.skip("python-magic is not installed, cannot run 'magic_available=True' test variant")
else:
monkeypatch.setattr(helpers, "magic", None)
response = make_response(
url="https://example.com/",
content=b"Hello World",
)
info = guess_file_info_from_response(response)
name, ext = info.filename.split(".")
UUID(name)
assert ext == expected_ext
def test_mimetype_from_header_when_unknown(self):
headers = {"Content-Type": "application/json"}
response = make_response(
url="https://example.com/file.unknown",
headers=headers,
content=b'{"a": 1}',
)
info = guess_file_info_from_response(response)
assert info.mimetype == "application/json"
def test_extension_added_when_missing(self):
headers = {"Content-Type": "image/png"}
response = make_response(
url="https://example.com/image",
headers=headers,
content=b"fakepngdata",
)
info = guess_file_info_from_response(response)
assert info.extension == ".png"
assert info.filename.endswith(".png")
def test_content_length_used_as_size(self):
headers = {
"Content-Length": "1234",
"Content-Type": "text/plain",
}
response = make_response(
url="https://example.com/a.txt",
headers=headers,
content=b"a" * 1234,
)
info = guess_file_info_from_response(response)
assert info.size == 1234
def test_size_minus_one_when_header_missing(self):
response = make_response(url="https://example.com/a.txt")
info = guess_file_info_from_response(response)
assert info.size == -1
def test_fallback_to_bin_extension(self):
headers = {"Content-Type": "application/octet-stream"}
response = make_response(
url="https://example.com/download",
headers=headers,
content=b"\x00\x01\x02\x03",
)
info = guess_file_info_from_response(response)
assert info.extension == ".bin"
assert info.filename.endswith(".bin")
def test_return_type(self):
response = make_response()
info = guess_file_info_from_response(response)
assert isinstance(info, FileInfo)
class TestMagicImportWarnings:
@pytest.mark.parametrize(
("platform_name", "expected_message"),
[
("Windows", "pip install python-magic-bin"),
("Darwin", "brew install libmagic"),
("Linux", "sudo apt-get install libmagic1"),
("Other", "install `libmagic`"),
],
)
def test_magic_import_warning_per_platform(
self,
monkeypatch,
platform_name,
expected_message,
):
import builtins
import importlib
# Force ImportError when "magic" is imported
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "magic":
raise ImportError("No module named magic")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
monkeypatch.setattr("platform.system", lambda: platform_name)
# Remove helpers so it imports fresh
import sys
original_helpers = sys.modules.get(helpers.__name__)
sys.modules.pop(helpers.__name__, None)
try:
with pytest.warns(UserWarning, match="To use python-magic") as warning:
imported_helpers = importlib.import_module(helpers.__name__)
assert expected_message in str(warning[0].message)
finally:
if original_helpers is not None:
sys.modules[helpers.__name__] = original_helpers
@@ -1,189 +0,0 @@
import sys
from enum import StrEnum
from unittest.mock import MagicMock, patch
import pytest
from flask_restx import Namespace
from pydantic import BaseModel
class UserModel(BaseModel):
id: int
name: str
class ProductModel(BaseModel):
id: int
price: float
@pytest.fixture(autouse=True)
def mock_console_ns():
"""Mock the console_ns to avoid circular imports during test collection."""
mock_ns = MagicMock(spec=Namespace)
mock_ns.models = {}
# Inject mock before importing schema module
with patch.dict(sys.modules, {"controllers.console": MagicMock(console_ns=mock_ns)}):
yield mock_ns
def test_default_ref_template_value():
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0
assert DEFAULT_REF_TEMPLATE_SWAGGER_2_0 == "#/definitions/{model}"
def test_register_schema_model_calls_namespace_schema_model():
from controllers.common.schema import register_schema_model
namespace = MagicMock(spec=Namespace)
register_schema_model(namespace, UserModel)
namespace.schema_model.assert_called_once()
model_name, schema = namespace.schema_model.call_args.args
assert model_name == "UserModel"
assert isinstance(schema, dict)
assert "properties" in schema
def test_register_schema_model_passes_schema_from_pydantic():
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_model
namespace = MagicMock(spec=Namespace)
register_schema_model(namespace, UserModel)
schema = namespace.schema_model.call_args.args[1]
expected_schema = UserModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
assert schema == expected_schema
def test_register_schema_models_registers_multiple_models():
from controllers.common.schema import register_schema_models
namespace = MagicMock(spec=Namespace)
register_schema_models(namespace, UserModel, ProductModel)
assert namespace.schema_model.call_count == 2
called_names = [call.args[0] for call in namespace.schema_model.call_args_list]
assert called_names == ["UserModel", "ProductModel"]
def test_register_schema_models_calls_register_schema_model(monkeypatch):
from controllers.common.schema import register_schema_models
namespace = MagicMock(spec=Namespace)
calls = []
def fake_register(ns, model):
calls.append((ns, model))
monkeypatch.setattr(
"controllers.common.schema.register_schema_model",
fake_register,
)
register_schema_models(namespace, UserModel, ProductModel)
assert calls == [
(namespace, UserModel),
(namespace, ProductModel),
]
class StatusEnum(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
class PriorityEnum(StrEnum):
HIGH = "high"
LOW = "low"
def test_get_or_create_model_returns_existing_model(mock_console_ns):
from controllers.common.schema import get_or_create_model
existing_model = MagicMock()
mock_console_ns.models = {"TestModel": existing_model}
result = get_or_create_model("TestModel", {"key": "value"})
assert result == existing_model
mock_console_ns.model.assert_not_called()
def test_get_or_create_model_creates_new_model_when_not_exists(mock_console_ns):
from controllers.common.schema import get_or_create_model
mock_console_ns.models = {}
new_model = MagicMock()
mock_console_ns.model.return_value = new_model
field_def = {"name": {"type": "string"}}
result = get_or_create_model("NewModel", field_def)
assert result == new_model
mock_console_ns.model.assert_called_once_with("NewModel", field_def)
def test_get_or_create_model_does_not_call_model_if_exists(mock_console_ns):
from controllers.common.schema import get_or_create_model
existing_model = MagicMock()
mock_console_ns.models = {"ExistingModel": existing_model}
result = get_or_create_model("ExistingModel", {"key": "value"})
assert result == existing_model
mock_console_ns.model.assert_not_called()
def test_register_enum_models_registers_single_enum():
from controllers.common.schema import register_enum_models
namespace = MagicMock(spec=Namespace)
register_enum_models(namespace, StatusEnum)
namespace.schema_model.assert_called_once()
model_name, schema = namespace.schema_model.call_args.args
assert model_name == "StatusEnum"
assert isinstance(schema, dict)
def test_register_enum_models_registers_multiple_enums():
from controllers.common.schema import register_enum_models
namespace = MagicMock(spec=Namespace)
register_enum_models(namespace, StatusEnum, PriorityEnum)
assert namespace.schema_model.call_count == 2
called_names = [call.args[0] for call in namespace.schema_model.call_args_list]
assert called_names == ["StatusEnum", "PriorityEnum"]
def test_register_enum_models_uses_correct_ref_template():
from controllers.common.schema import register_enum_models
namespace = MagicMock(spec=Namespace)
register_enum_models(namespace, StatusEnum)
schema = namespace.schema_model.call_args.args[1]
# Verify the schema contains enum values
assert "enum" in schema or "anyOf" in schema
@@ -1,85 +0,0 @@
"""Shared fixtures for controllers.web unit tests."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from flask import Flask
@pytest.fixture
def app() -> Flask:
"""Minimal Flask app for request contexts."""
flask_app = Flask(__name__)
flask_app.config["TESTING"] = True
return flask_app
class FakeSession:
"""Stand-in for db.session that returns pre-seeded objects by model class name."""
def __init__(self, mapping: dict[str, Any] | None = None):
self._mapping: dict[str, Any] = mapping or {}
self._model_name: str | None = None
def query(self, model: type) -> FakeSession:
self._model_name = model.__name__
return self
def where(self, *_args: object, **_kwargs: object) -> FakeSession:
return self
def first(self) -> Any:
assert self._model_name is not None
return self._mapping.get(self._model_name)
class FakeDB:
"""Minimal db stub exposing engine and session."""
def __init__(self, session: FakeSession | None = None):
self.session = session or FakeSession()
self.engine = object()
def make_app_model(
*,
app_id: str = "app-1",
tenant_id: str = "tenant-1",
mode: str = "chat",
enable_site: bool = True,
status: str = "normal",
) -> SimpleNamespace:
"""Build a fake App model with common defaults."""
tenant = SimpleNamespace(
id=tenant_id,
status="normal",
plan="basic",
custom_config_dict={},
)
return SimpleNamespace(
id=app_id,
tenant_id=tenant_id,
tenant=tenant,
mode=mode,
enable_site=enable_site,
status=status,
workflow=None,
app_model_config=None,
)
def make_end_user(
*,
user_id: str = "end-user-1",
session_id: str = "session-1",
external_user_id: str = "ext-user-1",
) -> SimpleNamespace:
"""Build a fake EndUser model with common defaults."""
return SimpleNamespace(
id=user_id,
session_id=session_id,
external_user_id=external_user_id,
)
@@ -1,165 +0,0 @@
"""Unit tests for controllers.web.app endpoints."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
from controllers.web.error import AppUnavailableError
# ---------------------------------------------------------------------------
# AppParameterApi
# ---------------------------------------------------------------------------
class TestAppParameterApi:
def test_advanced_chat_mode_uses_workflow(self, app: Flask) -> None:
features_dict = {"opening_statement": "Hello"}
workflow = SimpleNamespace(
features_dict=features_dict,
user_input_form=lambda to_old_structure=False: [],
)
app_model = SimpleNamespace(mode="advanced-chat", workflow=workflow)
with (
app.test_request_context("/parameters"),
patch("controllers.web.app.get_parameters_from_feature_dict", return_value={}) as mock_params,
patch("controllers.web.app.fields.Parameters") as mock_fields,
):
mock_fields.model_validate.return_value.model_dump.return_value = {"result": "ok"}
result = AppParameterApi().get(app_model, SimpleNamespace())
mock_params.assert_called_once_with(features_dict=features_dict, user_input_form=[])
assert result == {"result": "ok"}
def test_workflow_mode_uses_workflow(self, app: Flask) -> None:
features_dict = {}
workflow = SimpleNamespace(
features_dict=features_dict,
user_input_form=lambda to_old_structure=False: [{"var": "x"}],
)
app_model = SimpleNamespace(mode="workflow", workflow=workflow)
with (
app.test_request_context("/parameters"),
patch("controllers.web.app.get_parameters_from_feature_dict", return_value={}) as mock_params,
patch("controllers.web.app.fields.Parameters") as mock_fields,
):
mock_fields.model_validate.return_value.model_dump.return_value = {}
AppParameterApi().get(app_model, SimpleNamespace())
mock_params.assert_called_once_with(features_dict=features_dict, user_input_form=[{"var": "x"}])
def test_advanced_chat_mode_no_workflow_raises(self, app: Flask) -> None:
app_model = SimpleNamespace(mode="advanced-chat", workflow=None)
with app.test_request_context("/parameters"):
with pytest.raises(AppUnavailableError):
AppParameterApi().get(app_model, SimpleNamespace())
def test_standard_mode_uses_app_model_config(self, app: Flask) -> None:
config = SimpleNamespace(to_dict=lambda: {"user_input_form": [{"var": "y"}], "key": "val"})
app_model = SimpleNamespace(mode="chat", app_model_config=config)
with (
app.test_request_context("/parameters"),
patch("controllers.web.app.get_parameters_from_feature_dict", return_value={}) as mock_params,
patch("controllers.web.app.fields.Parameters") as mock_fields,
):
mock_fields.model_validate.return_value.model_dump.return_value = {}
AppParameterApi().get(app_model, SimpleNamespace())
call_kwargs = mock_params.call_args
assert call_kwargs.kwargs["user_input_form"] == [{"var": "y"}]
def test_standard_mode_no_config_raises(self, app: Flask) -> None:
app_model = SimpleNamespace(mode="chat", app_model_config=None)
with app.test_request_context("/parameters"):
with pytest.raises(AppUnavailableError):
AppParameterApi().get(app_model, SimpleNamespace())
# ---------------------------------------------------------------------------
# AppMeta
# ---------------------------------------------------------------------------
class TestAppMeta:
@patch("controllers.web.app.AppService")
def test_get_returns_meta(self, mock_service_cls: MagicMock, app: Flask) -> None:
mock_service_cls.return_value.get_app_meta.return_value = {"tool_icons": {}}
app_model = SimpleNamespace(id="app-1")
with app.test_request_context("/meta"):
result = AppMeta().get(app_model, SimpleNamespace())
assert result == {"tool_icons": {}}
# ---------------------------------------------------------------------------
# AppAccessMode
# ---------------------------------------------------------------------------
class TestAppAccessMode:
@patch("controllers.web.app.FeatureService.get_system_features")
def test_returns_public_when_webapp_auth_disabled(self, mock_features: MagicMock, app: Flask) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
with app.test_request_context("/webapp/access-mode?appId=app-1"):
result = AppAccessMode().get()
assert result == {"accessMode": "public"}
@patch("controllers.web.app.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
@patch("controllers.web.app.FeatureService.get_system_features")
def test_returns_access_mode_with_app_id(
self, mock_features: MagicMock, mock_access: MagicMock, app: Flask
) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
mock_access.return_value = SimpleNamespace(access_mode="internal")
with app.test_request_context("/webapp/access-mode?appId=app-1"):
result = AppAccessMode().get()
assert result == {"accessMode": "internal"}
mock_access.assert_called_once_with("app-1")
@patch("controllers.web.app.AppService.get_app_id_by_code", return_value="resolved-id")
@patch("controllers.web.app.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
@patch("controllers.web.app.FeatureService.get_system_features")
def test_resolves_app_code_to_id(
self, mock_features: MagicMock, mock_access: MagicMock, mock_resolve: MagicMock, app: Flask
) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
mock_access.return_value = SimpleNamespace(access_mode="external")
with app.test_request_context("/webapp/access-mode?appCode=code1"):
result = AppAccessMode().get()
mock_resolve.assert_called_once_with("code1")
mock_access.assert_called_once_with("resolved-id")
assert result == {"accessMode": "external"}
@patch("controllers.web.app.FeatureService.get_system_features")
def test_raises_when_no_app_id_or_code(self, mock_features: MagicMock, app: Flask) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
with app.test_request_context("/webapp/access-mode"):
with pytest.raises(ValueError, match="appId or appCode"):
AppAccessMode().get()
# ---------------------------------------------------------------------------
# AppWebAuthPermission
# ---------------------------------------------------------------------------
class TestAppWebAuthPermission:
@patch("controllers.web.app.WebAppAuthService.is_app_require_permission_check", return_value=False)
def test_returns_true_when_no_permission_check_required(self, mock_check: MagicMock, app: Flask) -> None:
with app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}):
result = AppWebAuthPermission().get()
assert result == {"result": True}
def test_raises_when_missing_app_id(self, app: Flask) -> None:
with app.test_request_context("/webapp/permission", headers={"X-App-Code": "code1"}):
with pytest.raises(ValueError, match="appId"):
AppWebAuthPermission().get()
@@ -1,135 +0,0 @@
"""Unit tests for controllers.web.audio endpoints."""
from __future__ import annotations
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.web.audio import AudioApi, TextApi
from controllers.web.error import (
AudioTooLargeError,
CompletionRequestError,
NoAudioUploadedError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderNotSupportSpeechToTextError,
ProviderQuotaExceededError,
UnsupportedAudioTypeError,
)
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from dify_graph.model_runtime.errors.invoke import InvokeError
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
ProviderNotSupportSpeechToTextServiceError,
UnsupportedAudioTypeServiceError,
)
def _app_model() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="chat")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="eu-1", external_user_id="ext-1")
# ---------------------------------------------------------------------------
# AudioApi (audio-to-text)
# ---------------------------------------------------------------------------
class TestAudioApi:
@patch("controllers.web.audio.AudioService.transcript_asr", return_value={"text": "hello"})
def test_happy_path(self, mock_asr: MagicMock, app: Flask) -> None:
app.config["RESTX_MASK_HEADER"] = "X-Fields"
data = {"file": (BytesIO(b"fake-audio"), "test.mp3")}
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
result = AudioApi().post(_app_model(), _end_user())
assert result == {"text": "hello"}
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=NoAudioUploadedServiceError())
def test_no_audio_uploaded(self, mock_asr: MagicMock, app: Flask) -> None:
data = {"file": (BytesIO(b""), "empty.mp3")}
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(NoAudioUploadedError):
AudioApi().post(_app_model(), _end_user())
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=AudioTooLargeServiceError("too big"))
def test_audio_too_large(self, mock_asr: MagicMock, app: Flask) -> None:
data = {"file": (BytesIO(b"big"), "big.mp3")}
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(AudioTooLargeError):
AudioApi().post(_app_model(), _end_user())
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=UnsupportedAudioTypeServiceError())
def test_unsupported_type(self, mock_asr: MagicMock, app: Flask) -> None:
data = {"file": (BytesIO(b"bad"), "bad.xyz")}
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(UnsupportedAudioTypeError):
AudioApi().post(_app_model(), _end_user())
@patch(
"controllers.web.audio.AudioService.transcript_asr",
side_effect=ProviderNotSupportSpeechToTextServiceError(),
)
def test_provider_not_support(self, mock_asr: MagicMock, app: Flask) -> None:
data = {"file": (BytesIO(b"x"), "x.mp3")}
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(ProviderNotSupportSpeechToTextError):
AudioApi().post(_app_model(), _end_user())
@patch(
"controllers.web.audio.AudioService.transcript_asr",
side_effect=ProviderTokenNotInitError(description="no token"),
)
def test_provider_not_init(self, mock_asr: MagicMock, app: Flask) -> None:
data = {"file": (BytesIO(b"x"), "x.mp3")}
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(ProviderNotInitializeError):
AudioApi().post(_app_model(), _end_user())
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=QuotaExceededError())
def test_quota_exceeded(self, mock_asr: MagicMock, app: Flask) -> None:
data = {"file": (BytesIO(b"x"), "x.mp3")}
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(ProviderQuotaExceededError):
AudioApi().post(_app_model(), _end_user())
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=ModelCurrentlyNotSupportError())
def test_model_not_support(self, mock_asr: MagicMock, app: Flask) -> None:
data = {"file": (BytesIO(b"x"), "x.mp3")}
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
AudioApi().post(_app_model(), _end_user())
# ---------------------------------------------------------------------------
# TextApi (text-to-audio)
# ---------------------------------------------------------------------------
class TestTextApi:
@patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes")
@patch("controllers.web.audio.web_ns")
def test_happy_path(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None:
mock_ns.payload = {"text": "hello", "voice": "alloy"}
with app.test_request_context("/text-to-audio", method="POST"):
result = TextApi().post(_app_model(), _end_user())
assert result == "audio-bytes"
mock_tts.assert_called_once()
@patch(
"controllers.web.audio.AudioService.transcript_tts",
side_effect=InvokeError(description="invoke failed"),
)
@patch("controllers.web.audio.web_ns")
def test_invoke_error_mapped(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None:
mock_ns.payload = {"text": "hello"}
with app.test_request_context("/text-to-audio", method="POST"):
with pytest.raises(CompletionRequestError):
TextApi().post(_app_model(), _end_user())
@@ -1,161 +0,0 @@
"""Unit tests for controllers.web.completion endpoints."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.web.completion import ChatApi, ChatStopApi, CompletionApi, CompletionStopApi
from controllers.web.error import (
CompletionRequestError,
NotChatAppError,
NotCompletionAppError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from dify_graph.model_runtime.errors.invoke import InvokeError
def _completion_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="completion")
def _chat_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="chat")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="eu-1")
# ---------------------------------------------------------------------------
# CompletionApi
# ---------------------------------------------------------------------------
class TestCompletionApi:
def test_wrong_mode_raises(self, app: Flask) -> None:
with app.test_request_context("/completion-messages", method="POST"):
with pytest.raises(NotCompletionAppError):
CompletionApi().post(_chat_app(), _end_user())
@patch("controllers.web.completion.helper.compact_generate_response", return_value={"answer": "hi"})
@patch("controllers.web.completion.AppGenerateService.generate")
@patch("controllers.web.completion.web_ns")
def test_happy_path(self, mock_ns: MagicMock, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}, "query": "test"}
mock_gen.return_value = "response-obj"
with app.test_request_context("/completion-messages", method="POST"):
result = CompletionApi().post(_completion_app(), _end_user())
assert result == {"answer": "hi"}
@patch(
"controllers.web.completion.AppGenerateService.generate",
side_effect=ProviderTokenNotInitError(description="not init"),
)
@patch("controllers.web.completion.web_ns")
def test_provider_not_init_error(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
with app.test_request_context("/completion-messages", method="POST"):
with pytest.raises(ProviderNotInitializeError):
CompletionApi().post(_completion_app(), _end_user())
@patch(
"controllers.web.completion.AppGenerateService.generate",
side_effect=QuotaExceededError(),
)
@patch("controllers.web.completion.web_ns")
def test_quota_exceeded_error(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
with app.test_request_context("/completion-messages", method="POST"):
with pytest.raises(ProviderQuotaExceededError):
CompletionApi().post(_completion_app(), _end_user())
@patch(
"controllers.web.completion.AppGenerateService.generate",
side_effect=ModelCurrentlyNotSupportError(),
)
@patch("controllers.web.completion.web_ns")
def test_model_not_support_error(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
with app.test_request_context("/completion-messages", method="POST"):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
CompletionApi().post(_completion_app(), _end_user())
# ---------------------------------------------------------------------------
# CompletionStopApi
# ---------------------------------------------------------------------------
class TestCompletionStopApi:
def test_wrong_mode_raises(self, app: Flask) -> None:
with app.test_request_context("/completion-messages/task-1/stop", method="POST"):
with pytest.raises(NotCompletionAppError):
CompletionStopApi().post(_chat_app(), _end_user(), "task-1")
@patch("controllers.web.completion.AppTaskService.stop_task")
def test_stop_success(self, mock_stop: MagicMock, app: Flask) -> None:
with app.test_request_context("/completion-messages/task-1/stop", method="POST"):
result, status = CompletionStopApi().post(_completion_app(), _end_user(), "task-1")
assert status == 200
assert result == {"result": "success"}
# ---------------------------------------------------------------------------
# ChatApi
# ---------------------------------------------------------------------------
class TestChatApi:
def test_wrong_mode_raises(self, app: Flask) -> None:
with app.test_request_context("/chat-messages", method="POST"):
with pytest.raises(NotChatAppError):
ChatApi().post(_completion_app(), _end_user())
@patch("controllers.web.completion.helper.compact_generate_response", return_value={"answer": "reply"})
@patch("controllers.web.completion.AppGenerateService.generate")
@patch("controllers.web.completion.web_ns")
def test_happy_path(self, mock_ns: MagicMock, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}, "query": "hi"}
mock_gen.return_value = "response"
with app.test_request_context("/chat-messages", method="POST"):
result = ChatApi().post(_chat_app(), _end_user())
assert result == {"answer": "reply"}
@patch(
"controllers.web.completion.AppGenerateService.generate",
side_effect=InvokeError(description="rate limit"),
)
@patch("controllers.web.completion.web_ns")
def test_invoke_error_mapped(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}, "query": "x"}
with app.test_request_context("/chat-messages", method="POST"):
with pytest.raises(CompletionRequestError):
ChatApi().post(_chat_app(), _end_user())
# ---------------------------------------------------------------------------
# ChatStopApi
# ---------------------------------------------------------------------------
class TestChatStopApi:
def test_wrong_mode_raises(self, app: Flask) -> None:
with app.test_request_context("/chat-messages/task-1/stop", method="POST"):
with pytest.raises(NotChatAppError):
ChatStopApi().post(_completion_app(), _end_user(), "task-1")
@patch("controllers.web.completion.AppTaskService.stop_task")
def test_stop_success(self, mock_stop: MagicMock, app: Flask) -> None:
with app.test_request_context("/chat-messages/task-1/stop", method="POST"):
result, status = ChatStopApi().post(_chat_app(), _end_user(), "task-1")
assert status == 200
assert result == {"result": "success"}
@@ -1,183 +0,0 @@
"""Unit tests for controllers.web.conversation endpoints."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from werkzeug.exceptions import NotFound
from controllers.web.conversation import (
ConversationApi,
ConversationListApi,
ConversationPinApi,
ConversationRenameApi,
ConversationUnPinApi,
)
from controllers.web.error import NotChatAppError
from services.errors.conversation import ConversationNotExistsError
def _chat_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="chat")
def _completion_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="completion")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="eu-1")
# ---------------------------------------------------------------------------
# ConversationListApi
# ---------------------------------------------------------------------------
class TestConversationListApi:
def test_non_chat_mode_raises(self, app: Flask) -> None:
with app.test_request_context("/conversations"):
with pytest.raises(NotChatAppError):
ConversationListApi().get(_completion_app(), _end_user())
@patch("controllers.web.conversation.WebConversationService.pagination_by_last_id")
@patch("controllers.web.conversation.db")
def test_happy_path(self, mock_db: MagicMock, mock_paginate: MagicMock, app: Flask) -> None:
conv_id = str(uuid4())
conv = SimpleNamespace(
id=conv_id,
name="Test",
inputs={},
status="normal",
introduction="",
created_at=1700000000,
updated_at=1700000000,
)
mock_paginate.return_value = SimpleNamespace(limit=20, has_more=False, data=[conv])
mock_db.engine = "engine"
session_mock = MagicMock()
session_ctx = MagicMock()
session_ctx.__enter__ = MagicMock(return_value=session_mock)
session_ctx.__exit__ = MagicMock(return_value=False)
with (
app.test_request_context("/conversations?limit=20"),
patch("controllers.web.conversation.Session", return_value=session_ctx),
):
result = ConversationListApi().get(_chat_app(), _end_user())
assert result["limit"] == 20
assert result["has_more"] is False
# ---------------------------------------------------------------------------
# ConversationApi (delete)
# ---------------------------------------------------------------------------
class TestConversationApi:
def test_non_chat_mode_raises(self, app: Flask) -> None:
with app.test_request_context(f"/conversations/{uuid4()}"):
with pytest.raises(NotChatAppError):
ConversationApi().delete(_completion_app(), _end_user(), uuid4())
@patch("controllers.web.conversation.ConversationService.delete")
def test_delete_success(self, mock_delete: MagicMock, app: Flask) -> None:
c_id = uuid4()
with app.test_request_context(f"/conversations/{c_id}"):
result, status = ConversationApi().delete(_chat_app(), _end_user(), c_id)
assert status == 204
assert result["result"] == "success"
@patch("controllers.web.conversation.ConversationService.delete", side_effect=ConversationNotExistsError())
def test_delete_not_found(self, mock_delete: MagicMock, app: Flask) -> None:
c_id = uuid4()
with app.test_request_context(f"/conversations/{c_id}"):
with pytest.raises(NotFound, match="Conversation Not Exists"):
ConversationApi().delete(_chat_app(), _end_user(), c_id)
# ---------------------------------------------------------------------------
# ConversationRenameApi
# ---------------------------------------------------------------------------
class TestConversationRenameApi:
def test_non_chat_mode_raises(self, app: Flask) -> None:
with app.test_request_context(f"/conversations/{uuid4()}/name", method="POST", json={"name": "x"}):
with pytest.raises(NotChatAppError):
ConversationRenameApi().post(_completion_app(), _end_user(), uuid4())
@patch("controllers.web.conversation.ConversationService.rename")
@patch("controllers.web.conversation.web_ns")
def test_rename_success(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None:
c_id = uuid4()
mock_ns.payload = {"name": "New Name", "auto_generate": False}
conv = SimpleNamespace(
id=str(c_id),
name="New Name",
inputs={},
status="normal",
introduction="",
created_at=1700000000,
updated_at=1700000000,
)
mock_rename.return_value = conv
with app.test_request_context(f"/conversations/{c_id}/name", method="POST", json={"name": "New Name"}):
result = ConversationRenameApi().post(_chat_app(), _end_user(), c_id)
assert result["name"] == "New Name"
@patch(
"controllers.web.conversation.ConversationService.rename",
side_effect=ConversationNotExistsError(),
)
@patch("controllers.web.conversation.web_ns")
def test_rename_not_found(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None:
c_id = uuid4()
mock_ns.payload = {"name": "X", "auto_generate": False}
with app.test_request_context(f"/conversations/{c_id}/name", method="POST", json={"name": "X"}):
with pytest.raises(NotFound, match="Conversation Not Exists"):
ConversationRenameApi().post(_chat_app(), _end_user(), c_id)
# ---------------------------------------------------------------------------
# ConversationPinApi / ConversationUnPinApi
# ---------------------------------------------------------------------------
class TestConversationPinApi:
def test_non_chat_mode_raises(self, app: Flask) -> None:
with app.test_request_context(f"/conversations/{uuid4()}/pin", method="PATCH"):
with pytest.raises(NotChatAppError):
ConversationPinApi().patch(_completion_app(), _end_user(), uuid4())
@patch("controllers.web.conversation.WebConversationService.pin")
def test_pin_success(self, mock_pin: MagicMock, app: Flask) -> None:
c_id = uuid4()
with app.test_request_context(f"/conversations/{c_id}/pin", method="PATCH"):
result = ConversationPinApi().patch(_chat_app(), _end_user(), c_id)
assert result["result"] == "success"
@patch("controllers.web.conversation.WebConversationService.pin", side_effect=ConversationNotExistsError())
def test_pin_not_found(self, mock_pin: MagicMock, app: Flask) -> None:
c_id = uuid4()
with app.test_request_context(f"/conversations/{c_id}/pin", method="PATCH"):
with pytest.raises(NotFound):
ConversationPinApi().patch(_chat_app(), _end_user(), c_id)
class TestConversationUnPinApi:
def test_non_chat_mode_raises(self, app: Flask) -> None:
with app.test_request_context(f"/conversations/{uuid4()}/unpin", method="PATCH"):
with pytest.raises(NotChatAppError):
ConversationUnPinApi().patch(_completion_app(), _end_user(), uuid4())
@patch("controllers.web.conversation.WebConversationService.unpin")
def test_unpin_success(self, mock_unpin: MagicMock, app: Flask) -> None:
c_id = uuid4()
with app.test_request_context(f"/conversations/{c_id}/unpin", method="PATCH"):
result = ConversationUnPinApi().patch(_chat_app(), _end_user(), c_id)
assert result["result"] == "success"
@@ -1,75 +0,0 @@
"""Unit tests for controllers.web.error HTTP exception classes."""
from __future__ import annotations
import pytest
from controllers.web.error import (
AppMoreLikeThisDisabledError,
AppSuggestedQuestionsAfterAnswerDisabledError,
AppUnavailableError,
AudioTooLargeError,
CompletionRequestError,
ConversationCompletedError,
InvalidArgumentError,
InvokeRateLimitError,
NoAudioUploadedError,
NotChatAppError,
NotCompletionAppError,
NotFoundError,
NotWorkflowAppError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderNotSupportSpeechToTextError,
ProviderQuotaExceededError,
UnsupportedAudioTypeError,
WebAppAuthAccessDeniedError,
WebAppAuthRequiredError,
WebFormRateLimitExceededError,
)
_ERROR_SPECS: list[tuple[type, str, int]] = [
(AppUnavailableError, "app_unavailable", 400),
(NotCompletionAppError, "not_completion_app", 400),
(NotChatAppError, "not_chat_app", 400),
(NotWorkflowAppError, "not_workflow_app", 400),
(ConversationCompletedError, "conversation_completed", 400),
(ProviderNotInitializeError, "provider_not_initialize", 400),
(ProviderQuotaExceededError, "provider_quota_exceeded", 400),
(ProviderModelCurrentlyNotSupportError, "model_currently_not_support", 400),
(CompletionRequestError, "completion_request_error", 400),
(AppMoreLikeThisDisabledError, "app_more_like_this_disabled", 403),
(AppSuggestedQuestionsAfterAnswerDisabledError, "app_suggested_questions_after_answer_disabled", 403),
(NoAudioUploadedError, "no_audio_uploaded", 400),
(AudioTooLargeError, "audio_too_large", 413),
(UnsupportedAudioTypeError, "unsupported_audio_type", 415),
(ProviderNotSupportSpeechToTextError, "provider_not_support_speech_to_text", 400),
(WebAppAuthRequiredError, "web_sso_auth_required", 401),
(WebAppAuthAccessDeniedError, "web_app_access_denied", 401),
(InvokeRateLimitError, "rate_limit_error", 429),
(WebFormRateLimitExceededError, "web_form_rate_limit_exceeded", 429),
(NotFoundError, "not_found", 404),
(InvalidArgumentError, "invalid_param", 400),
]
@pytest.mark.parametrize(
("cls", "expected_code", "expected_status"),
_ERROR_SPECS,
ids=[cls.__name__ for cls, _, _ in _ERROR_SPECS],
)
def test_error_class_attributes(cls: type, expected_code: str, expected_status: int) -> None:
"""Each error class exposes the correct error_code and HTTP status code."""
assert cls.error_code == expected_code
assert cls.code == expected_status
def test_error_classes_have_description() -> None:
"""Every error class has a description (string or None for generic errors)."""
# NotFoundError and InvalidArgumentError use None description by design
_NO_DESCRIPTION = {NotFoundError, InvalidArgumentError}
for cls, _, _ in _ERROR_SPECS:
if cls in _NO_DESCRIPTION:
continue
assert isinstance(cls.description, str), f"{cls.__name__} missing description"
assert len(cls.description) > 0, f"{cls.__name__} has empty description"
@@ -1,38 +0,0 @@
"""Unit tests for controllers.web.feature endpoints."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from flask import Flask
from controllers.web.feature import SystemFeatureApi
class TestSystemFeatureApi:
@patch("controllers.web.feature.FeatureService.get_system_features")
def test_returns_system_features(self, mock_features: MagicMock, app: Flask) -> None:
mock_model = MagicMock()
mock_model.model_dump.return_value = {"sso_enforced_for_signin": False, "webapp_auth": {"enabled": False}}
mock_features.return_value = mock_model
with app.test_request_context("/system-features"):
result = SystemFeatureApi().get()
assert result == {"sso_enforced_for_signin": False, "webapp_auth": {"enabled": False}}
mock_features.assert_called_once()
@patch("controllers.web.feature.FeatureService.get_system_features")
def test_unauthenticated_access(self, mock_features: MagicMock, app: Flask) -> None:
"""SystemFeatureApi is unauthenticated by design — no WebApiResource decorator."""
mock_model = MagicMock()
mock_model.model_dump.return_value = {}
mock_features.return_value = mock_model
# Verify it's a bare Resource, not WebApiResource
from flask_restx import Resource
from controllers.web.wraps import WebApiResource
assert issubclass(SystemFeatureApi, Resource)
assert not issubclass(SystemFeatureApi, WebApiResource)
@@ -1,89 +0,0 @@
"""Unit tests for controllers.web.files endpoints."""
from __future__ import annotations
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.common.errors import (
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
TooManyFilesError,
)
from controllers.web.files import FileApi
def _app_model() -> SimpleNamespace:
return SimpleNamespace(id="app-1")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="eu-1")
class TestFileApi:
def test_no_file_uploaded(self, app: Flask) -> None:
with app.test_request_context("/files/upload", method="POST", content_type="multipart/form-data"):
with pytest.raises(NoFileUploadedError):
FileApi().post(_app_model(), _end_user())
def test_too_many_files(self, app: Flask) -> None:
data = {
"file": (BytesIO(b"a"), "a.txt"),
"file2": (BytesIO(b"b"), "b.txt"),
}
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
# Now has "file" key but len(request.files) > 1
with pytest.raises(TooManyFilesError):
FileApi().post(_app_model(), _end_user())
def test_filename_missing(self, app: Flask) -> None:
data = {"file": (BytesIO(b"content"), "")}
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(FilenameNotExistsError):
FileApi().post(_app_model(), _end_user())
@patch("controllers.web.files.FileService")
@patch("controllers.web.files.db")
def test_upload_success(self, mock_db: MagicMock, mock_file_svc_cls: MagicMock, app: Flask) -> None:
mock_db.engine = "engine"
from datetime import datetime
upload_file = SimpleNamespace(
id="file-1",
name="test.txt",
size=100,
extension="txt",
mime_type="text/plain",
created_by="eu-1",
created_at=datetime(2024, 1, 1),
)
mock_file_svc_cls.return_value.upload_file.return_value = upload_file
data = {"file": (BytesIO(b"content"), "test.txt")}
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
result, status = FileApi().post(_app_model(), _end_user())
assert status == 201
assert result["id"] == "file-1"
assert result["name"] == "test.txt"
@patch("controllers.web.files.FileService")
@patch("controllers.web.files.db")
def test_file_too_large_from_service(self, mock_db: MagicMock, mock_file_svc_cls: MagicMock, app: Flask) -> None:
import services.errors.file
mock_db.engine = "engine"
mock_file_svc_cls.return_value.upload_file.side_effect = services.errors.file.FileTooLargeError(
description="max 10MB"
)
data = {"file": (BytesIO(b"big"), "big.txt")}
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(FileTooLargeError):
FileApi().post(_app_model(), _end_user())
@@ -1,156 +0,0 @@
"""Unit tests for controllers.web.message — feedback, more-like-this, suggested questions."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from werkzeug.exceptions import NotFound
from controllers.web.error import (
AppMoreLikeThisDisabledError,
NotChatAppError,
NotCompletionAppError,
)
from controllers.web.message import (
MessageFeedbackApi,
MessageMoreLikeThisApi,
MessageSuggestedQuestionApi,
)
from services.errors.app import MoreLikeThisDisabledError
from services.errors.message import MessageNotExistsError
def _chat_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="chat")
def _completion_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="completion")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="eu-1")
# ---------------------------------------------------------------------------
# MessageFeedbackApi
# ---------------------------------------------------------------------------
class TestMessageFeedbackApi:
@patch("controllers.web.message.MessageService.create_feedback")
@patch("controllers.web.message.web_ns")
def test_feedback_success(self, mock_ns: MagicMock, mock_create: MagicMock, app: Flask) -> None:
mock_ns.payload = {"rating": "like", "content": "great"}
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/feedbacks", method="POST"):
result = MessageFeedbackApi().post(_chat_app(), _end_user(), msg_id)
assert result == {"result": "success"}
mock_create.assert_called_once()
@patch("controllers.web.message.MessageService.create_feedback")
@patch("controllers.web.message.web_ns")
def test_feedback_null_rating(self, mock_ns: MagicMock, mock_create: MagicMock, app: Flask) -> None:
mock_ns.payload = {"rating": None}
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/feedbacks", method="POST"):
result = MessageFeedbackApi().post(_chat_app(), _end_user(), msg_id)
assert result == {"result": "success"}
@patch(
"controllers.web.message.MessageService.create_feedback",
side_effect=MessageNotExistsError(),
)
@patch("controllers.web.message.web_ns")
def test_feedback_message_not_found(self, mock_ns: MagicMock, mock_create: MagicMock, app: Flask) -> None:
mock_ns.payload = {"rating": "dislike"}
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/feedbacks", method="POST"):
with pytest.raises(NotFound, match="Message Not Exists"):
MessageFeedbackApi().post(_chat_app(), _end_user(), msg_id)
# ---------------------------------------------------------------------------
# MessageMoreLikeThisApi
# ---------------------------------------------------------------------------
class TestMessageMoreLikeThisApi:
def test_wrong_mode_raises(self, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
with pytest.raises(NotCompletionAppError):
MessageMoreLikeThisApi().get(_chat_app(), _end_user(), msg_id)
@patch("controllers.web.message.helper.compact_generate_response", return_value={"answer": "similar"})
@patch("controllers.web.message.AppGenerateService.generate_more_like_this")
def test_happy_path(self, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
msg_id = uuid4()
mock_gen.return_value = "response"
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
result = MessageMoreLikeThisApi().get(_completion_app(), _end_user(), msg_id)
assert result == {"answer": "similar"}
@patch(
"controllers.web.message.AppGenerateService.generate_more_like_this",
side_effect=MessageNotExistsError(),
)
def test_message_not_found(self, mock_gen: MagicMock, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
with pytest.raises(NotFound, match="Message Not Exists"):
MessageMoreLikeThisApi().get(_completion_app(), _end_user(), msg_id)
@patch(
"controllers.web.message.AppGenerateService.generate_more_like_this",
side_effect=MoreLikeThisDisabledError(),
)
def test_feature_disabled(self, mock_gen: MagicMock, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
with pytest.raises(AppMoreLikeThisDisabledError):
MessageMoreLikeThisApi().get(_completion_app(), _end_user(), msg_id)
# ---------------------------------------------------------------------------
# MessageSuggestedQuestionApi
# ---------------------------------------------------------------------------
class TestMessageSuggestedQuestionApi:
def test_wrong_mode_raises(self, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
with pytest.raises(NotChatAppError):
MessageSuggestedQuestionApi().get(_completion_app(), _end_user(), msg_id)
def test_wrong_mode_raises(self, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
with pytest.raises(NotChatAppError):
MessageSuggestedQuestionApi().get(_completion_app(), _end_user(), msg_id)
@patch("controllers.web.message.MessageService.get_suggested_questions_after_answer")
def test_happy_path(self, mock_suggest: MagicMock, app: Flask) -> None:
msg_id = uuid4()
mock_suggest.return_value = ["What about X?", "Tell me more about Y."]
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
result = MessageSuggestedQuestionApi().get(_chat_app(), _end_user(), msg_id)
assert result["data"] == ["What about X?", "Tell me more about Y."]
@patch(
"controllers.web.message.MessageService.get_suggested_questions_after_answer",
side_effect=MessageNotExistsError(),
)
def test_message_not_found(self, mock_suggest: MagicMock, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
with pytest.raises(NotFound, match="Message not found"):
MessageSuggestedQuestionApi().get(_chat_app(), _end_user(), msg_id)
@@ -1,103 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from werkzeug.exceptions import NotFound, Unauthorized
from controllers.web.error import WebAppAuthRequiredError
from controllers.web.passport import (
PassportService,
decode_enterprise_webapp_user_id,
exchange_token_for_existing_web_user,
generate_session_id,
)
from services.webapp_auth_service import WebAppAuthType
def test_decode_enterprise_webapp_user_id_none() -> None:
assert decode_enterprise_webapp_user_id(None) is None
def test_decode_enterprise_webapp_user_id_invalid_source(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(PassportService, "verify", lambda *_args, **_kwargs: {"token_source": "bad"})
with pytest.raises(Unauthorized):
decode_enterprise_webapp_user_id("token")
def test_decode_enterprise_webapp_user_id_valid(monkeypatch: pytest.MonkeyPatch) -> None:
decoded = {"token_source": "webapp_login_token", "user_id": "u1"}
monkeypatch.setattr(PassportService, "verify", lambda *_args, **_kwargs: decoded)
assert decode_enterprise_webapp_user_id("token") == decoded
def test_exchange_token_public_flow(monkeypatch: pytest.MonkeyPatch) -> None:
site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal")
app_model = SimpleNamespace(id="a1", status="normal", enable_site=True)
def _scalar_side_effect(*_args, **_kwargs):
if not hasattr(_scalar_side_effect, "calls"):
_scalar_side_effect.calls = 0
_scalar_side_effect.calls += 1
return site if _scalar_side_effect.calls == 1 else app_model
db_session = SimpleNamespace(scalar=_scalar_side_effect)
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
monkeypatch.setattr("controllers.web.passport._exchange_for_public_app_token", lambda *_args, **_kwargs: "resp")
decoded = {"auth_type": "public"}
result = exchange_token_for_existing_web_user("code", decoded, WebAppAuthType.PUBLIC)
assert result == "resp"
def test_exchange_token_requires_external(monkeypatch: pytest.MonkeyPatch) -> None:
site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal")
app_model = SimpleNamespace(id="a1", status="normal", enable_site=True)
def _scalar_side_effect(*_args, **_kwargs):
if not hasattr(_scalar_side_effect, "calls"):
_scalar_side_effect.calls = 0
_scalar_side_effect.calls += 1
return site if _scalar_side_effect.calls == 1 else app_model
db_session = SimpleNamespace(scalar=_scalar_side_effect)
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
decoded = {"auth_type": "internal"}
with pytest.raises(WebAppAuthRequiredError):
exchange_token_for_existing_web_user("code", decoded, WebAppAuthType.EXTERNAL)
def test_exchange_token_missing_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal")
app_model = SimpleNamespace(id="a1", status="normal", enable_site=True, tenant_id="t1")
def _scalar_side_effect(*_args, **_kwargs):
if not hasattr(_scalar_side_effect, "calls"):
_scalar_side_effect.calls = 0
_scalar_side_effect.calls += 1
if _scalar_side_effect.calls == 1:
return site
if _scalar_side_effect.calls == 2:
return app_model
return None
db_session = SimpleNamespace(scalar=_scalar_side_effect, add=lambda *_a, **_k: None, commit=lambda: None)
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
decoded = {"auth_type": "internal"}
with pytest.raises(NotFound):
exchange_token_for_existing_web_user("code", decoded, WebAppAuthType.INTERNAL)
def test_generate_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
counts = [1, 0]
def _scalar(*_args, **_kwargs):
return counts.pop(0)
db_session = SimpleNamespace(scalar=_scalar)
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
session_id = generate_session_id()
assert session_id
@@ -1,423 +0,0 @@
"""Unit tests for Pydantic models defined in controllers.web modules.
Covers validation logic, field defaults, constraints, and custom validators
for all ~15 Pydantic models across the web controller layer.
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from pydantic import ValidationError
# ---------------------------------------------------------------------------
# app.py models
# ---------------------------------------------------------------------------
from controllers.web.app import AppAccessModeQuery
class TestAppAccessModeQuery:
def test_alias_resolution(self) -> None:
q = AppAccessModeQuery.model_validate({"appId": "abc", "appCode": "xyz"})
assert q.app_id == "abc"
assert q.app_code == "xyz"
def test_defaults_to_none(self) -> None:
q = AppAccessModeQuery.model_validate({})
assert q.app_id is None
assert q.app_code is None
def test_accepts_snake_case(self) -> None:
q = AppAccessModeQuery(app_id="id1", app_code="code1")
assert q.app_id == "id1"
assert q.app_code == "code1"
# ---------------------------------------------------------------------------
# audio.py models
# ---------------------------------------------------------------------------
from controllers.web.audio import TextToAudioPayload
class TestTextToAudioPayload:
def test_defaults(self) -> None:
p = TextToAudioPayload.model_validate({})
assert p.message_id is None
assert p.voice is None
assert p.text is None
assert p.streaming is None
def test_valid_uuid_message_id(self) -> None:
uid = str(uuid4())
p = TextToAudioPayload(message_id=uid)
assert p.message_id == uid
def test_none_message_id_passthrough(self) -> None:
p = TextToAudioPayload(message_id=None)
assert p.message_id is None
def test_invalid_uuid_message_id(self) -> None:
with pytest.raises(ValidationError, match="not a valid uuid"):
TextToAudioPayload(message_id="not-a-uuid")
# ---------------------------------------------------------------------------
# completion.py models
# ---------------------------------------------------------------------------
from controllers.web.completion import ChatMessagePayload, CompletionMessagePayload
class TestCompletionMessagePayload:
def test_defaults(self) -> None:
p = CompletionMessagePayload(inputs={})
assert p.query == ""
assert p.files is None
assert p.response_mode is None
assert p.retriever_from == "web_app"
def test_accepts_full_payload(self) -> None:
p = CompletionMessagePayload(
inputs={"key": "val"},
query="test",
files=[{"id": "f1"}],
response_mode="streaming",
)
assert p.response_mode == "streaming"
assert p.files == [{"id": "f1"}]
def test_invalid_response_mode(self) -> None:
with pytest.raises(ValidationError):
CompletionMessagePayload(inputs={}, response_mode="invalid")
class TestChatMessagePayload:
def test_valid_uuid_fields(self) -> None:
cid = str(uuid4())
pid = str(uuid4())
p = ChatMessagePayload(inputs={}, query="hi", conversation_id=cid, parent_message_id=pid)
assert p.conversation_id == cid
assert p.parent_message_id == pid
def test_none_uuid_fields(self) -> None:
p = ChatMessagePayload(inputs={}, query="hi")
assert p.conversation_id is None
assert p.parent_message_id is None
def test_invalid_conversation_id(self) -> None:
with pytest.raises(ValidationError, match="not a valid uuid"):
ChatMessagePayload(inputs={}, query="hi", conversation_id="bad")
def test_invalid_parent_message_id(self) -> None:
with pytest.raises(ValidationError, match="not a valid uuid"):
ChatMessagePayload(inputs={}, query="hi", parent_message_id="bad")
def test_query_required(self) -> None:
with pytest.raises(ValidationError):
ChatMessagePayload(inputs={})
# ---------------------------------------------------------------------------
# conversation.py models
# ---------------------------------------------------------------------------
from controllers.web.conversation import ConversationListQuery, ConversationRenamePayload
class TestConversationListQuery:
def test_defaults(self) -> None:
q = ConversationListQuery()
assert q.last_id is None
assert q.limit == 20
assert q.pinned is None
assert q.sort_by == "-updated_at"
def test_limit_lower_bound(self) -> None:
with pytest.raises(ValidationError):
ConversationListQuery(limit=0)
def test_limit_upper_bound(self) -> None:
with pytest.raises(ValidationError):
ConversationListQuery(limit=101)
def test_limit_boundaries_valid(self) -> None:
assert ConversationListQuery(limit=1).limit == 1
assert ConversationListQuery(limit=100).limit == 100
def test_valid_sort_by_options(self) -> None:
for opt in ("created_at", "-created_at", "updated_at", "-updated_at"):
assert ConversationListQuery(sort_by=opt).sort_by == opt
def test_invalid_sort_by(self) -> None:
with pytest.raises(ValidationError):
ConversationListQuery(sort_by="invalid")
def test_valid_last_id(self) -> None:
uid = str(uuid4())
assert ConversationListQuery(last_id=uid).last_id == uid
def test_invalid_last_id(self) -> None:
with pytest.raises(ValidationError, match="not a valid uuid"):
ConversationListQuery(last_id="not-uuid")
class TestConversationRenamePayload:
def test_auto_generate_true_no_name_required(self) -> None:
p = ConversationRenamePayload(auto_generate=True)
assert p.name is None
def test_auto_generate_false_requires_name(self) -> None:
with pytest.raises(ValidationError, match="name is required"):
ConversationRenamePayload(auto_generate=False)
def test_auto_generate_false_blank_name_rejected(self) -> None:
with pytest.raises(ValidationError, match="name is required"):
ConversationRenamePayload(auto_generate=False, name=" ")
def test_auto_generate_false_with_valid_name(self) -> None:
p = ConversationRenamePayload(auto_generate=False, name="My Chat")
assert p.name == "My Chat"
def test_defaults(self) -> None:
p = ConversationRenamePayload(name="test")
assert p.auto_generate is False
assert p.name == "test"
# ---------------------------------------------------------------------------
# message.py models
# ---------------------------------------------------------------------------
from controllers.web.message import MessageFeedbackPayload, MessageListQuery, MessageMoreLikeThisQuery
class TestMessageListQuery:
def test_valid_query(self) -> None:
cid = str(uuid4())
q = MessageListQuery(conversation_id=cid)
assert q.conversation_id == cid
assert q.first_id is None
assert q.limit == 20
def test_invalid_conversation_id(self) -> None:
with pytest.raises(ValidationError, match="not a valid uuid"):
MessageListQuery(conversation_id="bad")
def test_limit_bounds(self) -> None:
cid = str(uuid4())
with pytest.raises(ValidationError):
MessageListQuery(conversation_id=cid, limit=0)
with pytest.raises(ValidationError):
MessageListQuery(conversation_id=cid, limit=101)
def test_valid_first_id(self) -> None:
cid = str(uuid4())
fid = str(uuid4())
q = MessageListQuery(conversation_id=cid, first_id=fid)
assert q.first_id == fid
def test_invalid_first_id(self) -> None:
cid = str(uuid4())
with pytest.raises(ValidationError, match="not a valid uuid"):
MessageListQuery(conversation_id=cid, first_id="invalid")
class TestMessageFeedbackPayload:
def test_defaults(self) -> None:
p = MessageFeedbackPayload()
assert p.rating is None
assert p.content is None
def test_valid_ratings(self) -> None:
assert MessageFeedbackPayload(rating="like").rating == "like"
assert MessageFeedbackPayload(rating="dislike").rating == "dislike"
def test_invalid_rating(self) -> None:
with pytest.raises(ValidationError):
MessageFeedbackPayload(rating="neutral")
class TestMessageMoreLikeThisQuery:
def test_valid_modes(self) -> None:
assert MessageMoreLikeThisQuery(response_mode="blocking").response_mode == "blocking"
assert MessageMoreLikeThisQuery(response_mode="streaming").response_mode == "streaming"
def test_invalid_mode(self) -> None:
with pytest.raises(ValidationError):
MessageMoreLikeThisQuery(response_mode="invalid")
def test_required(self) -> None:
with pytest.raises(ValidationError):
MessageMoreLikeThisQuery()
# ---------------------------------------------------------------------------
# remote_files.py models
# ---------------------------------------------------------------------------
from controllers.web.remote_files import RemoteFileUploadPayload
class TestRemoteFileUploadPayload:
def test_valid_url(self) -> None:
p = RemoteFileUploadPayload(url="https://example.com/file.pdf")
assert str(p.url) == "https://example.com/file.pdf"
def test_invalid_url(self) -> None:
with pytest.raises(ValidationError):
RemoteFileUploadPayload(url="not-a-url")
def test_url_required(self) -> None:
with pytest.raises(ValidationError):
RemoteFileUploadPayload()
# ---------------------------------------------------------------------------
# saved_message.py models
# ---------------------------------------------------------------------------
from controllers.web.saved_message import SavedMessageCreatePayload, SavedMessageListQuery
class TestSavedMessageListQuery:
def test_defaults(self) -> None:
q = SavedMessageListQuery()
assert q.last_id is None
assert q.limit == 20
def test_limit_bounds(self) -> None:
with pytest.raises(ValidationError):
SavedMessageListQuery(limit=0)
with pytest.raises(ValidationError):
SavedMessageListQuery(limit=101)
def test_valid_last_id(self) -> None:
uid = str(uuid4())
q = SavedMessageListQuery(last_id=uid)
assert q.last_id == uid
def test_empty_last_id(self) -> None:
q = SavedMessageListQuery(last_id="")
assert q.last_id == ""
class TestSavedMessageCreatePayload:
def test_valid_message_id(self) -> None:
uid = str(uuid4())
p = SavedMessageCreatePayload(message_id=uid)
assert p.message_id == uid
def test_required(self) -> None:
with pytest.raises(ValidationError):
SavedMessageCreatePayload()
# ---------------------------------------------------------------------------
# workflow.py models
# ---------------------------------------------------------------------------
from controllers.web.workflow import WorkflowRunPayload
class TestWorkflowRunPayload:
def test_defaults(self) -> None:
p = WorkflowRunPayload(inputs={})
assert p.inputs == {}
assert p.files is None
def test_with_files(self) -> None:
p = WorkflowRunPayload(inputs={"k": "v"}, files=[{"id": "f1"}])
assert p.files == [{"id": "f1"}]
def test_inputs_required(self) -> None:
with pytest.raises(ValidationError):
WorkflowRunPayload()
# ---------------------------------------------------------------------------
# forgot_password.py models
# ---------------------------------------------------------------------------
from controllers.web.forgot_password import (
ForgotPasswordCheckPayload,
ForgotPasswordResetPayload,
ForgotPasswordSendPayload,
)
class TestForgotPasswordSendPayload:
def test_valid_email(self) -> None:
p = ForgotPasswordSendPayload(email="user@example.com")
assert p.email == "user@example.com"
def test_invalid_email(self) -> None:
with pytest.raises(ValidationError, match="not a valid email"):
ForgotPasswordSendPayload(email="not-an-email")
def test_language_optional(self) -> None:
p = ForgotPasswordSendPayload(email="a@b.com")
assert p.language is None
class TestForgotPasswordCheckPayload:
def test_valid(self) -> None:
p = ForgotPasswordCheckPayload(email="a@b.com", code="1234", token="tok")
assert p.email == "a@b.com"
assert p.code == "1234"
assert p.token == "tok"
def test_empty_token_rejected(self) -> None:
with pytest.raises(ValidationError):
ForgotPasswordCheckPayload(email="a@b.com", code="1234", token="")
class TestForgotPasswordResetPayload:
def test_valid_passwords(self) -> None:
p = ForgotPasswordResetPayload(token="tok", new_password="Valid1234", password_confirm="Valid1234")
assert p.new_password == "Valid1234"
def test_weak_password_rejected(self) -> None:
with pytest.raises(ValidationError, match="Password must contain"):
ForgotPasswordResetPayload(token="tok", new_password="short", password_confirm="short")
def test_letters_only_password_rejected(self) -> None:
with pytest.raises(ValidationError, match="Password must contain"):
ForgotPasswordResetPayload(token="tok", new_password="abcdefghi", password_confirm="abcdefghi")
def test_digits_only_password_rejected(self) -> None:
with pytest.raises(ValidationError, match="Password must contain"):
ForgotPasswordResetPayload(token="tok", new_password="123456789", password_confirm="123456789")
# ---------------------------------------------------------------------------
# login.py models
# ---------------------------------------------------------------------------
from controllers.web.login import EmailCodeLoginSendPayload, EmailCodeLoginVerifyPayload, LoginPayload
class TestLoginPayload:
def test_valid(self) -> None:
p = LoginPayload(email="a@b.com", password="Valid1234")
assert p.email == "a@b.com"
def test_invalid_email(self) -> None:
with pytest.raises(ValidationError, match="not a valid email"):
LoginPayload(email="bad", password="Valid1234")
def test_weak_password(self) -> None:
with pytest.raises(ValidationError, match="Password must contain"):
LoginPayload(email="a@b.com", password="weak")
class TestEmailCodeLoginSendPayload:
def test_valid(self) -> None:
p = EmailCodeLoginSendPayload(email="a@b.com")
assert p.language is None
def test_with_language(self) -> None:
p = EmailCodeLoginSendPayload(email="a@b.com", language="zh-Hans")
assert p.language == "zh-Hans"
class TestEmailCodeLoginVerifyPayload:
def test_valid(self) -> None:
p = EmailCodeLoginVerifyPayload(email="a@b.com", code="1234", token="tok")
assert p.code == "1234"
def test_empty_token_rejected(self) -> None:
with pytest.raises(ValidationError):
EmailCodeLoginVerifyPayload(email="a@b.com", code="1234", token="")
@@ -1,147 +0,0 @@
"""Unit tests for controllers.web.remote_files endpoints."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.common.errors import FileTooLargeError, RemoteFileUploadError
from controllers.web.remote_files import RemoteFileInfoApi, RemoteFileUploadApi
def _app_model() -> SimpleNamespace:
return SimpleNamespace(id="app-1")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="eu-1")
# ---------------------------------------------------------------------------
# RemoteFileInfoApi
# ---------------------------------------------------------------------------
class TestRemoteFileInfoApi:
@patch("controllers.web.remote_files.ssrf_proxy")
def test_head_success(self, mock_proxy: MagicMock, app: Flask) -> None:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_proxy.head.return_value = mock_resp
with app.test_request_context("/remote-files/https%3A%2F%2Fexample.com%2Ffile.pdf"):
result = RemoteFileInfoApi().get(_app_model(), _end_user(), "https%3A%2F%2Fexample.com%2Ffile.pdf")
assert result["file_type"] == "application/pdf"
assert result["file_length"] == 1024
@patch("controllers.web.remote_files.ssrf_proxy")
def test_fallback_to_get(self, mock_proxy: MagicMock, app: Flask) -> None:
head_resp = MagicMock()
head_resp.status_code = 405 # Method not allowed
get_resp = MagicMock()
get_resp.status_code = 200
get_resp.headers = {"Content-Type": "text/plain", "Content-Length": "42"}
get_resp.raise_for_status = MagicMock()
mock_proxy.head.return_value = head_resp
mock_proxy.get.return_value = get_resp
with app.test_request_context("/remote-files/https%3A%2F%2Fexample.com%2Ffile.txt"):
result = RemoteFileInfoApi().get(_app_model(), _end_user(), "https%3A%2F%2Fexample.com%2Ffile.txt")
assert result["file_type"] == "text/plain"
mock_proxy.get.assert_called_once()
# ---------------------------------------------------------------------------
# RemoteFileUploadApi
# ---------------------------------------------------------------------------
class TestRemoteFileUploadApi:
@patch("controllers.web.remote_files.file_helpers.get_signed_file_url", return_value="https://signed-url")
@patch("controllers.web.remote_files.FileService")
@patch("controllers.web.remote_files.helpers.guess_file_info_from_response")
@patch("controllers.web.remote_files.ssrf_proxy")
@patch("controllers.web.remote_files.web_ns")
@patch("controllers.web.remote_files.db")
def test_upload_success(
self,
mock_db: MagicMock,
mock_ns: MagicMock,
mock_proxy: MagicMock,
mock_guess: MagicMock,
mock_file_svc_cls: MagicMock,
mock_signed: MagicMock,
app: Flask,
) -> None:
mock_db.engine = "engine"
mock_ns.payload = {"url": "https://example.com/file.pdf"}
head_resp = MagicMock()
head_resp.status_code = 200
head_resp.content = b"pdf-content"
head_resp.request.method = "HEAD"
mock_proxy.head.return_value = head_resp
get_resp = MagicMock()
get_resp.content = b"pdf-content"
mock_proxy.get.return_value = get_resp
mock_guess.return_value = SimpleNamespace(
filename="file.pdf", extension="pdf", mimetype="application/pdf", size=100
)
mock_file_svc_cls.is_file_size_within_limit.return_value = True
from datetime import datetime
upload_file = SimpleNamespace(
id="f-1",
name="file.pdf",
size=100,
extension="pdf",
mime_type="application/pdf",
created_by="eu-1",
created_at=datetime(2024, 1, 1),
)
mock_file_svc_cls.return_value.upload_file.return_value = upload_file
with app.test_request_context("/remote-files/upload", method="POST"):
result, status = RemoteFileUploadApi().post(_app_model(), _end_user())
assert status == 201
assert result["id"] == "f-1"
@patch("controllers.web.remote_files.FileService.is_file_size_within_limit", return_value=False)
@patch("controllers.web.remote_files.helpers.guess_file_info_from_response")
@patch("controllers.web.remote_files.ssrf_proxy")
@patch("controllers.web.remote_files.web_ns")
def test_file_too_large(
self,
mock_ns: MagicMock,
mock_proxy: MagicMock,
mock_guess: MagicMock,
mock_size_check: MagicMock,
app: Flask,
) -> None:
mock_ns.payload = {"url": "https://example.com/big.zip"}
head_resp = MagicMock()
head_resp.status_code = 200
mock_proxy.head.return_value = head_resp
mock_guess.return_value = SimpleNamespace(
filename="big.zip", extension="zip", mimetype="application/zip", size=999999999
)
with app.test_request_context("/remote-files/upload", method="POST"):
with pytest.raises(FileTooLargeError):
RemoteFileUploadApi().post(_app_model(), _end_user())
@patch("controllers.web.remote_files.ssrf_proxy")
@patch("controllers.web.remote_files.web_ns")
def test_fetch_failure_raises(self, mock_ns: MagicMock, mock_proxy: MagicMock, app: Flask) -> None:
import httpx
mock_ns.payload = {"url": "https://example.com/bad"}
mock_proxy.head.side_effect = httpx.RequestError("connection failed")
with app.test_request_context("/remote-files/upload", method="POST"):
with pytest.raises(RemoteFileUploadError):
RemoteFileUploadApi().post(_app_model(), _end_user())
@@ -1,97 +0,0 @@
"""Unit tests for controllers.web.saved_message endpoints."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from werkzeug.exceptions import NotFound
from controllers.web.error import NotCompletionAppError
from controllers.web.saved_message import SavedMessageApi, SavedMessageListApi
from services.errors.message import MessageNotExistsError
def _completion_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="completion")
def _chat_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="chat")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="eu-1")
# ---------------------------------------------------------------------------
# SavedMessageListApi (GET)
# ---------------------------------------------------------------------------
class TestSavedMessageListApiGet:
def test_non_completion_mode_raises(self, app: Flask) -> None:
with app.test_request_context("/saved-messages"):
with pytest.raises(NotCompletionAppError):
SavedMessageListApi().get(_chat_app(), _end_user())
@patch("controllers.web.saved_message.SavedMessageService.pagination_by_last_id")
def test_happy_path(self, mock_paginate: MagicMock, app: Flask) -> None:
mock_paginate.return_value = SimpleNamespace(limit=20, has_more=False, data=[])
with app.test_request_context("/saved-messages?limit=20"):
result = SavedMessageListApi().get(_completion_app(), _end_user())
assert result["limit"] == 20
assert result["has_more"] is False
# ---------------------------------------------------------------------------
# SavedMessageListApi (POST)
# ---------------------------------------------------------------------------
class TestSavedMessageListApiPost:
def test_non_completion_mode_raises(self, app: Flask) -> None:
with app.test_request_context("/saved-messages", method="POST"):
with pytest.raises(NotCompletionAppError):
SavedMessageListApi().post(_chat_app(), _end_user())
@patch("controllers.web.saved_message.SavedMessageService.save")
@patch("controllers.web.saved_message.web_ns")
def test_save_success(self, mock_ns: MagicMock, mock_save: MagicMock, app: Flask) -> None:
msg_id = str(uuid4())
mock_ns.payload = {"message_id": msg_id}
with app.test_request_context("/saved-messages", method="POST"):
result = SavedMessageListApi().post(_completion_app(), _end_user())
assert result["result"] == "success"
@patch("controllers.web.saved_message.SavedMessageService.save", side_effect=MessageNotExistsError())
@patch("controllers.web.saved_message.web_ns")
def test_save_not_found(self, mock_ns: MagicMock, mock_save: MagicMock, app: Flask) -> None:
mock_ns.payload = {"message_id": str(uuid4())}
with app.test_request_context("/saved-messages", method="POST"):
with pytest.raises(NotFound, match="Message Not Exists"):
SavedMessageListApi().post(_completion_app(), _end_user())
# ---------------------------------------------------------------------------
# SavedMessageApi (DELETE)
# ---------------------------------------------------------------------------
class TestSavedMessageApi:
def test_non_completion_mode_raises(self, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/saved-messages/{msg_id}", method="DELETE"):
with pytest.raises(NotCompletionAppError):
SavedMessageApi().delete(_chat_app(), _end_user(), msg_id)
@patch("controllers.web.saved_message.SavedMessageService.delete")
def test_delete_success(self, mock_delete: MagicMock, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/saved-messages/{msg_id}", method="DELETE"):
result, status = SavedMessageApi().delete(_completion_app(), _end_user(), msg_id)
assert status == 204
assert result["result"] == "success"
@@ -1,126 +0,0 @@
"""Unit tests for controllers.web.site endpoints."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from werkzeug.exceptions import Forbidden
from controllers.web.site import AppSiteApi, AppSiteInfo
def _tenant(*, status: str = "normal") -> SimpleNamespace:
return SimpleNamespace(
id="tenant-1",
status=status,
plan="basic",
custom_config_dict={"remove_webapp_brand": False, "replace_webapp_logo": False},
)
def _site() -> SimpleNamespace:
return SimpleNamespace(
title="Site",
icon_type="emoji",
icon="robot",
icon_background="#fff",
description="desc",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
copyright=None,
privacy_policy=None,
custom_disclaimer=None,
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
)
# ---------------------------------------------------------------------------
# AppSiteApi
# ---------------------------------------------------------------------------
class TestAppSiteApi:
@patch("controllers.web.site.FeatureService.get_features")
@patch("controllers.web.site.db")
def test_happy_path(self, mock_db: MagicMock, mock_features: MagicMock, app: Flask) -> None:
app.config["RESTX_MASK_HEADER"] = "X-Fields"
mock_features.return_value = SimpleNamespace(can_replace_logo=False)
site_obj = _site()
mock_db.session.query.return_value.where.return_value.first.return_value = site_obj
tenant = _tenant()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
end_user = SimpleNamespace(id="eu-1")
with app.test_request_context("/site"):
result = AppSiteApi().get(app_model, end_user)
# marshal_with serializes AppSiteInfo to a dict
assert result["app_id"] == "app-1"
assert result["plan"] == "basic"
assert result["enable_site"] is True
@patch("controllers.web.site.db")
def test_missing_site_raises_forbidden(self, mock_db: MagicMock, app: Flask) -> None:
app.config["RESTX_MASK_HEADER"] = "X-Fields"
mock_db.session.query.return_value.where.return_value.first.return_value = None
tenant = _tenant()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant)
end_user = SimpleNamespace(id="eu-1")
with app.test_request_context("/site"):
with pytest.raises(Forbidden):
AppSiteApi().get(app_model, end_user)
@patch("controllers.web.site.db")
def test_archived_tenant_raises_forbidden(self, mock_db: MagicMock, app: Flask) -> None:
app.config["RESTX_MASK_HEADER"] = "X-Fields"
from models.account import TenantStatus
mock_db.session.query.return_value.where.return_value.first.return_value = _site()
tenant = SimpleNamespace(
id="tenant-1",
status=TenantStatus.ARCHIVE,
plan="basic",
custom_config_dict={},
)
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant)
end_user = SimpleNamespace(id="eu-1")
with app.test_request_context("/site"):
with pytest.raises(Forbidden):
AppSiteApi().get(app_model, end_user)
# ---------------------------------------------------------------------------
# AppSiteInfo
# ---------------------------------------------------------------------------
class TestAppSiteInfo:
def test_basic_fields(self) -> None:
tenant = _tenant()
site_obj = _site()
info = AppSiteInfo(tenant, SimpleNamespace(id="app-1", enable_site=True), site_obj, "eu-1", False)
assert info.app_id == "app-1"
assert info.end_user_id == "eu-1"
assert info.enable_site is True
assert info.plan == "basic"
assert info.can_replace_logo is False
assert info.model_config is None
@patch("controllers.web.site.dify_config", SimpleNamespace(FILES_URL="https://files.example.com"))
def test_can_replace_logo_sets_custom_config(self) -> None:
tenant = SimpleNamespace(
id="tenant-1",
plan="pro",
custom_config_dict={"remove_webapp_brand": True, "replace_webapp_logo": True},
)
site_obj = _site()
info = AppSiteInfo(tenant, SimpleNamespace(id="app-1", enable_site=True), site_obj, "eu-1", True)
assert info.can_replace_logo is True
assert info.custom_config["remove_webapp_brand"] is True
assert "webapp-logo" in info.custom_config["replace_webapp_logo"]
@@ -5,8 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
import services.errors.account
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi
def encode_code(code: str) -> str:
@@ -90,114 +89,3 @@ class TestEmailCodeLoginApi:
mock_revoke_token.assert_called_once_with("token-123")
mock_login.assert_called_once()
mock_reset_login_rate.assert_called_once_with("user@example.com")
class TestLoginApi:
@patch("controllers.web.login.WebAppAuthService.login", return_value="access-tok")
@patch("controllers.web.login.WebAppAuthService.authenticate")
def test_login_success(self, mock_auth: MagicMock, mock_login: MagicMock, app: Flask) -> None:
mock_auth.return_value = MagicMock()
with app.test_request_context(
"/web/login",
method="POST",
json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()},
):
response = LoginApi().post()
assert response.get_json()["data"]["access_token"] == "access-tok"
mock_auth.assert_called_once()
@patch(
"controllers.web.login.WebAppAuthService.authenticate",
side_effect=services.errors.account.AccountLoginError(),
)
def test_login_banned_account(self, mock_auth: MagicMock, app: Flask) -> None:
from controllers.console.error import AccountBannedError
with app.test_request_context(
"/web/login",
method="POST",
json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()},
):
with pytest.raises(AccountBannedError):
LoginApi().post()
@patch(
"controllers.web.login.WebAppAuthService.authenticate",
side_effect=services.errors.account.AccountPasswordError(),
)
def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask) -> None:
from controllers.console.auth.error import AuthenticationFailedError
with app.test_request_context(
"/web/login",
method="POST",
json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()},
):
with pytest.raises(AuthenticationFailedError):
LoginApi().post()
class TestLoginStatusApi:
@patch("controllers.web.login.extract_webapp_access_token", return_value=None)
def test_no_app_code_returns_logged_in_false(self, mock_extract: MagicMock, app: Flask) -> None:
with app.test_request_context("/web/login/status"):
result = LoginStatusApi().get()
assert result["logged_in"] is False
assert result["app_logged_in"] is False
@patch("controllers.web.login.decode_jwt_token")
@patch("controllers.web.login.PassportService")
@patch("controllers.web.login.WebAppAuthService.is_app_require_permission_check", return_value=False)
@patch("controllers.web.login.AppService.get_app_id_by_code", return_value="app-1")
@patch("controllers.web.login.extract_webapp_access_token", return_value="tok")
def test_public_app_user_logged_in(
self,
mock_extract: MagicMock,
mock_app_id: MagicMock,
mock_perm: MagicMock,
mock_passport: MagicMock,
mock_decode: MagicMock,
app: Flask,
) -> None:
mock_decode.return_value = (MagicMock(), MagicMock())
with app.test_request_context("/web/login/status?app_code=code1"):
result = LoginStatusApi().get()
assert result["logged_in"] is True
assert result["app_logged_in"] is True
@patch("controllers.web.login.decode_jwt_token", side_effect=Exception("bad"))
@patch("controllers.web.login.PassportService")
@patch("controllers.web.login.WebAppAuthService.is_app_require_permission_check", return_value=True)
@patch("controllers.web.login.AppService.get_app_id_by_code", return_value="app-1")
@patch("controllers.web.login.extract_webapp_access_token", return_value="tok")
def test_private_app_passport_fails(
self,
mock_extract: MagicMock,
mock_app_id: MagicMock,
mock_perm: MagicMock,
mock_passport_cls: MagicMock,
mock_decode: MagicMock,
app: Flask,
) -> None:
mock_passport_cls.return_value.verify.side_effect = Exception("bad")
with app.test_request_context("/web/login/status?app_code=code1"):
result = LoginStatusApi().get()
assert result["logged_in"] is False
assert result["app_logged_in"] is False
class TestLogoutApi:
@patch("controllers.web.login.clear_webapp_access_token_from_cookie")
def test_logout_success(self, mock_clear: MagicMock, app: Flask) -> None:
with app.test_request_context("/web/logout", method="POST"):
response = LogoutApi().post()
assert response.get_json() == {"result": "success"}
mock_clear.assert_called_once()
@@ -1,192 +0,0 @@
"""Unit tests for controllers.web.passport — token issuance and enterprise auth exchange."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from werkzeug.exceptions import NotFound, Unauthorized
from controllers.web.error import WebAppAuthRequiredError
from controllers.web.passport import (
PassportResource,
decode_enterprise_webapp_user_id,
exchange_token_for_existing_web_user,
generate_session_id,
)
from services.webapp_auth_service import WebAppAuthType
# ---------------------------------------------------------------------------
# decode_enterprise_webapp_user_id
# ---------------------------------------------------------------------------
class TestDecodeEnterpriseWebappUserId:
def test_none_token_returns_none(self) -> None:
assert decode_enterprise_webapp_user_id(None) is None
@patch("controllers.web.passport.PassportService")
def test_valid_token_returns_decoded(self, mock_passport_cls: MagicMock) -> None:
mock_passport_cls.return_value.verify.return_value = {
"token_source": "webapp_login_token",
"user_id": "u1",
}
result = decode_enterprise_webapp_user_id("valid-jwt")
assert result["user_id"] == "u1"
@patch("controllers.web.passport.PassportService")
def test_wrong_source_raises_unauthorized(self, mock_passport_cls: MagicMock) -> None:
mock_passport_cls.return_value.verify.return_value = {
"token_source": "other_source",
}
with pytest.raises(Unauthorized, match="Expected 'webapp_login_token'"):
decode_enterprise_webapp_user_id("bad-jwt")
@patch("controllers.web.passport.PassportService")
def test_missing_source_raises_unauthorized(self, mock_passport_cls: MagicMock) -> None:
mock_passport_cls.return_value.verify.return_value = {}
with pytest.raises(Unauthorized, match="Expected 'webapp_login_token'"):
decode_enterprise_webapp_user_id("no-source-jwt")
# ---------------------------------------------------------------------------
# generate_session_id
# ---------------------------------------------------------------------------
class TestGenerateSessionId:
@patch("controllers.web.passport.db")
def test_returns_unique_session_id(self, mock_db: MagicMock) -> None:
mock_db.session.scalar.return_value = 0
sid = generate_session_id()
assert isinstance(sid, str)
assert len(sid) == 36 # UUID format
@patch("controllers.web.passport.db")
def test_retries_on_collision(self, mock_db: MagicMock) -> None:
# First call returns count=1 (collision), second returns 0
mock_db.session.scalar.side_effect = [1, 0]
sid = generate_session_id()
assert isinstance(sid, str)
assert mock_db.session.scalar.call_count == 2
# ---------------------------------------------------------------------------
# exchange_token_for_existing_web_user
# ---------------------------------------------------------------------------
class TestExchangeTokenForExistingWebUser:
@patch("controllers.web.passport.PassportService")
@patch("controllers.web.passport.db")
def test_external_auth_type_mismatch_raises(self, mock_db: MagicMock, mock_passport_cls: MagicMock) -> None:
site = SimpleNamespace(code="code1", app_id="app-1")
app_model = SimpleNamespace(id="app-1", status="normal", enable_site=True, tenant_id="t1")
mock_db.session.scalar.side_effect = [site, app_model]
decoded = {"user_id": "u1", "auth_type": "internal"} # mismatch: expected "external"
with pytest.raises(WebAppAuthRequiredError, match="external"):
exchange_token_for_existing_web_user(
app_code="code1", enterprise_user_decoded=decoded, auth_type=WebAppAuthType.EXTERNAL
)
@patch("controllers.web.passport.PassportService")
@patch("controllers.web.passport.db")
def test_internal_auth_type_mismatch_raises(self, mock_db: MagicMock, mock_passport_cls: MagicMock) -> None:
site = SimpleNamespace(code="code1", app_id="app-1")
app_model = SimpleNamespace(id="app-1", status="normal", enable_site=True, tenant_id="t1")
mock_db.session.scalar.side_effect = [site, app_model]
decoded = {"user_id": "u1", "auth_type": "external"} # mismatch: expected "internal"
with pytest.raises(WebAppAuthRequiredError, match="internal"):
exchange_token_for_existing_web_user(
app_code="code1", enterprise_user_decoded=decoded, auth_type=WebAppAuthType.INTERNAL
)
@patch("controllers.web.passport.PassportService")
@patch("controllers.web.passport.db")
def test_site_not_found_raises(self, mock_db: MagicMock, mock_passport_cls: MagicMock) -> None:
mock_db.session.scalar.return_value = None
decoded = {"user_id": "u1", "auth_type": "external"}
with pytest.raises(NotFound):
exchange_token_for_existing_web_user(
app_code="code1", enterprise_user_decoded=decoded, auth_type=WebAppAuthType.EXTERNAL
)
# ---------------------------------------------------------------------------
# PassportResource.get
# ---------------------------------------------------------------------------
class TestPassportResource:
@patch("controllers.web.passport.FeatureService.get_system_features")
def test_missing_app_code_raises_unauthorized(self, mock_features: MagicMock, app: Flask) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
with app.test_request_context("/passport"):
with pytest.raises(Unauthorized, match="X-App-Code"):
PassportResource().get()
@patch("controllers.web.passport.PassportService")
@patch("controllers.web.passport.generate_session_id", return_value="new-sess-id")
@patch("controllers.web.passport.db")
@patch("controllers.web.passport.FeatureService.get_system_features")
def test_creates_new_end_user_when_no_user_id(
self,
mock_features: MagicMock,
mock_db: MagicMock,
mock_gen_session: MagicMock,
mock_passport_cls: MagicMock,
app: Flask,
) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
site = SimpleNamespace(app_id="app-1", code="code1")
app_model = SimpleNamespace(id="app-1", status="normal", enable_site=True, tenant_id="t1")
mock_db.session.scalar.side_effect = [site, app_model]
mock_passport_cls.return_value.issue.return_value = "issued-token"
with app.test_request_context("/passport", headers={"X-App-Code": "code1"}):
response = PassportResource().get()
assert response.get_json()["access_token"] == "issued-token"
mock_db.session.add.assert_called_once()
mock_db.session.commit.assert_called_once()
@patch("controllers.web.passport.PassportService")
@patch("controllers.web.passport.db")
@patch("controllers.web.passport.FeatureService.get_system_features")
def test_reuses_existing_end_user_when_user_id_provided(
self,
mock_features: MagicMock,
mock_db: MagicMock,
mock_passport_cls: MagicMock,
app: Flask,
) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
site = SimpleNamespace(app_id="app-1", code="code1")
app_model = SimpleNamespace(id="app-1", status="normal", enable_site=True, tenant_id="t1")
existing_user = SimpleNamespace(id="eu-1", session_id="sess-existing")
mock_db.session.scalar.side_effect = [site, app_model, existing_user]
mock_passport_cls.return_value.issue.return_value = "reused-token"
with app.test_request_context("/passport?user_id=sess-existing", headers={"X-App-Code": "code1"}):
response = PassportResource().get()
assert response.get_json()["access_token"] == "reused-token"
# Should not create a new end user
mock_db.session.add.assert_not_called()
@patch("controllers.web.passport.db")
@patch("controllers.web.passport.FeatureService.get_system_features")
def test_site_not_found_raises(self, mock_features: MagicMock, mock_db: MagicMock, app: Flask) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_db.session.scalar.return_value = None
with app.test_request_context("/passport", headers={"X-App-Code": "code1"}):
with pytest.raises(NotFound):
PassportResource().get()
@patch("controllers.web.passport.db")
@patch("controllers.web.passport.FeatureService.get_system_features")
def test_disabled_app_raises_not_found(self, mock_features: MagicMock, mock_db: MagicMock, app: Flask) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
site = SimpleNamespace(app_id="app-1", code="code1")
disabled_app = SimpleNamespace(id="app-1", status="normal", enable_site=False)
mock_db.session.scalar.side_effect = [site, disabled_app]
with app.test_request_context("/passport", headers={"X-App-Code": "code1"}):
with pytest.raises(NotFound):
PassportResource().get()
@@ -1,95 +0,0 @@
"""Unit tests for controllers.web.workflow endpoints."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.web.error import (
NotWorkflowAppError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.web.workflow import WorkflowRunApi, WorkflowTaskStopApi
from core.errors.error import ProviderTokenNotInitError, QuotaExceededError
def _workflow_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="workflow")
def _chat_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode="chat")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="eu-1")
# ---------------------------------------------------------------------------
# WorkflowRunApi
# ---------------------------------------------------------------------------
class TestWorkflowRunApi:
def test_wrong_mode_raises(self, app: Flask) -> None:
with app.test_request_context("/workflows/run", method="POST"):
with pytest.raises(NotWorkflowAppError):
WorkflowRunApi().post(_chat_app(), _end_user())
@patch("controllers.web.workflow.helper.compact_generate_response", return_value={"result": "ok"})
@patch("controllers.web.workflow.AppGenerateService.generate")
@patch("controllers.web.workflow.web_ns")
def test_happy_path(self, mock_ns: MagicMock, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {"key": "val"}}
mock_gen.return_value = "response"
with app.test_request_context("/workflows/run", method="POST"):
result = WorkflowRunApi().post(_workflow_app(), _end_user())
assert result == {"result": "ok"}
@patch(
"controllers.web.workflow.AppGenerateService.generate",
side_effect=ProviderTokenNotInitError(description="not init"),
)
@patch("controllers.web.workflow.web_ns")
def test_provider_not_init(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
with app.test_request_context("/workflows/run", method="POST"):
with pytest.raises(ProviderNotInitializeError):
WorkflowRunApi().post(_workflow_app(), _end_user())
@patch(
"controllers.web.workflow.AppGenerateService.generate",
side_effect=QuotaExceededError(),
)
@patch("controllers.web.workflow.web_ns")
def test_quota_exceeded(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}}
with app.test_request_context("/workflows/run", method="POST"):
with pytest.raises(ProviderQuotaExceededError):
WorkflowRunApi().post(_workflow_app(), _end_user())
# ---------------------------------------------------------------------------
# WorkflowTaskStopApi
# ---------------------------------------------------------------------------
class TestWorkflowTaskStopApi:
def test_wrong_mode_raises(self, app: Flask) -> None:
with app.test_request_context("/workflows/tasks/task-1/stop", method="POST"):
with pytest.raises(NotWorkflowAppError):
WorkflowTaskStopApi().post(_chat_app(), _end_user(), "task-1")
@patch("controllers.web.workflow.GraphEngineManager.send_stop_command")
@patch("controllers.web.workflow.AppQueueManager.set_stop_flag_no_user_check")
def test_stop_calls_both_mechanisms(self, mock_legacy: MagicMock, mock_graph: MagicMock, app: Flask) -> None:
with app.test_request_context("/workflows/tasks/task-1/stop", method="POST"):
result = WorkflowTaskStopApi().post(_workflow_app(), _end_user(), "task-1")
assert result == {"result": "success"}
mock_legacy.assert_called_once_with("task-1")
mock_graph.assert_called_once_with("task-1")
@@ -1,127 +0,0 @@
"""Unit tests for controllers.web.workflow_events endpoints."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.web.error import NotFoundError
from controllers.web.workflow_events import WorkflowEventsApi
from models.enums import CreatorUserRole
def _workflow_app() -> SimpleNamespace:
return SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="eu-1")
# ---------------------------------------------------------------------------
# WorkflowEventsApi
# ---------------------------------------------------------------------------
class TestWorkflowEventsApi:
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
@patch("controllers.web.workflow_events.db")
def test_workflow_run_not_found(self, mock_db: MagicMock, mock_factory: MagicMock, app: Flask) -> None:
mock_db.engine = "engine"
mock_repo = MagicMock()
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = None
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
with app.test_request_context("/workflow/run-1/events"):
with pytest.raises(NotFoundError):
WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
@patch("controllers.web.workflow_events.db")
def test_workflow_run_wrong_app(self, mock_db: MagicMock, mock_factory: MagicMock, app: Flask) -> None:
mock_db.engine = "engine"
run = SimpleNamespace(
id="run-1",
app_id="other-app",
created_by_role=CreatorUserRole.END_USER,
created_by="eu-1",
finished_at=None,
)
mock_repo = MagicMock()
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = run
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
with app.test_request_context("/workflow/run-1/events"):
with pytest.raises(NotFoundError):
WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
@patch("controllers.web.workflow_events.db")
def test_workflow_run_not_created_by_end_user(
self, mock_db: MagicMock, mock_factory: MagicMock, app: Flask
) -> None:
mock_db.engine = "engine"
run = SimpleNamespace(
id="run-1",
app_id="app-1",
created_by_role=CreatorUserRole.ACCOUNT,
created_by="eu-1",
finished_at=None,
)
mock_repo = MagicMock()
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = run
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
with app.test_request_context("/workflow/run-1/events"):
with pytest.raises(NotFoundError):
WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
@patch("controllers.web.workflow_events.db")
def test_workflow_run_wrong_end_user(self, mock_db: MagicMock, mock_factory: MagicMock, app: Flask) -> None:
mock_db.engine = "engine"
run = SimpleNamespace(
id="run-1",
app_id="app-1",
created_by_role=CreatorUserRole.END_USER,
created_by="other-user",
finished_at=None,
)
mock_repo = MagicMock()
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = run
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
with app.test_request_context("/workflow/run-1/events"):
with pytest.raises(NotFoundError):
WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
@patch("controllers.web.workflow_events.WorkflowResponseConverter")
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
@patch("controllers.web.workflow_events.db")
def test_finished_run_returns_sse_response(
self, mock_db: MagicMock, mock_factory: MagicMock, mock_converter: MagicMock, app: Flask
) -> None:
from datetime import datetime
mock_db.engine = "engine"
run = SimpleNamespace(
id="run-1",
app_id="app-1",
created_by_role=CreatorUserRole.END_USER,
created_by="eu-1",
finished_at=datetime(2024, 1, 1),
)
mock_repo = MagicMock()
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = run
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
finish_response = MagicMock()
finish_response.model_dump.return_value = {"task_id": "run-1"}
finish_response.event.value = "workflow_finished"
mock_converter.workflow_run_result_to_finish_response.return_value = finish_response
with app.test_request_context("/workflow/run-1/events"):
response = WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
assert response.mimetype == "text/event-stream"
@@ -1,393 +0,0 @@
"""Unit tests for controllers.web.wraps — JWT auth decorator and validation helpers."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError
from controllers.web.wraps import (
_validate_user_accessibility,
_validate_webapp_token,
decode_jwt_token,
)
# ---------------------------------------------------------------------------
# _validate_webapp_token
# ---------------------------------------------------------------------------
class TestValidateWebappToken:
def test_enterprise_enabled_and_app_auth_requires_webapp_source(self) -> None:
"""When both flags are true, a non-webapp source must raise."""
decoded = {"token_source": "other"}
with pytest.raises(WebAppAuthRequiredError):
_validate_webapp_token(decoded, app_web_auth_enabled=True, system_webapp_auth_enabled=True)
def test_enterprise_enabled_and_app_auth_accepts_webapp_source(self) -> None:
decoded = {"token_source": "webapp"}
_validate_webapp_token(decoded, app_web_auth_enabled=True, system_webapp_auth_enabled=True)
def test_enterprise_enabled_and_app_auth_missing_source_raises(self) -> None:
decoded = {}
with pytest.raises(WebAppAuthRequiredError):
_validate_webapp_token(decoded, app_web_auth_enabled=True, system_webapp_auth_enabled=True)
def test_public_app_rejects_webapp_source(self) -> None:
"""When auth is not required, a webapp-sourced token must be rejected."""
decoded = {"token_source": "webapp"}
with pytest.raises(Unauthorized):
_validate_webapp_token(decoded, app_web_auth_enabled=False, system_webapp_auth_enabled=False)
def test_public_app_accepts_non_webapp_source(self) -> None:
decoded = {"token_source": "other"}
_validate_webapp_token(decoded, app_web_auth_enabled=False, system_webapp_auth_enabled=False)
def test_public_app_accepts_no_source(self) -> None:
decoded = {}
_validate_webapp_token(decoded, app_web_auth_enabled=False, system_webapp_auth_enabled=False)
def test_system_enabled_but_app_public(self) -> None:
"""system_webapp_auth_enabled=True but app is public — webapp source rejected."""
decoded = {"token_source": "webapp"}
with pytest.raises(Unauthorized):
_validate_webapp_token(decoded, app_web_auth_enabled=False, system_webapp_auth_enabled=True)
# ---------------------------------------------------------------------------
# _validate_user_accessibility
# ---------------------------------------------------------------------------
class TestValidateUserAccessibility:
def test_skips_when_auth_disabled(self) -> None:
"""No checks when system or app auth is disabled."""
_validate_user_accessibility(
decoded={},
app_code="code",
app_web_auth_enabled=False,
system_webapp_auth_enabled=False,
webapp_settings=None,
)
def test_missing_user_id_raises(self) -> None:
decoded = {}
with pytest.raises(WebAppAuthRequiredError):
_validate_user_accessibility(
decoded=decoded,
app_code="code",
app_web_auth_enabled=True,
system_webapp_auth_enabled=True,
webapp_settings=SimpleNamespace(access_mode="internal"),
)
def test_missing_webapp_settings_raises(self) -> None:
decoded = {"user_id": "u1"}
with pytest.raises(WebAppAuthRequiredError, match="settings not found"):
_validate_user_accessibility(
decoded=decoded,
app_code="code",
app_web_auth_enabled=True,
system_webapp_auth_enabled=True,
webapp_settings=None,
)
def test_missing_auth_type_raises(self) -> None:
decoded = {"user_id": "u1", "granted_at": 1}
settings = SimpleNamespace(access_mode="public")
with pytest.raises(WebAppAuthAccessDeniedError, match="auth_type"):
_validate_user_accessibility(
decoded=decoded,
app_code="code",
app_web_auth_enabled=True,
system_webapp_auth_enabled=True,
webapp_settings=settings,
)
def test_missing_granted_at_raises(self) -> None:
decoded = {"user_id": "u1", "auth_type": "external"}
settings = SimpleNamespace(access_mode="public")
with pytest.raises(WebAppAuthAccessDeniedError, match="granted_at"):
_validate_user_accessibility(
decoded=decoded,
app_code="code",
app_web_auth_enabled=True,
system_webapp_auth_enabled=True,
webapp_settings=settings,
)
@patch("controllers.web.wraps.EnterpriseService.get_app_sso_settings_last_update_time")
@patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check", return_value=False)
def test_external_auth_type_checks_sso_update_time(
self, mock_perm_check: MagicMock, mock_sso_time: MagicMock
) -> None:
# granted_at is before SSO update time → denied
mock_sso_time.return_value = datetime.now(UTC)
old_granted = int((datetime.now(UTC) - timedelta(hours=1)).timestamp())
decoded = {"user_id": "u1", "auth_type": "external", "granted_at": old_granted}
settings = SimpleNamespace(access_mode="public")
with pytest.raises(WebAppAuthAccessDeniedError, match="SSO settings"):
_validate_user_accessibility(
decoded=decoded,
app_code="code",
app_web_auth_enabled=True,
system_webapp_auth_enabled=True,
webapp_settings=settings,
)
@patch("controllers.web.wraps.EnterpriseService.get_workspace_sso_settings_last_update_time")
@patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check", return_value=False)
def test_internal_auth_type_checks_workspace_sso_update_time(
self, mock_perm_check: MagicMock, mock_workspace_sso: MagicMock
) -> None:
mock_workspace_sso.return_value = datetime.now(UTC)
old_granted = int((datetime.now(UTC) - timedelta(hours=1)).timestamp())
decoded = {"user_id": "u1", "auth_type": "internal", "granted_at": old_granted}
settings = SimpleNamespace(access_mode="public")
with pytest.raises(WebAppAuthAccessDeniedError, match="SSO settings"):
_validate_user_accessibility(
decoded=decoded,
app_code="code",
app_web_auth_enabled=True,
system_webapp_auth_enabled=True,
webapp_settings=settings,
)
@patch("controllers.web.wraps.EnterpriseService.get_app_sso_settings_last_update_time")
@patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check", return_value=False)
def test_external_auth_passes_when_granted_after_sso_update(
self, mock_perm_check: MagicMock, mock_sso_time: MagicMock
) -> None:
mock_sso_time.return_value = datetime.now(UTC) - timedelta(hours=2)
recent_granted = int(datetime.now(UTC).timestamp())
decoded = {"user_id": "u1", "auth_type": "external", "granted_at": recent_granted}
settings = SimpleNamespace(access_mode="public")
# Should not raise
_validate_user_accessibility(
decoded=decoded,
app_code="code",
app_web_auth_enabled=True,
system_webapp_auth_enabled=True,
webapp_settings=settings,
)
@patch("controllers.web.wraps.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp", return_value=False)
@patch("controllers.web.wraps.AppService.get_app_id_by_code", return_value="app-id-1")
@patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check", return_value=True)
def test_permission_check_denies_unauthorized_user(
self, mock_perm: MagicMock, mock_app_id: MagicMock, mock_allowed: MagicMock
) -> None:
decoded = {"user_id": "u1", "auth_type": "external", "granted_at": int(datetime.now(UTC).timestamp())}
settings = SimpleNamespace(access_mode="internal")
with pytest.raises(WebAppAuthAccessDeniedError):
_validate_user_accessibility(
decoded=decoded,
app_code="code",
app_web_auth_enabled=True,
system_webapp_auth_enabled=True,
webapp_settings=settings,
)
# ---------------------------------------------------------------------------
# decode_jwt_token
# ---------------------------------------------------------------------------
class TestDecodeJwtToken:
@patch("controllers.web.wraps._validate_user_accessibility")
@patch("controllers.web.wraps._validate_webapp_token")
@patch("controllers.web.wraps.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
@patch("controllers.web.wraps.AppService.get_app_id_by_code")
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
@patch("controllers.web.wraps.db")
def test_happy_path(
self,
mock_db: MagicMock,
mock_extract: MagicMock,
mock_passport_cls: MagicMock,
mock_features: MagicMock,
mock_app_id: MagicMock,
mock_access_mode: MagicMock,
mock_validate_token: MagicMock,
mock_validate_user: MagicMock,
app: Flask,
) -> None:
mock_extract.return_value = "jwt-token"
mock_passport_cls.return_value.verify.return_value = {
"app_code": "code1",
"app_id": "app-1",
"end_user_id": "eu-1",
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
app_model = SimpleNamespace(id="app-1", enable_site=True)
site = SimpleNamespace(code="code1")
end_user = SimpleNamespace(id="eu-1", session_id="sess-1")
# Configure session mock to return correct objects via scalar()
session_mock = MagicMock()
session_mock.scalar.side_effect = [app_model, site, end_user]
session_ctx = MagicMock()
session_ctx.__enter__ = MagicMock(return_value=session_mock)
session_ctx.__exit__ = MagicMock(return_value=False)
mock_db.engine = "engine"
with patch("controllers.web.wraps.Session", return_value=session_ctx):
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
result_app, result_user = decode_jwt_token()
assert result_app.id == "app-1"
assert result_user.id == "eu-1"
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_missing_token_raises_unauthorized(
self, mock_extract: MagicMock, mock_features: MagicMock, app: Flask
) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_extract.return_value = None
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
with pytest.raises(Unauthorized):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
@patch("controllers.web.wraps.db")
def test_missing_app_raises_not_found(
self,
mock_db: MagicMock,
mock_extract: MagicMock,
mock_passport_cls: MagicMock,
mock_features: MagicMock,
app: Flask,
) -> None:
mock_extract.return_value = "jwt-token"
mock_passport_cls.return_value.verify.return_value = {
"app_code": "code1",
"app_id": "app-1",
"end_user_id": "eu-1",
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
session_mock = MagicMock()
session_mock.scalar.return_value = None # No app found
session_ctx = MagicMock()
session_ctx.__enter__ = MagicMock(return_value=session_mock)
session_ctx.__exit__ = MagicMock(return_value=False)
mock_db.engine = "engine"
with patch("controllers.web.wraps.Session", return_value=session_ctx):
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
with pytest.raises(NotFound):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
@patch("controllers.web.wraps.db")
def test_disabled_site_raises_bad_request(
self,
mock_db: MagicMock,
mock_extract: MagicMock,
mock_passport_cls: MagicMock,
mock_features: MagicMock,
app: Flask,
) -> None:
mock_extract.return_value = "jwt-token"
mock_passport_cls.return_value.verify.return_value = {
"app_code": "code1",
"app_id": "app-1",
"end_user_id": "eu-1",
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
app_model = SimpleNamespace(id="app-1", enable_site=False)
session_mock = MagicMock()
# scalar calls: app_model, site (code found), then end_user
session_mock.scalar.side_effect = [app_model, SimpleNamespace(code="code1"), None]
session_ctx = MagicMock()
session_ctx.__enter__ = MagicMock(return_value=session_mock)
session_ctx.__exit__ = MagicMock(return_value=False)
mock_db.engine = "engine"
with patch("controllers.web.wraps.Session", return_value=session_ctx):
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
with pytest.raises(BadRequest, match="Site is disabled"):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
@patch("controllers.web.wraps.db")
def test_missing_end_user_raises_not_found(
self,
mock_db: MagicMock,
mock_extract: MagicMock,
mock_passport_cls: MagicMock,
mock_features: MagicMock,
app: Flask,
) -> None:
mock_extract.return_value = "jwt-token"
mock_passport_cls.return_value.verify.return_value = {
"app_code": "code1",
"app_id": "app-1",
"end_user_id": "eu-1",
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
app_model = SimpleNamespace(id="app-1", enable_site=True)
site = SimpleNamespace(code="code1")
session_mock = MagicMock()
session_mock.scalar.side_effect = [app_model, site, None] # end_user is None
session_ctx = MagicMock()
session_ctx.__enter__ = MagicMock(return_value=session_mock)
session_ctx.__exit__ = MagicMock(return_value=False)
mock_db.engine = "engine"
with patch("controllers.web.wraps.Session", return_value=session_ctx):
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
with pytest.raises(NotFound):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
@patch("controllers.web.wraps.db")
def test_user_id_mismatch_raises_unauthorized(
self,
mock_db: MagicMock,
mock_extract: MagicMock,
mock_passport_cls: MagicMock,
mock_features: MagicMock,
app: Flask,
) -> None:
mock_extract.return_value = "jwt-token"
mock_passport_cls.return_value.verify.return_value = {
"app_code": "code1",
"app_id": "app-1",
"end_user_id": "eu-1",
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
app_model = SimpleNamespace(id="app-1", enable_site=True)
site = SimpleNamespace(code="code1")
end_user = SimpleNamespace(id="eu-1", session_id="sess-1")
session_mock = MagicMock()
session_mock.scalar.side_effect = [app_model, site, end_user]
session_ctx = MagicMock()
session_ctx.__enter__ = MagicMock(return_value=session_mock)
session_ctx.__exit__ = MagicMock(return_value=False)
mock_db.engine = "engine"
with patch("controllers.web.wraps.Session", return_value=session_ctx):
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
with pytest.raises(Unauthorized, match="expired"):
decode_jwt_token(user_id="different-user")
@@ -124,12 +124,12 @@ def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch):
def start(self):
self.started = True
def fake_thread(*args, **kwargs):
def fake_thread(**kwargs):
thread = DummyThread(**kwargs)
captured["thread"] = thread
return thread
monkeypatch.setattr(message_cycle_manager, "Timer", fake_thread)
monkeypatch.setattr(message_cycle_manager, "Thread", fake_thread)
manager = MessageCycleManager(application_generate_entity=entity, task_state=MagicMock())
thread = manager.generate_conversation_name(conversation_id="existing-conversation-id", query="hello")
@@ -8,9 +8,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from dify_graph.enums import WorkflowNodeExecutionStatus
from dify_graph.model_runtime.entities.llm_entities import LLMUsage
from dify_graph.nodes.knowledge_retrieval.entities import (
Condition,
KnowledgeRetrievalNodeData,
MetadataFilteringCondition,
MultipleRetrievalConfig,
RerankingModelConfig,
SingleRetrievalConfig,
@@ -595,106 +593,3 @@ class TestFetchDatasetRetriever:
# Assert
assert version == "1"
def test_resolve_metadata_filtering_conditions_templates(
self,
mock_graph_init_params,
mock_graph_runtime_state,
mock_rag_retrieval,
):
"""_resolve_metadata_filtering_conditions should expand {{#...#}} and keep numbers/None unchanged."""
# Arrange
node_id = str(uuid.uuid4())
config = {
"id": node_id,
"data": {
"title": "Knowledge Retrieval",
"type": "knowledge-retrieval",
"dataset_ids": [str(uuid.uuid4())],
"retrieval_mode": "multiple",
},
}
# Variable in pool used by template
mock_graph_runtime_state.variable_pool.add(["start", "query"], StringSegment(value="readme"))
node = KnowledgeRetrievalNode(
id=node_id,
config=config,
graph_init_params=mock_graph_init_params,
graph_runtime_state=mock_graph_runtime_state,
rag_retrieval=mock_rag_retrieval,
)
conditions = MetadataFilteringCondition(
logical_operator="and",
conditions=[
Condition(name="document_name", comparison_operator="is", value="{{#start.query#}}"),
Condition(name="tags", comparison_operator="in", value=["x", "{{#start.query#}}"]),
Condition(name="year", comparison_operator="=", value=2025),
],
)
# Act
resolved = node._resolve_metadata_filtering_conditions(conditions)
# Assert
assert resolved.logical_operator == "and"
assert resolved.conditions[0].value == "readme"
assert isinstance(resolved.conditions[1].value, list)
assert resolved.conditions[1].value[1] == "readme"
assert resolved.conditions[2].value == 2025
def test_fetch_passes_resolved_metadata_conditions(
self,
mock_graph_init_params,
mock_graph_runtime_state,
mock_rag_retrieval,
):
"""_fetch_dataset_retriever should pass resolved metadata conditions into request."""
# Arrange
query = "hi"
variables = {"query": query}
mock_graph_runtime_state.variable_pool.add(["start", "q"], StringSegment(value="readme"))
node_data = KnowledgeRetrievalNodeData(
title="Knowledge Retrieval",
type="knowledge-retrieval",
dataset_ids=[str(uuid.uuid4())],
retrieval_mode="multiple",
multiple_retrieval_config=MultipleRetrievalConfig(
top_k=4,
score_threshold=0.0,
reranking_mode="reranking_model",
reranking_enable=True,
reranking_model=RerankingModelConfig(provider="cohere", model="rerank-v2"),
),
metadata_filtering_mode="manual",
metadata_filtering_conditions=MetadataFilteringCondition(
logical_operator="and",
conditions=[
Condition(name="document_name", comparison_operator="is", value="{{#start.q#}}"),
],
),
)
node_id = str(uuid.uuid4())
config = {"id": node_id, "data": node_data.model_dump()}
node = KnowledgeRetrievalNode(
id=node_id,
config=config,
graph_init_params=mock_graph_init_params,
graph_runtime_state=mock_graph_runtime_state,
rag_retrieval=mock_rag_retrieval,
)
mock_rag_retrieval.knowledge_retrieval.return_value = []
mock_rag_retrieval.llm_usage = LLMUsage.empty_usage()
# Act
node._fetch_dataset_retriever(node_data=node_data, variables=variables)
# Assert the passed request has resolved value
call_args = mock_rag_retrieval.knowledge_retrieval.call_args
request = call_args[1]["request"]
assert request.metadata_filtering_conditions is not None
assert request.metadata_filtering_conditions.conditions[0].value == "readme"
@@ -16,7 +16,6 @@ from dify_graph.nodes.document_extractor.node import (
_extract_text_from_excel,
_extract_text_from_pdf,
_extract_text_from_plain_text,
_normalize_docx_zip,
)
from dify_graph.variables import ArrayFileSegment
from dify_graph.variables.segments import ArrayStringSegment
@@ -87,38 +86,6 @@ def test_run_invalid_variable_type(document_extractor_node, mock_graph_runtime_s
assert "is not an ArrayFileSegment" in result.error
def test_run_empty_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state):
"""Empty file list should return SUCCEEDED with empty documents and ArrayStringSegment([])."""
document_extractor_node.graph_runtime_state = mock_graph_runtime_state
# Provide an actual ArrayFileSegment with an empty list
mock_graph_runtime_state.variable_pool.get.return_value = ArrayFileSegment(value=[])
result = document_extractor_node._run()
assert isinstance(result, NodeRunResult)
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED, result.error
assert result.process_data.get("documents") == []
assert result.outputs["text"] == ArrayStringSegment(value=[])
def test_run_none_only_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state):
"""A file list containing only None (e.g., [None]) should be filtered to [] and succeed."""
document_extractor_node.graph_runtime_state = mock_graph_runtime_state
# Use a Mock to bypass type validation for None entries in the list
afs = Mock(spec=ArrayFileSegment)
afs.value = [None]
mock_graph_runtime_state.variable_pool.get.return_value = afs
result = document_extractor_node._run()
assert isinstance(result, NodeRunResult)
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED, result.error
assert result.process_data.get("documents") == []
assert result.outputs["text"] == ArrayStringSegment(value=[])
@pytest.mark.parametrize(
("mime_type", "file_content", "expected_text", "transfer_method", "extension"),
[
@@ -418,58 +385,3 @@ def test_extract_text_from_excel_numeric_type_column(mock_excel_file):
expected_manual = "| 1.0 | 1.1 |\n| --- | --- |\n| Test | Test |\n\n"
assert expected_manual == result
def _make_docx_zip(use_backslash: bool) -> bytes:
"""Helper to build a minimal in-memory DOCX zip.
When use_backslash=True the ZIP entry names use backslash separators
(as produced by Evernote on Windows), otherwise forward slashes are used.
"""
import zipfile
sep = "\\" if use_backslash else "/"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", b"<Types/>")
zf.writestr(f"_rels{sep}.rels", b"<Relationships/>")
zf.writestr(f"word{sep}document.xml", b"<w:document/>")
zf.writestr(f"word{sep}_rels{sep}document.xml.rels", b"<Relationships/>")
return buf.getvalue()
def test_normalize_docx_zip_replaces_backslashes():
"""ZIP entries with backslash separators must be rewritten to forward slashes."""
import zipfile
malformed = _make_docx_zip(use_backslash=True)
fixed = _normalize_docx_zip(malformed)
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
names = zf.namelist()
assert "word/document.xml" in names
assert "word/_rels/document.xml.rels" in names
# No entry should contain a backslash after normalization
assert all("\\" not in name for name in names)
def test_normalize_docx_zip_leaves_forward_slash_unchanged():
"""ZIP entries that already use forward slashes must not be modified."""
import zipfile
normal = _make_docx_zip(use_backslash=False)
fixed = _normalize_docx_zip(normal)
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
names = zf.namelist()
assert "word/document.xml" in names
assert "word/_rels/document.xml.rels" in names
def test_normalize_docx_zip_returns_original_on_bad_zip():
"""Non-zip bytes must be returned as-is without raising."""
garbage = b"not a zip file at all"
result = _normalize_docx_zip(garbage)
assert result == garbage
@@ -1,9 +1,8 @@
"""Unit tests for enterprise service integrations.
This module covers the enterprise-only default workspace auto-join behavior:
- Enterprise mode disabled: no external calls
- Successful join / skipped join: no errors
- Failures (network/invalid response/invalid UUID): soft-fail wrapper must not raise
Covers:
- Default workspace auto-join behavior
- License status caching (get_cached_license_status)
"""
from unittest.mock import patch
@@ -11,6 +10,9 @@ from unittest.mock import patch
import pytest
from services.enterprise.enterprise_service import (
INVALID_LICENSE_CACHE_TTL,
LICENSE_STATUS_CACHE_KEY,
VALID_LICENSE_CACHE_TTL,
DefaultWorkspaceJoinResult,
EnterpriseService,
try_join_default_workspace,
@@ -37,7 +39,6 @@ class TestJoinDefaultWorkspace:
"/default-workspace/members",
json={"account_id": account_id},
timeout=1.0,
raise_for_status=True,
)
def test_join_default_workspace_invalid_response_format_raises(self):
@@ -139,3 +140,134 @@ class TestTryJoinDefaultWorkspace:
# Should not raise even though UUID parsing fails inside join_default_workspace
try_join_default_workspace("not-a-uuid")
# ---------------------------------------------------------------------------
# get_cached_license_status
# ---------------------------------------------------------------------------
_EE_SVC = "services.enterprise.enterprise_service"
class TestGetCachedLicenseStatus:
"""Tests for EnterpriseService.get_cached_license_status."""
def test_returns_none_when_enterprise_disabled(self):
with patch(f"{_EE_SVC}.dify_config") as mock_config:
mock_config.ENTERPRISE_ENABLED = False
assert EnterpriseService.get_cached_license_status() is None
def test_cache_hit_returns_license_status_enum(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = b"active"
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.ACTIVE
assert isinstance(result, LicenseStatus)
mock_get_info.assert_not_called()
def test_cache_miss_fetches_api_and_caches_valid_status(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_get_info.return_value = {"License": {"status": "active"}}
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.ACTIVE
mock_redis.setex.assert_called_once_with(
LICENSE_STATUS_CACHE_KEY, VALID_LICENSE_CACHE_TTL, LicenseStatus.ACTIVE
)
def test_cache_miss_fetches_api_and_caches_invalid_status_with_short_ttl(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_get_info.return_value = {"License": {"status": "expired"}}
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.EXPIRED
mock_redis.setex.assert_called_once_with(
LICENSE_STATUS_CACHE_KEY, INVALID_LICENSE_CACHE_TTL, LicenseStatus.EXPIRED
)
def test_redis_read_failure_falls_through_to_api(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.side_effect = ConnectionError("redis down")
mock_get_info.return_value = {"License": {"status": "active"}}
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.ACTIVE
mock_get_info.assert_called_once()
def test_redis_write_failure_still_returns_status(self):
from services.feature_service import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_redis.setex.side_effect = ConnectionError("redis down")
mock_get_info.return_value = {"License": {"status": "expiring"}}
result = EnterpriseService.get_cached_license_status()
assert result == LicenseStatus.EXPIRING
def test_api_failure_returns_none(self):
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_get_info.side_effect = Exception("network failure")
assert EnterpriseService.get_cached_license_status() is None
def test_api_returns_no_license_info(self):
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
patch(f"{_EE_SVC}.redis_client") as mock_redis,
patch.object(EnterpriseService, "get_info") as mock_get_info,
):
mock_config.ENTERPRISE_ENABLED = True
mock_redis.get.return_value = None
mock_get_info.return_value = {} # no "License" key
assert EnterpriseService.get_cached_license_status() is None
mock_redis.setex.assert_not_called()
@@ -554,9 +554,11 @@ class TestMessagesCleanServiceFromDays:
MessagesCleanService.from_days(policy=policy, days=-1)
# Act
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
fixed_now = datetime.datetime(2024, 6, 15, 14, 0, 0)
mock_now.return_value = fixed_now
mock_datetime.datetime.now.return_value = fixed_now
mock_datetime.timedelta = datetime.timedelta
service = MessagesCleanService.from_days(policy=policy, days=0)
# Assert
@@ -584,9 +586,11 @@ class TestMessagesCleanServiceFromDays:
dry_run = True
# Act
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
fixed_now = datetime.datetime(2024, 6, 15, 10, 30, 0)
mock_now.return_value = fixed_now
mock_datetime.datetime.now.return_value = fixed_now
mock_datetime.timedelta = datetime.timedelta
service = MessagesCleanService.from_days(
policy=policy,
days=days,
@@ -609,9 +613,11 @@ class TestMessagesCleanServiceFromDays:
policy = BillingDisabledPolicy()
# Act
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
fixed_now = datetime.datetime(2024, 6, 15, 10, 30, 0)
mock_now.return_value = fixed_now
mock_datetime.datetime.now.return_value = fixed_now
mock_datetime.timedelta = datetime.timedelta
service = MessagesCleanService.from_days(policy=policy)
# Assert
File diff suppressed because it is too large Load Diff
+16 -14
View File
@@ -6,13 +6,6 @@ from typing import Any
class ConfigHelper:
_LEGACY_SECTION_MAP = {
"admin_config": "admin",
"token_config": "auth",
"app_config": "app",
"api_key_config": "api_key",
}
"""Helper class for reading and writing configuration files."""
def __init__(self, base_dir: Path | None = None):
@@ -57,8 +50,14 @@ class ConfigHelper:
Dictionary containing config data, or None if file doesn't exist
"""
# Provide backward compatibility for old config names
if filename in self._LEGACY_SECTION_MAP:
return self.get_state_section(self._LEGACY_SECTION_MAP[filename])
if filename in ["admin_config", "token_config", "app_config", "api_key_config"]:
section_map = {
"admin_config": "admin",
"token_config": "auth",
"app_config": "app",
"api_key_config": "api_key",
}
return self.get_state_section(section_map[filename])
config_path = self.get_config_path(filename)
@@ -86,11 +85,14 @@ class ConfigHelper:
True if successful, False otherwise
"""
# Provide backward compatibility for old config names
if filename in self._LEGACY_SECTION_MAP:
return self.update_state_section(
self._LEGACY_SECTION_MAP[filename],
data,
)
if filename in ["admin_config", "token_config", "app_config", "api_key_config"]:
section_map = {
"admin_config": "admin",
"token_config": "auth",
"app_config": "app",
"api_key_config": "api_key",
}
return self.update_state_section(section_map[filename], data)
self.ensure_config_dir()
config_path = self.get_config_path(filename)
-6
View File
@@ -2,12 +2,6 @@
- Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions.
## Overlay Components (Mandatory)
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
## Automated Test Generation
- Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests.
@@ -503,7 +503,7 @@ describe('TimePicker', () => {
const emitted = onChange.mock.calls[0][0]
expect(isDayjsObject(emitted)).toBe(true)
// 10:30 UTC converted to America/New_York (UTC-5 in Jan) = 05:30
expect(emitted.utcOffset()).toBe(dayjs.tz('2024-01-01', 'America/New_York').utcOffset())
expect(emitted.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset())
expect(emitted.hour()).toBe(5)
expect(emitted.minute()).toBe(30)
})
@@ -20,7 +20,7 @@ describe('dayjs utilities', () => {
const result = toDayjs('07:15 PM', { timezone: tz })
expect(result).toBeDefined()
expect(result?.format('HH:mm')).toBe('19:15')
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone: tz }).startOf('day').utcOffset())
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone: tz }).utcOffset())
})
it('isDayjsObject detects dayjs instances', () => {
@@ -1,257 +0,0 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '../index'
describe('context-menu wrapper', () => {
describe('ContextMenuContent', () => {
it('should position content at bottom-start with default placement when props are omitted', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
<ContextMenuItem>Content action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
})
it('should apply custom placement when custom positioning props are provided', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
placement="top-end"
sideOffset={12}
alignOffset={-3}
positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }}
>
<ContextMenuItem>Custom content</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
})
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
const handlePositionerMouseEnter = vi.fn()
const handlePopupClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
positionerProps={{
'role': 'group',
'aria-label': 'context content positioner',
'id': 'context-content-positioner',
'onMouseEnter': handlePositionerMouseEnter,
}}
popupProps={{
role: 'menu',
id: 'context-content-popup',
onClick: handlePopupClick,
}}
>
<ContextMenuItem>Passthrough content</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'context content positioner' })
const popup = screen.getByRole('menu')
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
expect(positioner).toHaveAttribute('id', 'context-content-positioner')
expect(popup).toHaveAttribute('id', 'context-content-popup')
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
describe('ContextMenuSubContent', () => {
it('should position sub-content at right-start with default placement when props are omitted', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub open>
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
<ContextMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}>
<ContextMenuItem>Sub action</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'sub positioner' })
expect(positioner).toHaveAttribute('data-side', 'right')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
})
})
describe('destructive prop behavior', () => {
it.each([true, false])('should remain interactive and not leak destructive prop on item when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
destructive={destructive}
aria-label="menu action"
id={`context-item-${String(destructive)}`}
onClick={handleClick}
>
Item label
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const item = screen.getByRole('menuitem', { name: 'menu action' })
fireEvent.click(item)
expect(item).toHaveAttribute('id', `context-item-${String(destructive)}`)
expect(item).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each([true, false])('should remain interactive and not leak destructive prop on submenu trigger when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub open>
<ContextMenuSubTrigger
destructive={destructive}
aria-label="submenu action"
id={`context-sub-${String(destructive)}`}
onClick={handleClick}
>
Trigger item
</ContextMenuSubTrigger>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>,
)
const trigger = screen.getByRole('menuitem', { name: 'submenu action' })
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('id', `context-sub-${String(destructive)}`)
expect(trigger).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each([true, false])('should remain interactive and not leak destructive prop on link item when destructive is %s', (destructive) => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLinkItem
destructive={destructive}
href="https://example.com/docs"
aria-label="context docs link"
id={`context-link-${String(destructive)}`}
target="_blank"
rel="noopener noreferrer"
>
Docs
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'context docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `context-link-${String(destructive)}`)
expect(link).not.toHaveAttribute('destructive')
})
})
describe('ContextMenuLinkItem close behavior', () => {
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLinkItem
href="https://example.com/docs"
closeOnClick={false}
aria-label="docs link"
>
Docs
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
})
})
describe('ContextMenuTrigger interaction', () => {
it('should open menu when right-clicking trigger area', () => {
render(
<ContextMenu>
<ContextMenuTrigger aria-label="context trigger area">
Trigger area
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Open on right click</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const trigger = screen.getByLabelText('context trigger area')
fireEvent.contextMenu(trigger)
expect(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument()
})
})
describe('ContextMenuSeparator', () => {
it('should render separator and keep surrounding rows when separator is between items', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>First action</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>Second action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getAllByRole('separator')).toHaveLength(1)
})
})
})
@@ -1,215 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuCheckboxItemIndicator,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuRadioItemIndicator,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '.'
const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => (
<ContextMenuTrigger
aria-label="context menu trigger area"
render={<button type="button" className="flex h-44 w-80 select-none items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center text-sm text-text-tertiary" />}
>
{label}
</ContextMenuTrigger>
)
const meta = {
title: 'Base/Navigation/ContextMenu',
component: ContextMenu,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound context menu built on Base UI ContextMenu. Open by right-clicking the trigger area.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ContextMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuItem>Edit</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
<ContextMenuItem>Archive</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
}
export const WithSubmenu: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuItem>Copy</ContextMenuItem>
<ContextMenuItem>Paste</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>Share</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Email</ContextMenuItem>
<ContextMenuItem>Slack</ContextMenuItem>
<ContextMenuItem>Copy link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
),
}
export const WithGroupLabel: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuGroupLabel>Actions</ContextMenuGroupLabel>
<ContextMenuItem>Rename</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuGroupLabel>Danger Zone</ContextMenuGroupLabel>
<ContextMenuItem destructive>Delete</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
),
}
const WithRadioItemsDemo = () => {
const [value, setValue] = useState('comfortable')
return (
<ContextMenu>
<TriggerArea label={`Right-click to set density: ${value}`} />
<ContextMenuContent>
<ContextMenuRadioGroup value={value} onValueChange={setValue}>
<ContextMenuRadioItem value="compact">
Compact
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
<ContextMenuRadioItem value="comfortable">
Comfortable
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
<ContextMenuRadioItem value="spacious">
Spacious
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuContent>
</ContextMenu>
)
}
export const WithRadioItems: Story = {
render: () => <WithRadioItemsDemo />,
}
const WithCheckboxItemsDemo = () => {
const [showToolbar, setShowToolbar] = useState(true)
const [showSidebar, setShowSidebar] = useState(false)
const [showStatusBar, setShowStatusBar] = useState(true)
return (
<ContextMenu>
<TriggerArea label="Right-click to configure panel visibility" />
<ContextMenuContent>
<ContextMenuCheckboxItem checked={showToolbar} onCheckedChange={setShowToolbar}>
Toolbar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showSidebar} onCheckedChange={setShowSidebar}>
Sidebar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showStatusBar} onCheckedChange={setShowStatusBar}>
Status bar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
</ContextMenuContent>
</ContextMenu>
)
}
export const WithCheckboxItems: Story = {
render: () => <WithCheckboxItemsDemo />,
}
export const WithLinkItems: Story = {
render: () => (
<ContextMenu>
<TriggerArea label="Right-click to open links" />
<ContextMenuContent>
<ContextMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
Dify Docs
</ContextMenuLinkItem>
<ContextMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
Product Roadmap
</ContextMenuLinkItem>
<ContextMenuSeparator />
<ContextMenuLinkItem destructive href="https://example.com/delete" rel="noopener noreferrer" target="_blank">
Dangerous External Action
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>
),
}
export const Complex: Story = {
render: () => (
<ContextMenu>
<TriggerArea label="Right-click to inspect all menu capabilities" />
<ContextMenuContent>
<ContextMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
Rename
</ContextMenuItem>
<ContextMenuItem>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
Share
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Email</ContextMenuItem>
<ContextMenuItem>Slack</ContextMenuItem>
<ContextMenuItem>Copy Link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem destructive>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
}
@@ -1,302 +0,0 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
import * as React from 'react'
import {
menuBackdropClassName,
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const ContextMenu = BaseContextMenu.Root
export const ContextMenuTrigger = BaseContextMenu.Trigger
export const ContextMenuPortal = BaseContextMenu.Portal
export const ContextMenuBackdrop = BaseContextMenu.Backdrop
export const ContextMenuSub = BaseContextMenu.SubmenuRoot
export const ContextMenuGroup = BaseContextMenu.Group
export const ContextMenuRadioGroup = BaseContextMenu.RadioGroup
type ContextMenuContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>,
'children' | 'className'
>
}
type ContextMenuPopupRenderProps = Required<Pick<ContextMenuContentProps, 'children'>> & {
placement: Placement
sideOffset: number
alignOffset: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
withBackdrop?: boolean
}
function renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
withBackdrop = false,
}: ContextMenuPopupRenderProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseContextMenu.Portal>
{withBackdrop && (
<BaseContextMenu.Backdrop className={menuBackdropClassName} />
)}
<BaseContextMenu.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<BaseContextMenu.Popup
className={cn(
menuPopupBaseClassName,
menuPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
>
{children}
</BaseContextMenu.Popup>
</BaseContextMenu.Positioner>
</BaseContextMenu.Portal>
)
}
export function ContextMenuContent({
children,
placement = 'bottom-start',
sideOffset = 0,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: ContextMenuContentProps) {
return renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
withBackdrop: true,
})
}
type ContextMenuItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.Item> & {
destructive?: boolean
}
export function ContextMenuItem({
className,
destructive,
...props
}: ContextMenuItemProps) {
return (
<BaseContextMenu.Item
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
}
type ContextMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.LinkItem> & {
destructive?: boolean
}
export function ContextMenuLinkItem({
className,
destructive,
closeOnClick = true,
...props
}: ContextMenuLinkItemProps) {
return (
<BaseContextMenu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
)
}
export function ContextMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>) {
return (
<BaseContextMenu.RadioItem
className={cn(menuRowClassName, className)}
{...props}
/>
)
}
export function ContextMenuCheckboxItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>) {
return (
<BaseContextMenu.CheckboxItem
className={cn(menuRowClassName, className)}
{...props}
/>
)
}
type ContextMenuIndicatorProps = Omit<React.ComponentPropsWithoutRef<'span'>, 'children'> & {
children?: React.ReactNode
}
export function ContextMenuItemIndicator({
className,
children,
...props
}: ContextMenuIndicatorProps) {
return (
<span
aria-hidden
className={cn(menuIndicatorClassName, className)}
{...props}
>
{children ?? <span aria-hidden className="i-ri-check-line h-4 w-4" />}
</span>
)
}
export function ContextMenuCheckboxItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItemIndicator>, 'children'>) {
return (
<BaseContextMenu.CheckboxItemIndicator
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</BaseContextMenu.CheckboxItemIndicator>
)
}
export function ContextMenuRadioItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItemIndicator>, 'children'>) {
return (
<BaseContextMenu.RadioItemIndicator
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</BaseContextMenu.RadioItemIndicator>
)
}
type ContextMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger> & {
destructive?: boolean
}
export function ContextMenuSubTrigger({
className,
destructive,
children,
...props
}: ContextMenuSubTriggerProps) {
return (
<BaseContextMenu.SubmenuTrigger
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-4 shrink-0 text-text-tertiary" />
</BaseContextMenu.SubmenuTrigger>
)
}
type ContextMenuSubContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
}
export function ContextMenuSubContent({
children,
placement = 'right-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: ContextMenuSubContentProps) {
return renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
export function ContextMenuGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>) {
return (
<BaseContextMenu.GroupLabel
className={cn(menuGroupLabelClassName, className)}
{...props}
/>
)
}
export function ContextMenuSeparator({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>) {
return (
<BaseContextMenu.Separator
className={cn(menuSeparatorClassName, className)}
{...props}
/>
)
}
@@ -1,12 +1,13 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { Menu } from '@base-ui/react/menu'
import { fireEvent, render, screen, within } from '@testing-library/react'
import Link from 'next/link'
import { describe, expect, it, vi } from 'vitest'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
@@ -14,22 +15,18 @@ import {
DropdownMenuTrigger,
} from '../index'
vi.mock('next/link', () => ({
default: ({
href,
children,
...props
}: {
href: string
children?: ReactNode
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
<a href={href} {...props}>
{children}
</a>
),
}))
describe('dropdown-menu wrapper', () => {
describe('alias exports', () => {
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
expect(DropdownMenu).toBe(Menu.Root)
expect(DropdownMenuPortal).toBe(Menu.Portal)
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
expect(DropdownMenuGroup).toBe(Menu.Group)
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
})
})
describe('DropdownMenuContent', () => {
it('should position content at bottom-end with default placement when props are omitted', () => {
render(
@@ -253,99 +250,6 @@ describe('dropdown-menu wrapper', () => {
})
})
describe('DropdownMenuLinkItem', () => {
it('should render as anchor and keep href/target attributes when link props are provided', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem href="https://example.com/docs" target="_blank" rel="noopener noreferrer">
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'Docs' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
href="https://example.com/docs"
closeOnClick={false}
aria-label="docs link"
>
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
})
it('should preserve link semantics when render prop uses a custom link component', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
render={<Link href="/account" />}
aria-label="account link"
>
Account settings
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'account link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', '/account')
expect(link).toHaveTextContent('Account settings')
})
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
destructive={destructive}
href="https://example.com/docs"
aria-label="docs link"
id={`menu-link-${String(destructive)}`}
onClick={handleClick}
>
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
fireEvent.click(link)
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `menu-link-${String(destructive)}`)
expect(link).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuSeparator', () => {
it('should forward passthrough props and handlers when separator props are provided', () => {
const handleMouseEnter = vi.fn()
@@ -8,7 +8,6 @@ import {
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
@@ -235,22 +234,6 @@ export const WithIcons: Story = {
),
}
export const WithLinkItems: Story = {
render: () => (
<DropdownMenu>
<TriggerButton label="Open links" />
<DropdownMenuContent>
<DropdownMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
Dify Docs
</DropdownMenuLinkItem>
<DropdownMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
Product Roadmap
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
const ComplexDemo = () => {
const [sortOrder, setSortOrder] = useState('newest')
const [showArchived, setShowArchived] = useState(false)
@@ -3,14 +3,6 @@
import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu'
import * as React from 'react'
import {
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
@@ -21,13 +13,20 @@ export const DropdownMenuSub = Menu.SubmenuRoot
export const DropdownMenuGroup = Menu.Group
export const DropdownMenuRadioGroup = Menu.RadioGroup
const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none'
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
export function DropdownMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
className={cn(menuRowClassName, className)}
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props}
/>
)
@@ -39,7 +38,10 @@ export function DropdownMenuRadioItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return (
<Menu.RadioItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@@ -53,7 +55,11 @@ export function DropdownMenuCheckboxItem({
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return (
<Menu.CheckboxItem
className={cn(menuRowClassName, className)}
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props}
/>
)
@@ -65,7 +71,10 @@ export function DropdownMenuCheckboxItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return (
<Menu.CheckboxItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@@ -79,7 +88,10 @@ export function DropdownMenuGroupLabel({
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(menuGroupLabelClassName, className)}
className={cn(
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase',
className,
)}
{...props}
/>
)
@@ -136,8 +148,8 @@ function renderDropdownMenuPopup({
>
<Menu.Popup
className={cn(
menuPopupBaseClassName,
menuPopupAnimationClassName,
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg backdrop-blur-[5px]',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
@@ -183,7 +195,12 @@ export function DropdownMenuSubTrigger({
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
{...props}
>
{children}
@@ -236,26 +253,12 @@ export function DropdownMenuItem({
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
}
type DropdownMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof Menu.LinkItem> & {
destructive?: boolean
}
export function DropdownMenuLinkItem({
className,
destructive,
closeOnClick = true,
...props
}: DropdownMenuLinkItemProps) {
return (
<Menu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
{...props}
/>
)
@@ -267,7 +270,7 @@ export function DropdownMenuSeparator({
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return (
<Menu.Separator
className={cn(menuSeparatorClassName, className)}
className={cn('my-1 h-px bg-divider-subtle', className)}
{...props}
/>
)
@@ -1,7 +0,0 @@
export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle'
export const menuPopupBaseClassName = 'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-none focus:outline-none focus-visible:outline-none backdrop-blur-[5px]'
export const menuPopupAnimationClassName = 'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'
export const menuBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'
@@ -225,97 +225,5 @@ describe('Compliance', () => {
payload: ACCOUNT_SETTING_TAB.BILLING,
})
})
// isPending branches: spinner visible, disabled class, guard blocks second call
it('should show spinner and guard against duplicate download when isPending is true', async () => {
// Arrange
let resolveDownload: (value: { url: string }) => void
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
resolveDownload = resolve
}))
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.team,
},
})
// Act
openMenuAndRender()
const downloadButtons = screen.getAllByText('common.operation.download')
fireEvent.click(downloadButtons[0])
// Assert - btn-disabled class and spinner should appear while mutation is pending
await waitFor(() => {
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
expect(menuItem).not.toBeNull()
const disabledBtn = menuItem!.querySelector('.cursor-not-allowed')
expect(disabledBtn).not.toBeNull()
}, { timeout: 10000 })
// Cleanup: resolve the pending promise
resolveDownload!({ url: 'http://example.com/doc.pdf' })
await waitFor(() => {
expect(downloadUrl).toHaveBeenCalled()
})
})
it('should not call downloadCompliance again while pending', async () => {
let resolveDownload: (value: { url: string }) => void
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
resolveDownload = resolve
}))
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.team,
},
})
openMenuAndRender()
const downloadButtons = screen.getAllByText('common.operation.download')
// First click starts download
fireEvent.click(downloadButtons[0])
// Wait for mutation to start and React to re-render (isPending=true)
await waitFor(() => {
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
const el = menuItem!.querySelector('.cursor-not-allowed')
expect(el).not.toBeNull()
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
}, { timeout: 10000 })
// Second click while pending - should be guarded by isPending check
fireEvent.click(downloadButtons[0])
resolveDownload!({ url: 'http://example.com/doc.pdf' })
await waitFor(() => {
expect(downloadUrl).toHaveBeenCalledTimes(1)
}, { timeout: 10000 })
// getDocDownloadUrl should still have only been called once
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
}, 20000)
// canShowUpgradeTooltip=false: enterprise plan has empty tooltip text → no TooltipContent
it('should show upgrade badge with empty tooltip for enterprise plan', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.enterprise,
},
})
// Act
openMenuAndRender()
// Assert - enterprise is not in any download list, so upgrade badges should appear
// The key branch: upgradeTooltip[Plan.enterprise] = '' → canShowUpgradeTooltip=false
expect(screen.getAllByText('billing.upgradeBtn.encourageShort').length).toBeGreaterThan(0)
})
})
})
@@ -184,7 +184,7 @@ export default function Compliance() {
<DropdownMenuSubContent
popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="py-1">
<DropdownMenuGroup className="p-1">
<ComplianceDocRowItem
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
label={t('compliance.soc2Type1', { ns: 'common' })}
@@ -247,23 +247,6 @@ describe('AccountDropdown', () => {
// Assert
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
})
// Compound AND middle-false: IS_CLOUD_EDITION=true but isCurrentWorkspaceOwner=false
it('should hide Compliance in Cloud Edition when user is not workspace owner', () => {
// Arrange
mockConfig.IS_CLOUD_EDITION = true
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
isCurrentWorkspaceOwner: false,
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.queryByText('common.userProfile.compliance')).not.toBeInTheDocument()
})
})
describe('Actions', () => {
@@ -9,7 +9,7 @@ import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
@@ -41,12 +41,12 @@ function AccountMenuRouteItem({
trailing,
}: AccountMenuRouteItemProps) {
return (
<DropdownMenuLinkItem
<DropdownMenuItem
className="justify-between"
render={<Link href={href} />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuLinkItem>
</DropdownMenuItem>
)
}
@@ -64,14 +64,12 @@ function AccountMenuExternalItem({
trailing,
}: AccountMenuExternalItemProps) {
return (
<DropdownMenuLinkItem
<DropdownMenuItem
className="justify-between"
href={href}
rel="noopener noreferrer"
target="_blank"
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuLinkItem>
</DropdownMenuItem>
)
}
@@ -103,7 +101,7 @@ type AccountMenuSectionProps = {
}
function AccountMenuSection({ children }: AccountMenuSectionProps) {
return <DropdownMenuGroup className="py-1">{children}</DropdownMenuGroup>
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
}
export default function AppSelector() {
@@ -146,8 +144,8 @@ export default function AppSelector() {
sideOffset={6}
popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="py-1">
<div className="mx-1 flex flex-nowrap items-center py-2 pl-3 pr-2">
<DropdownMenuGroup className="px-1 py-1">
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
<div className="grow">
<div className="break-all text-text-primary system-md-medium">
{userProfile.name}
@@ -36,8 +36,8 @@ vi.mock('@/config', async (importOriginal) => {
return {
...actual,
IS_CE_EDITION: false,
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value || '' },
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value || '' },
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value },
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value },
}
})
@@ -173,18 +173,25 @@ describe('Support', () => {
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
})
// Optional chain null guard: ZENDESK_WIDGET_KEY is null
it('should show Email Support when ZENDESK_WIDGET_KEY is null', () => {
it('should show email support if specified in the config', () => {
// Arrange
mockZendeskKey.value = null as unknown as string
mockZendeskKey.value = ''
mockSupportEmailKey.value = 'support@example.com'
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
},
})
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
expect(screen.queryByText('common.userProfile.emailSupport')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.emailSupport')?.closest('a')?.getAttribute('href')?.startsWith(`mailto:${mockSupportEmailKey.value}`)).toBe(true)
})
})
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type'
import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'
@@ -31,7 +31,7 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
<DropdownMenuSubContent
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="py-1">
<DropdownMenuGroup className="p-1">
{hasDedicatedChannel && hasZendeskWidget && (
<DropdownMenuItem
className="justify-between"
@@ -47,43 +47,37 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
</DropdownMenuItem>
)}
{hasDedicatedChannel && !hasZendeskWidget && (
<DropdownMenuLinkItem
<DropdownMenuItem
className="justify-between"
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)}
rel="noopener noreferrer"
target="_blank"
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)} rel="noopener noreferrer" target="_blank" />}
>
<MenuItemContent
iconClassName="i-ri-mail-send-line"
label={t('userProfile.emailSupport', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuLinkItem>
</DropdownMenuItem>
)}
<DropdownMenuLinkItem
<DropdownMenuItem
className="justify-between"
href="https://forum.dify.ai/"
rel="noopener noreferrer"
target="_blank"
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
>
<MenuItemContent
iconClassName="i-ri-discuss-line"
label={t('userProfile.forum', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuLinkItem>
<DropdownMenuLinkItem
</DropdownMenuItem>
<DropdownMenuItem
className="justify-between"
href="https://discord.gg/5AEfbxcd9k"
rel="noopener noreferrer"
target="_blank"
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
>
<MenuItemContent
iconClassName="i-ri-discord-line"
label={t('userProfile.community', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuLinkItem>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -136,32 +136,4 @@ describe('WorkplaceSelector', () => {
})
})
})
describe('Edge Cases', () => {
// find() returns undefined: no workspace with current: true
it('should not crash when no workspace has current: true', () => {
// Arrange
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
// name[0]?.toLocaleUpperCase() undefined: workspace with empty name
it('should not crash when workspace name is empty string', () => {
// Arrange
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
})
})
@@ -388,33 +388,37 @@ describe('DataSourceNotion Component', () => {
})
describe('Additional Action Edge Cases', () => {
it.each([
undefined,
null,
{},
{ data: undefined },
{ data: null },
{ data: '' },
{ data: 0 },
{ data: false },
{ data: 'http' },
{ data: 'internal' },
{ data: 'unknown' },
])('should cover connection data branch: %s', async (val) => {
it('should cover all possible falsy/nullish branches for connection data in handleAuthAgain and useEffect', async () => {
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
render(<DataSourceNotion />)
// Trigger handleAuthAgain with these values
const workspaceItem = getWorkspaceItem('Workspace 1')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
fireEvent.click(authAgainBtn)
const connectionCases = [
undefined,
null,
{},
{ data: undefined },
{ data: null },
{ data: '' },
{ data: 0 },
{ data: false },
{ data: 'http' },
{ data: 'internal' },
{ data: 'unknown' },
]
expect(useNotionConnection).toHaveBeenCalled()
for (const val of connectionCases) {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
// Trigger handleAuthAgain with these values
const workspaceItem = getWorkspaceItem('Workspace 1')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
fireEvent.click(authAgainBtn)
}
await waitFor(() => expect(useNotionConnection).toHaveBeenCalled())
})
})
@@ -134,46 +134,5 @@ describe('ConfigJinaReaderModal Component', () => {
resolveSave!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
})
it('should show encryption info and external link in the modal', async () => {
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Verify PKCS1_OAEP link exists
const pkcsLink = screen.getByText('PKCS1_OAEP')
expect(pkcsLink.closest('a')).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
// Verify the Jina Reader external link
const jinaLink = screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })
expect(jinaLink).toHaveAttribute('target', '_blank')
})
it('should return early when save is clicked while already saving (isSaving guard)', async () => {
const user = userEvent.setup()
// Arrange - a save that never resolves so isSaving stays true
let resolveFirst: (value: { result: 'success' }) => void
const neverResolves = new Promise<{ result: 'success' }>((resolve) => {
resolveFirst = resolve
})
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(neverResolves)
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
await user.type(apiKeyInput, 'valid-key')
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
// First click - starts saving, isSaving becomes true
await user.click(saveBtn)
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Second click using fireEvent bypasses disabled check - hits isSaving guard
const { fireEvent: fe } = await import('@testing-library/react')
fe.click(saveBtn)
// Still only called once because isSaving=true returns early
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Cleanup
resolveFirst!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalled())
})
})
})
@@ -195,57 +195,4 @@ describe('DataSourceWebsite Component', () => {
expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
describe('Firecrawl Save Flow', () => {
it('should re-fetch sources after saving Firecrawl configuration', async () => {
// Arrange
await renderAndWait(DataSourceProvider.fireCrawl)
fireEvent.click(screen.getByText('common.dataSource.configure'))
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
vi.mocked(fetchDataSources).mockClear()
// Act - fill in required API key field and save
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
fireEvent.change(apiKeyInput, { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(fetchDataSources).toHaveBeenCalled()
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
})
})
})
describe('Cancel Flow', () => {
it('should close watercrawl modal when cancel is clicked', async () => {
// Arrange
await renderAndWait(DataSourceProvider.waterCrawl)
fireEvent.click(screen.getByText('common.dataSource.configure'))
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert - modal closed
await waitFor(() => {
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
})
})
it('should close jina reader modal when cancel is clicked', async () => {
// Arrange
await renderAndWait(DataSourceProvider.jinaReader)
fireEvent.click(screen.getByText('common.dataSource.configure'))
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert - modal closed
await waitFor(() => {
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
})
})
})
})
@@ -1,9 +1,8 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Operate from './Operate'
describe('Operate', () => {
it('should render cancel and save when editing is open', () => {
it('renders cancel and save when editing', () => {
render(
<Operate
isOpen
@@ -19,7 +18,7 @@ describe('Operate', () => {
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
})
it('should show add-key prompt when closed', () => {
it('shows add key prompt when closed', () => {
render(
<Operate
isOpen={false}
@@ -34,7 +33,7 @@ describe('Operate', () => {
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
})
it('should show invalid state and edit prompt when status is fail', () => {
it('shows invalid state indicator and edit prompt when status is fail', () => {
render(
<Operate
isOpen={false}
@@ -50,7 +49,7 @@ describe('Operate', () => {
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
})
it('should show edit prompt without error text when status is success', () => {
it('shows edit prompt without error text when status is success', () => {
render(
<Operate
isOpen={false}
@@ -66,30 +65,11 @@ describe('Operate', () => {
expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull()
})
it('should not call onAdd when disabled', async () => {
const user = userEvent.setup()
const onAdd = vi.fn()
it('shows no actions for unsupported status', () => {
render(
<Operate
isOpen={false}
status="add"
disabled
onAdd={onAdd}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
await user.click(screen.getByText('common.provider.addKey'))
expect(onAdd).not.toHaveBeenCalled()
})
it('should show no actions when status is unsupported', () => {
render(
<Operate
isOpen={false}
// @ts-expect-error intentional invalid status for runtime fallback coverage
status="unknown"
status={'unknown' as never}
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
@@ -267,99 +267,6 @@ describe('MembersPage', () => {
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
})
it('should show non-billing member format for team plan even when billing is enabled', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
plan: {
type: Plan.team,
total: { teamMembers: 50 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
} as unknown as ReturnType<typeof useProviderContext>['plan'],
}))
render(<MembersPage />)
// Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
})
it('should show invite button when user is manager but not owner', () => {
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: true,
} as unknown as AppContextValue)
render(<MembersPage />)
expect(screen.getByRole('button', { name: /invite/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
})
it('should use created_at as fallback when last_active_at is empty', () => {
const memberNoLastActive: Member = {
...mockAccounts[1],
last_active_at: '',
created_at: '1700000000',
}
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [memberNoLastActive] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1700000000000)
})
it('should not show plural s when only one account in billing layout', () => {
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [mockAccounts[0]] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
plan: {
type: Plan.sandbox,
total: { teamMembers: 5 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
} as unknown as ReturnType<typeof useProviderContext>['plan'],
}))
render(<MembersPage />)
expect(screen.getByText(/plansCommon\.member/i)).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should not show plural s when only one account in non-billing layout', () => {
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [mockAccounts[0]] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should show normal role as fallback for unknown role', () => {
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: false,
} as unknown as AppContextValue)
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [{ ...mockAccounts[1], role: 'unknown_role' as Member['role'] }] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('should show upgrade button when member limit is full', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
@@ -1,5 +1,5 @@
import type { InvitationResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
@@ -171,66 +171,6 @@ describe('InviteModal', () => {
expect(screen.queryByText('user@example.com')).not.toBeInTheDocument()
})
it('should show unlimited label when workspace member limit is zero', async () => {
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 5, limit: 0 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
expect(await screen.findByText(/license\.unlimited/i)).toBeInTheDocument()
})
it('should initialize usedSize to zero when workspace_members.size is null', async () => {
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: null, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
// usedSize starts at 0 (via ?? 0 fallback), no emails added → counter shows 0
expect(await screen.findByText('0')).toBeInTheDocument()
})
it('should not call onSend when invite result is not success', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockResolvedValue({
result: 'error',
invitation_results: [],
} as unknown as InvitationResponse)
renderModal()
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockOnSend).not.toHaveBeenCalled()
expect(mockOnCancel).not.toHaveBeenCalled()
})
})
it('should show destructive text color when used size exceeds limit', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
const counter = screen.getByText('11')
expect(counter.closest('div')).toHaveClass('text-text-destructive')
})
it('should not submit if already submitting', async () => {
const user = userEvent.setup()
let resolveInvite: (value: InvitationResponse) => void
@@ -262,72 +202,4 @@ describe('InviteModal', () => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
const user = userEvent.setup()
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
// isLimitExceeded=true → button is disabled, cannot submit
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
expect(sendBtn).toBeDisabled()
expect(inviteMember).not.toHaveBeenCalled()
})
it('should hit isSubmitting guard inside handleSend when button is force-clicked during submission', async () => {
const user = userEvent.setup()
let resolveInvite: (value: InvitationResponse) => void
const invitePromise = new Promise<InvitationResponse>((resolve) => {
resolveInvite = resolve
})
vi.mocked(inviteMember).mockReturnValue(invitePromise)
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
// First click starts submission
await user.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Force-click bypasses disabled attribute → hits isSubmitting guard in handleSend
fireEvent.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Cleanup
resolveInvite!({ result: 'success', invitation_results: [] })
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
it('should not show error text color when isLimited is false even with many emails', async () => {
// size=0, limit=0 → isLimited=false, usedSize=emails.length
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 0, limit: 0 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
// isLimited=false → no destructive color
const counter = screen.getByText('1')
expect(counter.closest('div')).not.toHaveClass('text-text-destructive')
})
})
@@ -2,12 +2,8 @@ import type { InvitationResult } from '@/models/common'
import { render, screen } from '@testing-library/react'
import InvitedModal from './index'
const mockConfigState = vi.hoisted(() => ({ isCeEdition: true }))
vi.mock('@/config', () => ({
get IS_CE_EDITION() {
return mockConfigState.isCeEdition
},
IS_CE_EDITION: true,
}))
describe('InvitedModal', () => {
@@ -17,11 +13,6 @@ describe('InvitedModal', () => {
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
]
beforeEach(() => {
vi.clearAllMocks()
mockConfigState.isCeEdition = true
})
it('should show success and failed invitation sections', async () => {
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
@@ -30,59 +21,4 @@ describe('InvitedModal', () => {
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
})
it('should hide invitation link section when there are no successes', () => {
const failedOnly: InvitationResult[] = [
{ email: 'fail@example.com', status: 'failed', message: 'Quota exceeded' },
]
render(<InvitedModal invitationResults={failedOnly} onCancel={mockOnCancel} />)
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
expect(screen.getByText(/members\.failedInvitationEmails/i)).toBeInTheDocument()
})
it('should hide failed section when there are only successes', () => {
const successOnly: InvitationResult[] = [
{ email: 'ok@example.com', status: 'success', url: 'http://invite.com/2' },
]
render(<InvitedModal invitationResults={successOnly} onCancel={mockOnCancel} />)
expect(screen.getByText(/members\.invitationLink/i)).toBeInTheDocument()
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
})
it('should hide both sections when results are empty', () => {
render(<InvitedModal invitationResults={[]} onCancel={mockOnCancel} />)
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
})
})
describe('InvitedModal (non-CE edition)', () => {
const mockOnCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockConfigState.isCeEdition = false
})
afterEach(() => {
mockConfigState.isCeEdition = true
})
it('should render invitationSentTip without CE edition content when IS_CE_EDITION is false', async () => {
const results: InvitationResult[] = [
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
]
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
// The !IS_CE_EDITION branch - should show the tip text
expect(await screen.findByText(/members\.invitationSentTip/i)).toBeInTheDocument()
// CE-only content should not be shown
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
})
})
@@ -49,13 +49,13 @@ describe('Operation', () => {
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false })
})
it('should render the current role label when member has editor role', () => {
it('renders the current role label', () => {
renderOperation()
expect(screen.getByText('common.members.editor')).toBeInTheDocument()
})
it('should show dataset operator option when feature flag is enabled', async () => {
it('shows dataset operator option when the feature flag is enabled', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
@@ -66,7 +66,7 @@ describe('Operation', () => {
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
})
it('should show owner-allowed role options when operator role is admin', async () => {
it('shows owner-allowed role options for admin operators', async () => {
const user = userEvent.setup()
renderOperation({}, 'admin')
@@ -77,7 +77,7 @@ describe('Operation', () => {
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('should not show role options when operator role is unsupported', async () => {
it('does not show role options for unsupported operators', async () => {
const user = userEvent.setup()
renderOperation({}, 'normal')
@@ -88,7 +88,7 @@ describe('Operation', () => {
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
})
it('should call updateMemberRole and onOperate when selecting another role', async () => {
it('calls updateMemberRole and onOperate when selecting another role', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
@@ -102,24 +102,7 @@ describe('Operation', () => {
})
})
it('should show dataset operator option when operator is admin and feature flag is enabled', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
renderOperation({}, 'admin')
await user.click(screen.getByText('common.members.editor'))
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
})
it('should fall back to normal role label when member role is unknown', () => {
renderOperation({ role: 'unknown_role' as Member['role'] })
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('should call deleteMemberOrCancelInvitation when removing the member', async () => {
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
@@ -13,6 +13,11 @@ vi.mock('@/context/app-context')
vi.mock('@/service/common')
vi.mock('@/service/use-common')
// Mock Modal directly to avoid transition/portal issues in tests
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => isShow ? <div data-testid="mock-modal">{children}</div> : null,
}))
vi.mock('./member-selector', () => ({
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
@@ -35,13 +40,11 @@ describe('TransferOwnershipModal', () => {
data: { accounts: [] },
} as unknown as ReturnType<typeof useMembers>)
// Stub globalThis.location.reload (component calls globalThis.location.reload())
// Fix Location stubbing for reload
const mockReload = vi.fn()
vi.stubGlobal('location', {
...window.location,
reload: mockReload,
href: '',
assign: vi.fn(),
replace: vi.fn(),
} as unknown as Location)
})
@@ -102,8 +105,8 @@ describe('TransferOwnershipModal', () => {
await waitFor(() => {
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
expect(window.location.reload).toHaveBeenCalled()
}, { timeout: 10000 })
}, 15000)
})
})
it('should handle timer countdown and resend', async () => {
vi.useFakeTimers()
@@ -199,70 +202,6 @@ describe('TransferOwnershipModal', () => {
})
})
it('should handle sendOwnerEmail returning null data', async () => {
const user = userEvent.setup()
vi.mocked(sendOwnerEmail).mockResolvedValue({
data: null,
result: 'success',
} as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>)
renderModal()
await user.click(screen.getByTestId('transfer-modal-send-code'))
// Should advance to verify step even with null data
await waitFor(() => {
expect(screen.getByText(/members\.transferModal\.verifyEmail/i)).toBeInTheDocument()
})
})
it('should show fallback error prefix when sendOwnerEmail throws null', async () => {
const user = userEvent.setup()
vi.mocked(sendOwnerEmail).mockRejectedValue(null)
renderModal()
await user.click(screen.getByTestId('transfer-modal-send-code'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('Error sending verification code:'),
}))
})
})
it('should show fallback error prefix when verifyOwnerEmail throws null', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(verifyOwnerEmail).mockRejectedValue(null)
renderModal()
await goToTransferStep(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('Error verifying email:'),
}))
})
})
it('should show fallback error prefix when ownershipTransfer throws null', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(ownershipTransfer).mockRejectedValue(null)
renderModal()
await goToTransferStep(user)
await selectNewOwnerAndSubmit(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('Error ownership transfer:'),
}))
})
})
it('should close when close button is clicked', async () => {
const user = userEvent.setup()
renderModal()
@@ -71,80 +71,9 @@ describe('MemberSelector', () => {
})
})
it('should filter list by email when name does not match', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'john@')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument()
})
it('should show placeholder when value does not match any account', () => {
render(<MemberSelector value="nonexistent-id" onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
})
it('should handle missing data gracefully', () => {
vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
})
it('should filter by email when account name is empty', async () => {
const user = userEvent.setup()
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [...mockAccounts, { id: '4', name: '', email: 'noname@example.com', avatar_url: '' }] },
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'noname@')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
})
it('should apply hover background class when dropdown is open', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
const trigger = screen.getByTestId('member-selector-trigger')
await user.click(trigger)
expect(trigger).toHaveClass('bg-state-base-hover-alt')
})
it('should not match account when neither name nor email contains search value', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'xyz-no-match-xyz')
expect(screen.queryByTestId('member-selector-item')).not.toBeInTheDocument()
})
it('should fall back to empty string for account with undefined email when searching', async () => {
const user = userEvent.setup()
vi.mocked(useMembers).mockReturnValue({
data: {
accounts: [
{ id: '1', name: 'John', email: undefined as unknown as string, avatar_url: '' },
],
},
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'john')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
})
})
@@ -433,55 +433,6 @@ describe('hooks', () => {
expect(result.current.credentials).toBeUndefined()
})
it('should not call invalidateQueries when neither predefined nor custom is enabled', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useQuery as Mock).mockReturnValue({
data: undefined,
isPending: false,
})
// Both predefinedEnabled and customEnabled are false (no credentialId)
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
false,
undefined,
undefined,
))
act(() => {
result.current.mutate()
})
expect(invalidateQueries).not.toHaveBeenCalled()
})
it('should build URL without credentialId when not provided in predefined queryFn', async () => {
// Trigger the queryFn when credentialId is undefined but predefinedEnabled is true
; (useQuery as Mock).mockReturnValue({
data: { credentials: { api_key: 'k' } },
isPending: false,
})
const { result: _result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
true,
undefined,
undefined,
))
// Find and invoke the predefined queryFn
const queryCall = (useQuery as Mock).mock.calls.find(
call => call[0].queryKey?.[1] === 'credentials',
)
if (queryCall) {
await queryCall[0].queryFn()
expect(fetchModelProviderCredentials).toHaveBeenCalled()
}
})
})
describe('useModelList', () => {
@@ -1160,26 +1111,6 @@ describe('hooks', () => {
expect(result.current.plugins![0].plugin_id).toBe('plugin1')
})
it('should deduplicate plugins that exist in both collections and regular plugins', () => {
const duplicatePlugin = { plugin_id: 'shared-plugin', type: 'plugin' }
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [duplicatePlugin],
isLoading: false,
})
; (useMarketplacePlugins as Mock).mockReturnValue({
plugins: [{ ...duplicatePlugin }, { plugin_id: 'unique-plugin', type: 'plugin' }],
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
expect(result.current.plugins).toHaveLength(2)
expect(result.current.plugins!.filter(p => p.plugin_id === 'shared-plugin')).toHaveLength(1)
})
it('should handle loading states', () => {
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [],
@@ -1196,45 +1127,6 @@ describe('hooks', () => {
expect(result.current.isLoading).toBe(true)
})
it('should not crash when plugins is undefined', () => {
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [],
isLoading: false,
})
; (useMarketplacePlugins as Mock).mockReturnValue({
plugins: undefined,
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
expect(result.current.plugins).toBeDefined()
expect(result.current.isLoading).toBe(false)
})
it('should return search plugins (not allPlugins) when searchText is truthy', () => {
const searchPlugins = [{ plugin_id: 'search-result', type: 'plugin' }]
const collectionPlugins = [{ plugin_id: 'collection-only', type: 'plugin' }]
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: collectionPlugins,
isLoading: false,
})
; (useMarketplacePlugins as Mock).mockReturnValue({
plugins: searchPlugins,
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], 'openai'))
expect(result.current.plugins).toEqual(searchPlugins)
expect(result.current.plugins?.some(p => p.plugin_id === 'collection-only')).toBe(false)
})
})
describe('useRefreshModel', () => {
@@ -1342,35 +1234,6 @@ describe('hooks', () => {
expect(emit).not.toHaveBeenCalled()
})
it('should emit event and invalidate all supported model types when __model_type is undefined', () => {
const invalidateQueries = vi.fn()
const emit = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit },
})
const provider = createMockProvider()
const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields
const { result } = renderHook(() => useRefreshModel())
act(() => {
result.current.handleRefreshModel(provider, customFields, true)
})
expect(emit).toHaveBeenCalledWith({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: 'openai',
})
// When __model_type is undefined, all supported model types are invalidated
const modelListCalls = invalidateQueries.mock.calls.filter(
call => call[0]?.queryKey?.[0] === 'model-list',
)
expect(modelListCalls).toHaveLength(provider.supported_model_types.length)
})
it('should handle provider with single model type', () => {
const invalidateQueries = vi.fn()
@@ -60,15 +60,7 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
type MockDefaultModelData = {
model: string
provider?: { provider: string }
} | null
const mockDefaultModelState: {
data: MockDefaultModelData
isLoading: boolean
} = {
const mockDefaultModelState = {
data: null,
isLoading: false,
}
@@ -204,129 +196,4 @@ describe('ModelProviderPage', () => {
])
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
})
it('should show not configured alert when all default models are absent', () => {
mockDefaultModelState.data = null
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
})
it('should not show not configured alert when default model is loading', () => {
mockDefaultModelState.data = null
mockDefaultModelState.isLoading = true
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should filter providers by label text', () => {
render(<ModelProviderPage searchText="OpenAI" />)
act(() => {
vi.advanceTimersByTime(600)
})
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
})
it('should classify system-enabled providers with matching quota as configured', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'sys-provider',
label: { en_US: 'System Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('sys-provider')).toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
})
it('should classify system-enabled provider with no matching quota as not configured', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'sys-no-quota',
label: { en_US: 'System No Quota' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [],
},
})
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('sys-no-quota')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
})
it('should preserve order of two non-fixed providers (sort returns 0)', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'alpha-provider',
label: { en_US: 'Alpha Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'beta-provider',
label: { en_US: 'Beta Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
render(<ModelProviderPage searchText="" />)
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
expect(renderedProviders).toEqual(['alpha-provider', 'beta-provider'])
})
it('should not show not configured alert when shared default model mock has data', () => {
mockDefaultModelState.data = { model: 'embed-model' }
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show not configured alert when rerankDefaultModel has data', () => {
mockDefaultModelState.data = { model: 'rerank-model', provider: { provider: 'cohere' } }
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show not configured alert when ttsDefaultModel has data', () => {
mockDefaultModelState.data = { model: 'tts-model', provider: { provider: 'openai' } }
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show not configured alert when speech2textDefaultModel has data', () => {
mockDefaultModelState.data = { model: 'whisper', provider: { provider: 'openai' } }
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
})
@@ -96,97 +96,4 @@ describe('AddCredentialInLoadBalancing', () => {
expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0])
})
// renderTrigger with open=true: bg-state-base-hover style applied
it('should apply hover background when trigger is rendered with open=true', async () => {
vi.doMock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
Authorized: ({
renderTrigger,
}: {
renderTrigger: (open?: boolean) => React.ReactNode
}) => (
<div data-testid="open-trigger">{renderTrigger(true)}</div>
),
}))
// Must invalidate module cache so the component picks up the new mock
vi.resetModules()
try {
const { default: AddCredentialLB } = await import('./add-credential-in-load-balancing')
const { container } = render(
<AddCredentialLB
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
// The trigger div rendered by renderTrigger(true) should have bg-state-base-hover
// (the static class applied when open=true via cn())
const triggerDiv = container.querySelector('[data-testid="open-trigger"] > div')
expect(triggerDiv).toBeInTheDocument()
expect(triggerDiv!.className).toContain('bg-state-base-hover')
}
finally {
vi.doUnmock('@/app/components/header/account-setting/model-provider-page/model-auth')
vi.resetModules()
}
})
// customizableModel configuration method: component renders the add credential label
it('should render correctly with customizableModel configuration method', () => {
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.customizableModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
})
it('should handle undefined available_credentials gracefully using nullish coalescing', () => {
const credentialWithNoAvailable = {
available_credentials: undefined,
credentials: {},
load_balancing: { enabled: false, configs: [] },
} as unknown as typeof modelCredential
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={credentialWithNoAvailable}
onSelectCredential={vi.fn()}
/>,
)
// Component should render without error - the ?? [] fallback is used
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
})
it('should not throw when update action fires without onUpdate prop', () => {
// Arrange - no onUpdate prop
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
// Act - trigger the update without onUpdate being set (should not throw)
expect(() => {
fireEvent.click(screen.getByRole('button', { name: 'Run update' }))
}).not.toThrow()
})
})
@@ -85,69 +85,4 @@ describe('CredentialItem', () => {
expect(onDelete).not.toHaveBeenCalled()
})
// All disable flags true → no action buttons rendered
it('should hide all action buttons when disableRename, disableEdit, and disableDelete are all true', () => {
// Act
render(
<CredentialItem
credential={credential}
onEdit={vi.fn()}
onDelete={vi.fn()}
disableRename
disableEdit
disableDelete
/>,
)
// Assert
expect(screen.queryByTestId('edit-icon')).not.toBeInTheDocument()
expect(screen.queryByTestId('delete-icon')).not.toBeInTheDocument()
})
// disabled=true guards: clicks on the item row and on delete should both be no-ops
it('should not call onItemClick when disabled=true and item is clicked', () => {
const onItemClick = vi.fn()
render(<CredentialItem credential={credential} disabled onItemClick={onItemClick} />)
fireEvent.click(screen.getByText('Test API Key'))
expect(onItemClick).not.toHaveBeenCalled()
})
it('should not call onDelete when disabled=true and delete button is clicked', () => {
const onDelete = vi.fn()
render(<CredentialItem credential={credential} disabled onDelete={onDelete} />)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
expect(onDelete).not.toHaveBeenCalled()
})
// showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match
it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => {
render(
<CredentialItem
credential={credential}
showSelectedIcon
selectedCredentialId="cred-1"
/>,
)
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
})
it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => {
render(
<CredentialItem
credential={credential}
showSelectedIcon
selectedCredentialId="other-cred"
/>,
)
expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
})
})
@@ -24,6 +24,36 @@ vi.mock('../hooks', () => ({
}),
}))
let mockPortalOpen = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
mockPortalOpen = open
return <div data-testid="portal" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
if (!mockPortalOpen)
return null
return <div data-testid="portal-content">{children}</div>
},
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>
)
},
}))
vi.mock('./authorized-item', () => ({
default: ({ credentials, model, onEdit, onDelete, onItemClick }: {
credentials: Credential[]
@@ -75,127 +105,382 @@ describe('Authorized', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
mockDeleteCredentialId = null
mockDoingAction = false
})
it('should render trigger and open popup when trigger is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
describe('Rendering', () => {
it('should render trigger button', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /addApiKey/i })).toBeInTheDocument()
})
expect(screen.getByText(/Trigger/)).toBeInTheDocument()
})
it('should call handleOpenModal when triggerOnlyOpenModal is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
triggerOnlyOpenModal
/>,
)
it('should render portal content when open', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(mockHandleOpenModal).toHaveBeenCalled()
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
})
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
})
it('should call onItemClick when credential is selected', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
/>,
)
it('should not render portal content when closed', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should render Add API Key button when not model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
it('should call handleActiveCredential when onItemClick is not provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.getByText(/addApiKey/)).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
it('should render Add Model Credential button when is model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
isOpen
/>,
)
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
expect(screen.getByText(/addModelCredential/)).toBeInTheDocument()
})
it('should call handleOpenModal with fixed model fields when adding model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
currentCustomConfigurationModelFixedFields={{
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}}
/>,
)
it('should not render add action when hideAddAction is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
hideAddAction
isOpen
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(screen.getByText(/addModelCredential/))
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
})
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
it('should render popup title when provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
popupTitle="Select Credential"
isOpen
/>,
)
expect(screen.getByText('Select Credential')).toBeInTheDocument()
})
})
it('should not render add action when hideAddAction is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
hideAddAction
/>,
)
describe('User Interactions', () => {
it('should call onOpenChange when trigger is clicked in controlled mode', () => {
const onOpenChange = vi.fn()
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(screen.queryByRole('button', { name: /addApiKey/i })).not.toBeInTheDocument()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen={false}
onOpenChange={onOpenChange}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(onOpenChange).toHaveBeenCalledWith(true)
})
it('should toggle portal on trigger click', () => {
const { rerender } = render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
rerender(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should open modal when triggerOnlyOpenModal is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
triggerOnlyOpenModal
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModal).toHaveBeenCalled()
})
it('should call handleOpenModal when Add API Key is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getByText(/addApiKey/))
expect(mockHandleOpenModal).toHaveBeenCalled()
})
it('should call handleOpenModal with credential and model when edit is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Edit')[0])
expect(mockHandleOpenModal).toHaveBeenCalledWith(
mockCredentials[0],
mockItems[0].model,
)
})
it('should pass current model fields when adding model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
currentCustomConfigurationModelFixedFields={{
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}}
isOpen
/>,
)
fireEvent.click(screen.getByText(/addModelCredential/))
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
it('should call onItemClick when credential is selected', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should call handleActiveCredential when onItemClick is not provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should not call onItemClick when disableItemClick is true', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
disableItemClick
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(onItemClick).not.toHaveBeenCalled()
})
})
it('should show confirm dialog and call confirm handler when delete is confirmed', () => {
mockDeleteCredentialId = 'cred-1'
describe('Delete Confirmation', () => {
it('should show confirm dialog when deleteCredentialId is set', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /common.operation.confirm/i }))
expect(mockHandleConfirmDelete).toHaveBeenCalled()
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
it('should not show confirm dialog when deleteCredentialId is null', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
it('should call closeConfirmDelete when cancel is clicked', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByText('Cancel'))
expect(mockCloseConfirmDelete).toHaveBeenCalled()
})
it('should call handleConfirmDelete when confirm is clicked', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByText('Confirm'))
expect(mockHandleConfirmDelete).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty items array', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={[]}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
})
it('should not render add action when provider does not allow custom token', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<Authorized
provider={restrictedProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
})
})
})
@@ -1,6 +1,5 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfigProvider from './config-provider'
const mockUseCredentialStatus = vi.fn()
@@ -55,8 +54,7 @@ describe('ConfigProvider', () => {
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should show setup label and unavailable tooltip when custom credentials are not allowed and no credential exists', async () => {
const user = userEvent.setup()
it('should still render setup label when custom credentials are not allowed', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: false,
@@ -67,50 +65,6 @@ describe('ConfigProvider', () => {
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
await user.hover(screen.getByText(/operation.setup/i))
expect(await screen.findByText(/auth\.credentialUnavailable/i)).toBeInTheDocument()
})
it('should show config label when hasCredential but not authorized', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: true,
authorized: false,
current_credential_id: 'cred-1',
current_credential_name: 'Key 1',
available_credentials: [],
})
render(<ConfigProvider provider={baseProvider} />)
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should show config label when custom credentials are not allowed but credential exists', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: true,
authorized: true,
current_credential_id: 'cred-1',
current_credential_name: 'Key 1',
available_credentials: [],
})
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should handle nullish credential values with fallbacks', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: false,
current_credential_id: null,
current_credential_name: null,
available_credentials: null,
})
render(<ConfigProvider provider={baseProvider} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
})
})
@@ -1,12 +1,12 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { fireEvent, render, screen } from '@testing-library/react'
import CredentialSelector from './credential-selector'
// Mock components
vi.mock('./authorized/credential-item', () => ({
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick?: (c: unknown) => void }) => (
<button type="button" onClick={() => onItemClick?.(credential)}>
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick: (c: unknown) => void }) => (
<div data-testid="credential-item" onClick={() => onItemClick(credential)}>
{credential.credential_name}
</button>
</div>
),
}))
@@ -19,6 +19,22 @@ vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
}))
// Mock portal components
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => {
// We should only render children if open or if we want to test they are hidden
// The real component might handle this with CSS or conditional rendering.
// Let's use conditional rendering in the mock to avoid "multiple elements" errors.
return <div data-testid="portal-content">{children}</div>
},
}))
describe('CredentialSelector', () => {
const mockCredentials = [
{ credential_id: 'cred-1', credential_name: 'Key 1' },
@@ -30,7 +46,7 @@ describe('CredentialSelector', () => {
vi.clearAllMocks()
})
it('should render selected credential name when selectedCredential is provided', () => {
it('should render selected credential name', () => {
render(
<CredentialSelector
selectedCredential={mockCredentials[0]}
@@ -39,11 +55,12 @@ describe('CredentialSelector', () => {
/>,
)
expect(screen.getByText('Key 1')).toBeInTheDocument()
// Use getAllByText and take the first one (the one in the trigger)
expect(screen.getAllByText('Key 1')[0]).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toBeInTheDocument()
})
it('should render placeholder when selectedCredential is missing', () => {
it('should render placeholder when no credential selected', () => {
render(
<CredentialSelector
credentials={mockCredentials}
@@ -54,8 +71,7 @@ describe('CredentialSelector', () => {
expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument()
})
it('should call onSelect when a credential item is clicked', async () => {
const user = userEvent.setup()
it('should open portal on click', () => {
render(
<CredentialSelector
credentials={mockCredentials}
@@ -63,14 +79,26 @@ describe('CredentialSelector', () => {
/>,
)
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
await user.click(screen.getByRole('button', { name: 'Key 2' }))
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getAllByTestId('credential-item')).toHaveLength(2)
})
it('should call onSelect when a credential is clicked', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('Key 2'))
expect(mockOnSelect).toHaveBeenCalledWith(mockCredentials[1])
})
it('should call onSelect with add-new payload when add action is clicked', async () => {
const user = userEvent.setup()
it('should call onSelect with add new credential data when clicking add button', () => {
render(
<CredentialSelector
credentials={mockCredentials}
@@ -78,8 +106,8 @@ describe('CredentialSelector', () => {
/>,
)
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
await user.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
credential_id: '__add_new_credential',
@@ -87,8 +115,7 @@ describe('CredentialSelector', () => {
}))
})
it('should not open options when disabled is true', async () => {
const user = userEvent.setup()
it('should not open portal when disabled', () => {
render(
<CredentialSelector
disabled
@@ -97,7 +124,7 @@ describe('CredentialSelector', () => {
/>,
)
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
expect(screen.queryByRole('button', { name: 'Key 1' })).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
})
})
@@ -1,11 +1,9 @@
import type { ReactNode } from 'react'
import type {
Credential,
CustomModel,
ModelProvider,
} from '../../declarations'
import { act, renderHook } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations'
import { useAuth } from './use-auth'
@@ -22,13 +20,9 @@ const mockAddModelCredential = vi.fn()
const mockEditProviderCredential = vi.fn()
const mockEditModelCredential = vi.fn()
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({ notify: mockNotify }),
}
})
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelModalHandler: () => mockOpenModelModal,
@@ -72,12 +66,6 @@ describe('useAuth', () => {
model_type: ModelTypeEnum.textGeneration,
}
const createWrapper = ({ children }: { children: ReactNode }) => (
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{children}
</ToastContext.Provider>
)
beforeEach(() => {
vi.clearAllMocks()
mockDeleteModelService.mockResolvedValue({ result: 'success' })
@@ -92,7 +80,7 @@ describe('useAuth', () => {
})
it('should open and close delete confirmation state', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
act(() => {
result.current.openConfirmDelete(credential, model)
@@ -112,7 +100,7 @@ describe('useAuth', () => {
})
it('should activate credential, notify success, and refresh models', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel))
await act(async () => {
await result.current.handleActiveCredential(credential, model)
@@ -132,7 +120,7 @@ describe('useAuth', () => {
})
it('should close delete dialog without calling services when nothing is pending', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
await act(async () => {
await result.current.handleConfirmDelete()
@@ -149,7 +137,7 @@ describe('useAuth', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, {
isModelCredential: false,
onRemove,
}), { wrapper: createWrapper })
}))
act(() => {
result.current.openConfirmDelete(credential, model)
@@ -173,7 +161,7 @@ describe('useAuth', () => {
const onRemove = vi.fn()
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, {
onRemove,
}), { wrapper: createWrapper })
}))
act(() => {
result.current.openConfirmDelete(undefined, model)
@@ -191,7 +179,7 @@ describe('useAuth', () => {
})
it('should add or edit credentials and refresh on successful save', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
await act(async () => {
await result.current.handleSaveCredential({ api_key: 'new-key' })
@@ -212,7 +200,7 @@ describe('useAuth', () => {
const deferred = createDeferred<{ result: string }>()
mockAddProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
let first!: Promise<void>
let second!: Promise<void>
@@ -238,7 +226,7 @@ describe('useAuth', () => {
isModelCredential: true,
onUpdate,
mode: ModelModalModeEnum.configModelCredential,
}), { wrapper: createWrapper })
}))
act(() => {
result.current.handleOpenModal(credential, model)
@@ -256,90 +244,4 @@ describe('useAuth', () => {
}),
)
})
it('should not notify or refresh when handleSaveCredential returns non-success result', async () => {
mockAddProviderCredential.mockResolvedValue({ result: 'error' })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
await act(async () => {
await result.current.handleSaveCredential({ api_key: 'some-key' })
})
expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'some-key' })
expect(mockNotify).not.toHaveBeenCalled()
expect(mockHandleRefreshModel).not.toHaveBeenCalled()
})
it('should pass undefined model and model_type when handleActiveCredential is called without a model parameter', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
await act(async () => {
await result.current.handleActiveCredential(credential)
})
expect(mockActiveProviderCredential).toHaveBeenCalledWith({
credential_id: 'cred-1',
model: undefined,
model_type: undefined,
})
})
// openConfirmDelete with credential only (no model): deleteCredentialId set, deleteModel stays null
it('should only set deleteCredentialId when openConfirmDelete is called without a model', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
act(() => {
result.current.openConfirmDelete(credential, undefined)
})
expect(result.current.deleteCredentialId).toBe('cred-1')
expect(result.current.deleteModel).toBeNull()
expect(result.current.pendingOperationCredentialId.current).toBe('cred-1')
expect(result.current.pendingOperationModel.current).toBeNull()
})
// doingActionRef guard: second handleConfirmDelete call while first is in progress is a no-op
it('should ignore a second handleConfirmDelete call while the first is still in progress', async () => {
const deferred = createDeferred<{ result: string }>()
mockDeleteProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
act(() => {
result.current.openConfirmDelete(credential, model)
})
let first!: Promise<void>
let second!: Promise<void>
await act(async () => {
first = result.current.handleConfirmDelete()
second = result.current.handleConfirmDelete()
deferred.resolve({ result: 'success' })
await Promise.all([first, second])
})
expect(mockDeleteProviderCredential).toHaveBeenCalledTimes(1)
})
// doingActionRef guard: second handleActiveCredential call while first is in progress is a no-op
it('should ignore a second handleActiveCredential call while the first is still in progress', async () => {
const deferred = createDeferred<{ result: string }>()
mockActiveProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
let first!: Promise<void>
let second!: Promise<void>
await act(async () => {
first = result.current.handleActiveCredential(credential)
second = result.current.handleActiveCredential(credential)
deferred.resolve({ result: 'success' })
await Promise.all([first, second])
})
expect(mockActiveProviderCredential).toHaveBeenCalledTimes(1)
})
})
@@ -13,13 +13,11 @@ vi.mock('./hooks', () => ({
// Mock Authorized
vi.mock('./authorized', () => ({
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: Array<{ selectedCredential?: unknown }>, popupTitle: string }) => (
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: { length: number }, popupTitle: string }) => (
<div data-testid="authorized-mock">
<div data-testid="trigger-closed">{renderTrigger()}</div>
<div data-testid="trigger-open">{renderTrigger(true)}</div>
<div data-testid="trigger-container">{renderTrigger()}</div>
<div data-testid="popup-title">{popupTitle}</div>
<div data-testid="items-count">{items.length}</div>
<div data-testid="items-selected">{items.map((it, i) => <span key={i} data-testid={`selected-${i}`}>{it.selectedCredential ? 'has-cred' : 'no-cred'}</span>)}</div>
</div>
),
}))
@@ -57,41 +55,8 @@ describe('ManageCustomModelCredentials', () => {
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
expect(screen.getAllByText(/modelProvider.auth.manageCredentials/).length).toBeGreaterThan(0)
expect(screen.getByText(/modelProvider.auth.manageCredentials/)).toBeInTheDocument()
expect(screen.getByTestId('items-count')).toHaveTextContent('2')
expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials')
})
it('should render trigger in both open and closed states', () => {
const mockModels = [
{
model: 'gpt-4',
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
]
mockUseCustomModels.mockReturnValue(mockModels)
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('trigger-closed')).toBeInTheDocument()
expect(screen.getByTestId('trigger-open')).toBeInTheDocument()
})
it('should pass undefined selectedCredential when model has no current_credential_id', () => {
const mockModels = [
{
model: 'gpt-3.5',
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: '',
current_credential_name: '',
},
]
mockUseCustomModels.mockReturnValue(mockModels)
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('selected-0')).toHaveTextContent('no-cred')
})
})
@@ -18,6 +18,15 @@ vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip-mock">
{children}
<div>{popupContent}</div>
</div>
),
}))
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
}))
@@ -116,131 +125,6 @@ describe('SwitchCredentialInLoadBalancing', () => {
/>,
)
fireEvent.mouseEnter(screen.getByText(/auth.credentialUnavailableInButton/))
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
})
// Empty credentials with allowed custom: no tooltip but still shows unavailable text
it('should show unavailable status without tooltip when custom credentials are allowed', () => {
// Act
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[]}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// Assert
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
})
// not_allowed_to_use=true: indicator is red and destructive button text is shown
it('should show red indicator and unavailable button text when credential has not_allowed_to_use=true', () => {
const unavailableCredential = { credential_id: 'cred-1', credential_name: 'Key 1', not_allowed_to_use: true }
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[unavailableCredential]}
customModelCredential={unavailableCredential}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
})
// from_enterprise=true on the selected credential: Enterprise badge appears in the trigger
it('should show Enterprise badge when selected credential has from_enterprise=true', () => {
const enterpriseCredential = { credential_id: 'cred-1', credential_name: 'Enterprise Key', from_enterprise: true }
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[enterpriseCredential]}
customModelCredential={enterpriseCredential}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText('Enterprise')).toBeInTheDocument()
})
// non-empty credentials with allow_custom_token=false: no tooltip (tooltip only for empty+notAllowCustom)
it('should not show unavailable tooltip when credentials are non-empty and allow_custom_token=false', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<SwitchCredentialInLoadBalancing
provider={restrictedProvider}
model={mockModel}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
fireEvent.mouseEnter(screen.getByText('Key 1'))
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
it('should pass undefined currentCustomConfigurationModelFixedFields when model is undefined', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
// @ts-expect-error testing runtime handling when model is omitted
model={undefined}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// Component still renders (Authorized receives undefined currentCustomConfigurationModelFixedFields)
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
it('should treat undefined credentials as empty list', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={undefined}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// credentials is undefined → empty=true → unavailable text shown
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument()
})
it('should render nothing for credential_name when it is empty string', () => {
const credWithEmptyName = { credential_id: 'cred-1', credential_name: '' }
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[credWithEmptyName]}
customModelCredential={credWithEmptyName}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// indicator-green shown (not authRemoved, not unavailable, not empty)
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
// credential_name is empty so nothing printed for name
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
})
@@ -24,6 +24,10 @@ vi.mock('../hooks', () => ({
useLanguage: () => mockLanguage,
}))
vi.mock('@/app/components/base/icons/src/public/llm', () => ({
OpenaiYellow: () => <svg data-testid="openai-yellow-icon" />,
}))
const createI18nText = (value: string): I18nText => ({
en_US: value,
zh_Hans: value,
@@ -88,10 +92,10 @@ describe('ModelIcon', () => {
icon_small: createI18nText('openai.png'),
})
const { container } = render(<ModelIcon provider={provider} modelName="o1" />)
render(<ModelIcon provider={provider} modelName="o1" />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument()
})
// Edge case
@@ -101,25 +105,4 @@ describe('ModelIcon', () => {
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.firstChild).not.toBeNull()
})
it('should render OpenAI Yellow icon for langgenius/openai/openai provider with model starting with o', () => {
const provider = createModel({
provider: 'langgenius/openai/openai',
icon_small: createI18nText('openai.png'),
})
const { container } = render(<ModelIcon provider={provider} modelName="o3" />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should apply opacity-50 when isDeprecated is true', () => {
const provider = createModel()
const { container } = render(<ModelIcon provider={provider} isDeprecated={true} />)
const wrapper = container.querySelector('.opacity-50')
expect(wrapper).toBeInTheDocument()
})
})
@@ -161,7 +161,7 @@ function Form<
const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name'))
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@@ -204,14 +204,13 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
)}
{tooltipContent}
</div>
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
<div className={cn('grid gap-3', `grid-cols-${options?.length}`)}>
{options.filter((option) => {
if (option.show_on.length)
@@ -230,7 +229,7 @@ function Form<
>
<RadioE isChecked={value[variable] === option.value} />
<div className="text-text-secondary system-sm-regular">{option.label[language] || option.label.en_US}</div>
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
</div>
))}
</div>
@@ -255,7 +254,7 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
@@ -296,9 +295,9 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className="flex items-center justify-between py-2 text-text-secondary system-sm-semibold">
<div className="system-sm-semibold flex items-center justify-between py-2 text-text-secondary">
<div className="flex items-center space-x-2">
<span className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>{label[language] || label.en_US}</span>
<span className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>{label[language] || label.en_US}</span>
{required && (
<span className="ml-1 text-red-500">*</span>
)}
@@ -327,7 +326,7 @@ function Form<
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@@ -359,7 +358,7 @@ function Form<
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@@ -423,7 +422,7 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@@ -452,7 +451,7 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@@ -93,88 +93,4 @@ describe('Input', () => {
expect(onChange).not.toHaveBeenCalledWith('2')
expect(onChange).not.toHaveBeenCalledWith('6')
})
it('should not clamp when min and max are not provided', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Free"
onChange={onChange}
/>,
)
const input = screen.getByPlaceholderText('Free')
fireEvent.change(input, { target: { value: '999' } })
fireEvent.blur(input)
// onChange only called from change event, not from blur clamping
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('999')
})
it('should show check circle icon when validated is true', () => {
const { container } = render(
<Input
placeholder="Key"
onChange={vi.fn()}
validated
/>,
)
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).toBeInTheDocument()
})
it('should not show check circle icon when validated is false', () => {
const { container } = render(
<Input
placeholder="Key"
onChange={vi.fn()}
validated={false}
/>,
)
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).not.toBeInTheDocument()
})
it('should apply disabled attribute when disabled prop is true', () => {
render(
<Input
placeholder="Disabled"
onChange={vi.fn()}
disabled
/>,
)
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled()
})
it('should call onFocus when input receives focus', () => {
const onFocus = vi.fn()
render(
<Input
placeholder="Focus"
onChange={vi.fn()}
onFocus={onFocus}
/>,
)
fireEvent.focus(screen.getByPlaceholderText('Focus'))
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('should render with custom className', () => {
render(
<Input
placeholder="Styled"
onChange={vi.fn()}
className="custom-class"
/>,
)
expect(screen.getByPlaceholderText('Styled')).toHaveClass('custom-class')
})
})
@@ -1,7 +1,5 @@
import type { ComponentProps } from 'react'
import type { Credential, CredentialFormSchema, CustomModel, ModelProvider } from '../declarations'
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@@ -45,6 +43,15 @@ const mockHandlers = vi.hoisted(() => ({
handleActiveCredential: vi.fn(),
}))
type FormResponse = {
isCheckValidated: boolean
values: Record<string, unknown>
}
const mockFormState = vi.hoisted(() => ({
responses: [] as FormResponse[],
setFieldValue: vi.fn(),
}))
vi.mock('../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: mockState.isLoading,
@@ -79,6 +86,36 @@ vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
const React = await import('react')
const AuthForm = React.forwardRef(({
onChange,
}: {
onChange?: (field: string, value: string) => void
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
}))
return (
<div>
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
</div>
)
})
return { default: AuthForm }
})
vi.mock('../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
<div>
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
</div>
),
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
@@ -121,7 +158,7 @@ const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
...overrides,
})
const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) => {
const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>>) => {
const provider = createProvider()
const props = {
provider,
@@ -131,50 +168,13 @@ const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) =>
onRemove: vi.fn(),
...overrides,
}
render(<ModelModal {...props} />)
return props
const view = render(<ModelModal {...props} />)
return {
...props,
unmount: view.unmount,
}
}
const mockFormRef1 = {
getFormValues: vi.fn(),
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
}
const mockFormRef2 = {
getFormValues: vi.fn(),
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
}
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
default: React.forwardRef((props: { formSchemas: Record<string, unknown>[], onChange?: (f: string, v: string) => void }, ref: React.ForwardedRef<unknown>) => {
React.useImperativeHandle(ref, () => {
// Return the mock depending on schemas passed (hacky but works for refs)
if (props.formSchemas.length > 0 && props.formSchemas[0].name === '__model_name')
return mockFormRef1
return mockFormRef2
})
return (
<div data-testid="auth-form" onClick={() => props.onChange?.('test-field', 'val')}>
AuthForm Mock (
{props.formSchemas.length}
{' '}
fields)
</div>
)
}),
}))
vi.mock('../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (val: unknown) => void }) => (
<button onClick={() => onSelect({ addNewCredential: true })} data-testid="credential-selector">
Select Credential
</button>
),
useAuth: vi.fn(),
useCredentialData: vi.fn(),
useModelFormSchemas: vi.fn(),
}))
describe('ModelModal', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -187,131 +187,167 @@ describe('ModelModal', () => {
mockState.formValues = {}
mockState.modelNameAndTypeFormSchemas = []
mockState.modelNameAndTypeFormValues = {}
// reset form refs
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __model_name: 'test', __model_type: ModelTypeEnum.textGeneration } })
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'test_auth', api_key: 'sk-test' } })
mockFormState.responses = []
})
it('should render title and loading state for predefined credential modal', () => {
it('should show title, description, and loading state for predefined models', () => {
mockState.isLoading = true
renderModal()
const predefined = renderModal()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
})
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
it('should render model credential title when mode is configModelCredential', () => {
renderModal({
mode: ModelModalModeEnum.configModelCredential,
model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration },
})
predefined.unmount()
const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel })
expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument()
customizable.unmount()
mockState.credentialData = { credentials: {}, available_credentials: [] }
renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } })
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
})
it('should render edit credential title when credential exists', () => {
renderModal({
mode: ModelModalModeEnum.configModelCredential,
credential: { credential_id: '1' } as unknown as Credential,
})
expect(screen.getByText('common.modelProvider.auth.editModelCredential')).toBeInTheDocument()
it('should reveal the credential label when adding a new credential', () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList })
expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Add New'))
expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument()
})
it('should change title to Add Model when mode is configCustomModel', () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
renderModal({ mode: ModelModalModeEnum.configCustomModel })
expect(screen.getByText('common.modelProvider.auth.addModel')).toBeInTheDocument()
it('should call onCancel when the cancel button is clicked', () => {
const { onCancel } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should validate and fail save if form is invalid in configCustomModel mode', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
})
it('should call onCancel when the escape key is pressed', () => {
const { onCancel } = renderModal()
it('should validate and save new credential and model in configCustomModel mode', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
const props = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'sk-test' },
name: 'test_auth',
model: 'test',
model_type: ModelTypeEnum.textGeneration,
})
expect(props.onSave).toHaveBeenCalled()
})
})
it('should save credential only in standard configProviderCredential mode', async () => {
const { onSave } = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'sk-test' },
name: 'test_auth',
})
expect(onSave).toHaveBeenCalled()
})
})
it('should save active credential and cancel when picking existing credential in addCustomModelToModelList mode', async () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm1', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
// By default selected is undefined so button clicks form
// Let's not click credential selector, so it evaluates without it. If selectedCredential is undefined, form validation is checked.
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
})
it('should save active credential when picking existing credential in addCustomModelToModelList mode', async () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm2', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
// Select existing credential (addNewCredential: true simulates new but we can simulate false if we just hack the mocked state in the component, but it's internal.
// The credential selector sets selectedCredential.
fireEvent.click(screen.getByTestId('credential-selector')) // Sets addNewCredential = true internally, so it proceeds to form save
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'auth', api: 'key' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api: 'key' },
name: 'auth',
model: 'm2',
model_type: ModelTypeEnum.textGeneration,
})
})
})
it('should open and confirm deletion of credential', () => {
mockState.credentialData = { credentials: { api_key: '123' }, available_credentials: [] }
mockState.formValues = { api_key: '123' } // To trigger isEditMode = true
const credential = { credential_id: 'c1' } as unknown as Credential
renderModal({ credential })
// Open Delete Confirm
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith(credential, undefined)
// Simulate the dialog appearing and confirming
mockState.deleteCredentialId = 'c1'
renderModal({ credential }) // Re-render logic mock
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.confirm' })[0])
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalled()
})
it('should bind escape key to cancel', () => {
const props = renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
expect(props.onCancel).toHaveBeenCalled()
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should confirm deletion when a delete dialog is shown', () => {
mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] }
mockState.deleteCredentialId = 'delete-id'
const credential: Credential = { credential_id: 'cred-1' }
const { onCancel } = renderModal({ credential })
expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should handle save flows for different modal modes', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema]
mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema]
mockFormState.responses = [
{ isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } },
{ isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } },
]
const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getAllByText('Model Name Change')[0])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model')
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'secret' },
name: 'Auth Name',
model: 'custom-model',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' })
configCustomModel.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }]
const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }
const configModelCredential = renderModal({
mode: ModelModalModeEnum.configModelCredential,
model,
credential: { credential_id: 'cred-123' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: 'cred-123',
credentials: { api_key: 'abc' },
name: 'Model Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' })
configModelCredential.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }]
const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'provider-key' },
name: 'Provider Auth',
})
})
configProviderCredential.unmount()
const addToModelList = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Choose Existing'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model)
expect(addToModelList.onCancel).toHaveBeenCalled()
addToModelList.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }]
const addToModelListWithNew = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Add New'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'new-key' },
name: 'New Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
addToModelListWithNew.unmount()
mockFormState.responses = [{ isCheckValidated: false, values: {} }]
const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4)
})
invalidSave.unmount()
mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] }
mockState.formValues = { api_key: 'value' }
const removable = renderModal({ credential: { credential_id: 'remove-1' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined)
removable.unmount()
})
})
@@ -1,8 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ModelParameterModal from './index'
let isAPIKeySet = true
let parameterRules: Array<Record<string, unknown>> | undefined = [
let parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
@@ -61,17 +62,42 @@ vi.mock('../hooks', () => ({
}),
}))
// Mock PortalToFollowElem components to control visibility and simplify testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
return {
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => {
return (
<div>
<div data-testid="portal-wrapper">
{children}
</div>
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => (
<div data-testid="portal-content" className={className}>
{children}
</div>
),
}
})
vi.mock('./parameter-item', () => ({
default: ({ parameterRule, onChange, onSwitch }: {
parameterRule: { name: string, label: { en_US: string } }
onChange: (v: number) => void
onSwitch: (checked: boolean, val: unknown) => void
}) => (
default: ({ parameterRule, value, onChange, onSwitch }: { parameterRule: { name: string, label: { en_US: string } }, value: string | number, onChange: (v: number) => void, onSwitch: (checked: boolean, val: unknown) => void }) => (
<div data-testid={`param-${parameterRule.name}`}>
{parameterRule.label.en_US}
<button onClick={() => onChange(0.9)}>Change</button>
<button onClick={() => onSwitch(false, undefined)}>Remove</button>
<button onClick={() => onSwitch(true, 'assigned')}>Add</button>
<input
aria-label={parameterRule.name}
value={value || ''}
onChange={e => onChange(Number(e.target.value))}
/>
<button onClick={() => onSwitch?.(false, undefined)}>Remove</button>
<button onClick={() => onSwitch?.(true, 'assigned')}>Add</button>
</div>
),
}))
@@ -79,6 +105,7 @@ vi.mock('./parameter-item', () => ({
vi.mock('../model-selector', () => ({
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector">
Model Selector
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
</div>
),
@@ -94,11 +121,16 @@ vi.mock('./trigger', () => ({
default: () => <button>Open Settings</button>,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(' '),
}))
// Mock config
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
PROVIDER_WITH_PRESET_TONE: ['openai'],
PROVIDER_WITH_PRESET_TONE: ['openai'], // ensure presets mock renders
}
})
@@ -156,19 +188,21 @@ describe('ModelParameterModal', () => {
]
})
it('should render trigger and open modal content when trigger is clicked', () => {
it('should render trigger and content', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByText('Open Settings')).toBeInTheDocument()
expect(screen.getByText('Temperature')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
})
it('should call onCompletionParamsChange when parameter changes and switch actions happen', () => {
it('should update params when changed and handle switch add/remove', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText('Change'))
const input = screen.getByLabelText('temperature')
fireEvent.change(input, { target: { value: '0.9' } })
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 0.9,
@@ -184,18 +218,51 @@ describe('ModelParameterModal', () => {
})
})
it('should call onCompletionParamsChange when preset is selected', () => {
it('should handle preset selection', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText('Preset 1'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
})
it('should call setModel when model selector picks another model', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText('Select GPT-4.1'))
it('should handle debug mode toggle', () => {
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
const toggle = screen.getByText(/debugAsMultipleModel/i)
fireEvent.click(toggle)
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
rerender(<ModelParameterModal {...defaultProps} debugWithMultipleModel />)
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
})
it('should handle custom renderTrigger', () => {
const renderTrigger = vi.fn().mockReturnValue(<div>Custom Trigger</div>)
render(<ModelParameterModal {...defaultProps} renderTrigger={renderTrigger} readonly />)
expect(screen.getByText('Custom Trigger')).toBeInTheDocument()
expect(renderTrigger).toHaveBeenCalled()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(renderTrigger).toHaveBeenCalledTimes(1)
})
it('should handle model selection and advanced mode parameters', () => {
parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
rerender(<ModelParameterModal {...defaultProps} isAdvancedMode />)
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select GPT-4.1'))
expect(defaultProps.setModel).toHaveBeenCalledWith({
modelId: 'gpt-4.1',
provider: 'openai',
@@ -203,32 +270,4 @@ describe('ModelParameterModal', () => {
features: ['vision', 'tool-call'],
})
})
it('should toggle debug mode when debug footer is clicked', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText(/debugAsMultipleModel/i))
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
})
it('should render loading state when parameter rules are loading', () => {
isRulesLoading = true
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should not open content when readonly is true', () => {
render(<ModelParameterModal {...defaultProps} readonly />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
it('should render no parameter items when rules are undefined', () => {
parameterRules = undefined
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})

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