Compare commits

..
Author SHA1 Message Date
yyh 5d790d634d Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-27 15:16:43 +08:00
林玮 (Jade Lin) 2b343f25d4 Merge remote-tracking branch 'origin/linw/snp-581-celery-task-should-join-thread' into deploy/saas 2026-07-27 14:59:40 +08:00
林玮 (Jade Lin) 45317a11b9 fix: bound app worker cleanup wait 2026-07-27 14:54:04 +08:00
林玮 (Jade Lin) 0e6af39553 Merge remote-tracking branch 'origin/linw/snp-581-celery-task-should-join-thread' into deploy/saas 2026-07-27 13:57:41 +08:00
yyh b81d521e0b perf(web): prefetch workspace list on intent 2026-07-27 12:47:19 +08:00
yyh c487dc2d7c fix(web): refine workspace switcher loading state 2026-07-27 12:27:12 +08:00
autofix-ci[bot]andyyh 0ed9fb4db6 [autofix.ci] apply automated fixes 2026-07-27 11:56:34 +08:00
yyh 52b825a4a1 fix: align workspace card plan ownership 2026-07-27 11:56:33 +08:00
yyhyyhautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
ffb1e29527 refactor(workflow): remove hook barrel exports (#39588)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 11:42:40 +08:00
林玮 (Jade Lin) bedb3c571b fix: correct workflow task return type 2026-07-27 11:40:34 +08:00
林玮 (Jade Lin) 05df469e28 test: join workflow resume thread stub 2026-07-27 11:35:56 +08:00
林玮 (Jade Lin) 07ab81c9f0 test: join workflow generation thread stub 2026-07-27 11:35:23 +08:00
林玮 (Jade Lin) 1319f485a1 Merge remote-tracking branch 'origin/linw/snp-581-celery-task-should-join-thread' into deploy/saas 2026-07-27 11:09:17 +08:00
林玮 (Jade Lin) b12ad644ee Merge remote-tracking branch 'origin/linw/snp-560-webapp-site-response-with-mode' into deploy/saas 2026-07-27 10:47:28 +08:00
林玮 (Jade Lin) 8073ba2fbe refactor(web): remove redundant app mode propagation 2026-07-27 10:42:07 +08:00
autofix-ci[bot]andGitHub 4919cc2395 [autofix.ci] apply automated fixes 2026-07-27 02:03:29 +00:00
林玮 (Jade Lin) e3dcf2efdb Merge remote-tracking branch 'origin/linw/snp-560-webapp-site-response-with-mode' into deploy/saas 2026-07-27 09:43:03 +08:00
hjlarry de7d4d4ab2 Merge branch 'build/plugin-list-improve' into deploy/saas 2026-07-25 15:36:41 +08:00
hjlarry a1efdeeabb fix: simplify plugin list API contracts 2026-07-25 15:34:38 +08:00
autofix-ci[bot]andGitHub 15cea0c08d [autofix.ci] apply automated fixes 2026-07-25 07:10:01 +00:00
hjlarry 543642542f Merge remote-tracking branch 'myori/deploy/saas' into deploy/saas 2026-07-25 15:07:07 +08:00
hjlarry 0d71273e30 Merge branch 'build/plugin-list-improve' into deploy/saas 2026-07-25 14:56:43 +08:00
hjlarry 671742d5b6 chore: improve summary api 2026-07-25 11:04:22 +08:00
林玮 (Jade Lin) e67d45cf7c fix: wait for workflow worker cleanup 2026-07-24 20:24:30 +08:00
林玮 (Jade Lin) 5d5439a169 feat: expose app mode in webapp site response 2026-07-24 20:15:10 +08:00
林玮 (Jade Lin) 6506d06f61 Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-24 16:00:54 +08:00
林玮 (Jade Lin) 689da4782f Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-24 16:00:43 +08:00
林玮 (Jade Lin) 3efe5f0da8 refactor(web): use semantic labelling for explore banner slides
(cherry picked from commit 8e89bd38ae)
2026-07-24 15:48:27 +08:00
林玮 (Jade Lin) 3665f6cb73 refactor(web): add accessible label to plan range switcher
(cherry picked from commit 810d028a3c)
2026-07-24 15:48:27 +08:00
林玮 (Jade Lin) 39ffee49ff refactor(web): use semantic markup for workflow save status
(cherry picked from commit a689278288)
2026-07-24 15:48:27 +08:00
林玮 (Jade Lin) 952d3482bb refactor(web): use semantic markup for billing usage info cards
(cherry picked from commit 87b9873bb7)
2026-07-24 15:48:27 +08:00
autofix-ci[bot]andGitHub 6fc6ac3d4c [autofix.ci] apply automated fixes 2026-07-24 05:42:34 +00:00
hjlarry c1647cd2a6 feat: model plugin summary api 2026-07-24 13:38:35 +08:00
林玮 (Jade Lin) 9b257f7df2 Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-23 18:18:26 +08:00
autofix-ci[bot]andGitHub aff527ce39 [autofix.ci] apply automated fixes 2026-07-23 08:36:56 +00:00
hjlarry 4ca9c36421 Merge remote-tracking branch 'origin/build/plugin-list-improve' into deploy/saas 2026-07-23 16:36:16 +08:00
hjlarry 00e7f4d984 feat: add category-scoped installed plugin ids 2026-07-23 16:13:38 +08:00
hjlarry 4375c7b988 feat: plugin list query improve 2026-07-23 10:32:26 +08:00
310 changed files with 11647 additions and 35527 deletions
-3
View File
@@ -666,7 +666,6 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
PLUGIN_REMOTE_INSTALL_HOST=localhost
PLUGIN_MAX_PACKAGE_SIZE=15728640
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
# Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users.
# Example: langgenius/openai,langgenius/gemini
@@ -678,8 +677,6 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
# Dify Agent backend
AGENT_BACKEND_BASE_URL=http://localhost:5050
# Bearer token sent to the Agent backend /runs API. Must match DIFY_AGENT_API_TOKEN on the server side.
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
+30 -59
View File
@@ -1,13 +1,10 @@
import logging
import time
from collections.abc import Callable
from typing import NamedTuple
import socketio
from flask import request
from opentelemetry.trace import get_current_span
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
from werkzeug.exceptions import Forbidden, HTTPException, ServiceUnavailable
from configs import dify_config
from contexts.wrapper import RecyclableContextVar
@@ -45,53 +42,6 @@ _CONSOLE_EXEMPT_PREFIXES = (
"/console/api/activate/check",
)
_WEBAPP_EXEMPT_PREFIXES = ("/api/system-features",)
_INVALID_LICENSE_STATUSES = (LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST)
def _session_surface_error(license_status: LicenseStatus | None) -> HTTPException:
if license_status is None:
return UnauthorizedAndForceLogout("Unable to verify enterprise license. Please contact your administrator.")
return UnauthorizedAndForceLogout(f"Enterprise license is {license_status}. Please contact your administrator.")
def _bearer_surface_error(license_status: LicenseStatus | None) -> HTTPException:
"""Token-authed: forcing a logout is meaningless and license state must not leak."""
return Forbidden(description="license_required")
def _retryable_surface_error(license_status: LicenseStatus | None) -> HTTPException:
"""Webhook senders retry on 5xx but treat 4xx as permanent, disabling the subscription."""
return ServiceUnavailable(description="license_required")
class _LicenseGatedSurface(NamedTuple):
prefix: str
exempt_prefixes: tuple[str, ...]
build_error: Callable[[LicenseStatus | None], HTTPException]
# /files (plugin-daemon data plane), /inner/api (enterprise control plane) and /health
# stay ungated: blocking them breaks workflow execution or license recovery itself.
_LICENSE_GATED_SURFACES = (
_LicenseGatedSurface("/console/api/", _CONSOLE_EXEMPT_PREFIXES, _session_surface_error),
_LicenseGatedSurface("/api/", _WEBAPP_EXEMPT_PREFIXES, _session_surface_error),
_LicenseGatedSurface("/v1", (), _bearer_surface_error),
_LicenseGatedSurface("/mcp", (), _bearer_surface_error),
_LicenseGatedSurface("/triggers", (), _retryable_surface_error),
)
def _match_license_gated_surface(path: str) -> _LicenseGatedSurface | None:
for surface in _LICENSE_GATED_SURFACES:
if not path.startswith(surface.prefix):
continue
if any(path.startswith(exempt) for exempt in surface.exempt_prefixes):
return None
return surface
return None
# ----------------------------
# Application Factory Function
@@ -112,17 +62,38 @@ 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:
surface = _match_license_gated_surface(request.path)
if surface is not None:
try:
license_status = EnterpriseService.get_cached_license_status()
except Exception:
logger.exception("Failed to check enterprise license status")
license_status = None
is_console_api = request.path.startswith("/console/api/")
is_webapp_api = request.path.startswith("/api/")
if license_status is None or license_status in _INVALID_LICENSE_STATUSES:
raise surface.build_error(license_status)
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
+1 -5
View File
@@ -11,7 +11,6 @@ from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAge
def create_agent_backend_run_client(
*,
base_url: str | None = None,
api_token: str | None = None,
use_fake: bool = False,
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
stream_read_timeout_seconds: float = 30,
@@ -23,11 +22,8 @@ def create_agent_backend_run_client(
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
if base_url is None:
raise ValueError("base_url is required when creating a real Agent backend client")
headers: dict[str, str] = {}
if api_token:
headers["Authorization"] = f"Bearer {api_token}"
return DifyAgentBackendRunClient(
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds, headers=headers),
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds),
stream_max_reconnects=stream_max_reconnects,
stream_timeout_seconds=stream_run_timeout_seconds,
)
@@ -12,11 +12,6 @@ class AgentBackendConfig(BaseSettings):
default=None,
)
AGENT_BACKEND_API_TOKEN: str | None = Field(
description="Bearer token for authenticating with the Agent backend /runs API.",
default=None,
)
AGENT_BACKEND_USE_FAKE: bool = Field(
description="Use the deterministic in-process fake Agent backend client.",
default=False,
-6
View File
@@ -266,12 +266,6 @@ class PluginConfig(BaseSettings):
default=60 * 60,
)
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED: bool = Field(
description="Whether tenant plugin model providers are cached in Redis. Disable when plugins are installed "
"by a system other than this one, which cannot invalidate the cache when a tenant's plugins change.",
default=True,
)
PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field(
description="TTL in seconds for caching tenant plugin model providers in Redis",
default=60 * 60 * 24,
-2
View File
@@ -144,7 +144,6 @@ from .workspace import (
models,
plugin,
rbac,
skills,
snippets,
tool_providers,
trigger_providers,
@@ -226,7 +225,6 @@ __all__ = [
"saved_message",
"setup",
"site",
"skills",
"snippet_workflow",
"snippet_workflow_draft_variable",
"snippets",
-77
View File
@@ -58,7 +58,6 @@ from services.app_service import (
AppResponseView,
AppService,
CreateAppParams,
RecentAppMode,
StarredAppListParams,
)
from services.enterprise import rbac_service as enterprise_rbac_service
@@ -140,10 +139,6 @@ class AppListBaseQuery(BaseModel):
raise ValueError("Invalid UUID format in creator_ids.") from exc
class RecentAppListQuery(BaseModel):
limit: int = Field(default=8, ge=1, le=8, description="Number of recently modified apps to return (1-8)")
class AppListQuery(AppListBaseQuery):
pass
@@ -416,33 +411,6 @@ class AppPartial(AppResponseModel):
return to_timestamp(value)
class RecentAppResponse(ResponseModel):
id: str
name: str
icon_type: IconType | None = None
icon: str | None = None
icon_background: str | None = None
mode: RecentAppMode
author_name: str | None = None
updated_at: int
permission_keys: list[str] = Field(default_factory=list)
maintainer: str | None = None
@computed_field(return_type=str | None) # type: ignore[prop-decorator]
@property
def icon_url(self) -> str | None:
return build_icon_url(self.icon_type, self.icon)
@field_validator("updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int) -> int:
return to_timestamp(value)
class RecentAppListResponse(ResponseModel):
data: list[RecentAppResponse]
class AppDetail(AppResponseModel):
id: str
name: str
@@ -607,8 +575,6 @@ register_schema_models(
register_response_schema_models(
console_ns,
AppPartial,
RecentAppResponse,
RecentAppListResponse,
AppDetailWithSite,
AppPagination,
)
@@ -733,49 +699,6 @@ class AppListApi(Resource):
return app_detail.model_dump(mode="json"), 201
@console_ns.route("/apps/recent")
class RecentAppListApi(Resource):
@console_ns.doc("list_recent_apps")
@console_ns.doc(description="Get recently modified apps for the home Continue Work section")
@console_ns.doc(params=query_params_from_model(RecentAppListQuery))
@console_ns.response(200, "Success", console_ns.models[RecentAppListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_session(write=False)
@with_current_user_id
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
"""Return the lightweight app cards needed by the Explore home page."""
args = query_params_from_request(RecentAppListQuery)
params = AppListParams(limit=args.limit)
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
current_tenant_id,
current_user_id,
session=session,
)
if dify_config.RBAC_ENABLED:
access_filter = resolve_app_access_filter(
current_tenant_id,
current_user_id,
session=session,
permissions=permissions,
)
access_filter.apply_to_params(params)
recent_apps = AppService().get_recent_apps(current_user_id, current_tenant_id, params, session)
permission_keys_map = permissions.app.permission_keys_by_resource_ids([app.id for app in recent_apps])
response_items = [
RecentAppResponse.model_validate(app, from_attributes=True).model_copy(
update={"permission_keys": permission_keys_map.get(app.id, [])}
)
for app in recent_apps
]
return dump_response(RecentAppListResponse, {"data": response_items}), 200
@console_ns.route("/apps/starred")
class StarredAppListApi(Resource):
@console_ns.doc("list_starred_apps")
+1 -1
View File
@@ -59,7 +59,7 @@ class TagBindingRemovePayload(BaseModel):
class TagListQueryParam(BaseModel):
type: TagType | Literal[""] = Field("", description="Tag type filter")
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
keyword: str | None = Field(None, description="Search keyword")
@@ -26,7 +26,11 @@ from libs.helper import dump_response, uuid_value
from libs.login import login_required
from models import Account
from services.billing_service import BillingService
from services.entities.model_provider_entities import ProviderResponse
from services.entities.model_provider_entities import (
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ProviderResponse,
)
from services.model_provider_service import ModelProviderService
@@ -91,6 +95,11 @@ class ModelProviderListResponse(ResponseModel):
data: list[ProviderResponse]
class ModelProviderSummaryListResponse(ResponseModel):
data: list[ModelProviderSummaryResponse]
plugins: dict[str, ModelProviderPluginSummaryResponse]
class ProviderCredentialsResponse(ResponseModel):
credentials: dict[str, Any] | None = None
@@ -114,6 +123,7 @@ register_response_schema_models(
console_ns,
SimpleResultResponse,
ModelProviderListResponse,
ModelProviderSummaryListResponse,
ProviderCredentialsResponse,
ValidationResultResponse,
ModelProviderPaymentCheckoutUrlResponse,
@@ -140,6 +150,25 @@ class ModelProviderListApi(Resource):
return ModelProviderListResponse(data=provider_list).model_dump(mode="json")
@console_ns.route("/workspaces/current/model-providers/summary")
class ModelProviderSummaryListApi(Resource):
@console_ns.response(
200,
"Model provider summaries retrieved successfully",
console_ns.models[ModelProviderSummaryListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
providers, plugins = ModelProviderService().get_provider_summary_list(tenant_id=tenant_id)
return dump_response(
ModelProviderSummaryListResponse,
{"data": providers, "plugins": plugins},
)
@console_ns.route("/workspaces/current/model-providers/<path:provider>/credentials")
class ModelProviderCredentialApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserCredentialId))
+96 -5
View File
@@ -1,5 +1,5 @@
import io
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any, Literal, TypedDict
@@ -43,6 +43,7 @@ from core.plugin.entities.plugin_daemon import PluginDecodeResponse, PluginInsta
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
from core.tools.entities.api_entities import ToolProviderApiEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
@@ -90,9 +91,21 @@ class ParserList(BaseModel):
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
type PluginCategoryListLanguage = Literal["en_US", "zh_Hans", "ja_JP", "pt_BR"]
class PluginCategoryListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
query: str = Field(default="", max_length=256, description="Case-insensitive search query")
tags: list[str] = Field(default_factory=list, max_length=128, description="Match any plugin tag")
language: Literal["en_US", "zh_Hans", "ja_JP", "pt_BR"] = Field(
default="en_US", description="Language used for localized label and description search"
)
class PluginInstalledIdsQuery(BaseModel):
category: PluginCategory = Field(description="Plugin category to include")
class ParserLatest(BaseModel):
@@ -325,6 +338,10 @@ class PluginListResponse(ResponseModel):
total: int
class PluginInstalledIdsResponse(ResponseModel):
plugin_ids: list[str]
class PluginVersionsResponse(ResponseModel):
versions: Mapping[str, PluginService.LatestPluginCache | None]
@@ -384,6 +401,7 @@ register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginInstalledIdsQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@@ -420,6 +438,7 @@ register_response_schema_models(
PluginDebuggingKeyResponse,
PluginDynamicOptionsResponse,
PluginInstallationsResponse,
PluginInstalledIdsResponse,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginManifestResponse,
@@ -477,7 +496,39 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
return content
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
def _localized_builtin_tool_text(value: I18nObject, language: PluginCategoryListLanguage) -> str:
return value.to_dict()[language] or value.en_US
def _builtin_tool_provider_matches_filters(
provider: ToolProviderApiEntity,
*,
query: str,
tags: Sequence[str],
language: PluginCategoryListLanguage,
) -> bool:
if tags and not any(tag in provider.labels for tag in tags):
return False
if not query:
return True
lower_query = query.lower()
candidates = (
provider.name,
_localized_builtin_tool_text(provider.label, language),
_localized_builtin_tool_text(provider.description, language),
)
return any(lower_query in candidate.lower() for candidate in candidates)
def _list_hardcoded_builtin_tool_providers(
tenant_id: str,
*,
query: str = "",
tags: Sequence[str] = (),
language: PluginCategoryListLanguage = "en_US",
) -> list[dict[str, Any]]:
"""List builtin providers using the same search and tag semantics as category plugins."""
db_builtin_providers = {
str(ToolProviderID(provider.provider)): provider
for provider in ToolManager.list_default_builtin_providers(tenant_id)
@@ -498,6 +549,13 @@ def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any
db_provider=db_builtin_providers.get(provider.entity.identity.name),
decrypt_credentials=False,
)
if not _builtin_tool_provider_matches_filters(
user_provider,
query=query,
tags=tags,
language=language,
):
continue
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
builtin_providers.append(user_provider)
@@ -552,7 +610,9 @@ class PluginCategoryListApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, category: str):
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
args = PluginCategoryListQuery.model_validate(
{**request.args.to_dict(flat=True), "tags": request.args.getlist("tags")}
)
try:
plugin_category = PluginCategory(category)
@@ -560,13 +620,26 @@ class PluginCategoryListApi(Resource):
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
try:
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
plugins = PluginService.list_by_category(
tenant_id,
plugin_category,
args.page,
args.page_size,
query=args.query,
tags=args.tags,
language=args.language,
)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
builtin_tools = []
if plugin_category == PluginCategory.Tool:
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
builtin_tools = _list_hardcoded_builtin_tool_providers(
tenant_id,
query=args.query,
tags=args.tags,
language=args.language,
)
return dump_response(
PluginCategoryListResponse,
@@ -578,6 +651,24 @@ class PluginCategoryListApi(Resource):
)
@console_ns.route("/workspaces/current/plugin/installed-ids")
class PluginInstalledIdsApi(Resource):
@console_ns.doc(params=query_params_from_model(PluginInstalledIdsQuery))
@console_ns.response(200, "Success", console_ns.models[PluginInstalledIdsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
args = PluginInstalledIdsQuery.model_validate(request.args.to_dict(flat=True))
try:
plugin_ids = PluginService.list_installed_plugin_ids(tenant_id, args.category)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
return dump_response(PluginInstalledIdsResponse, {"plugin_ids": plugin_ids})
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
class PluginListLatestVersionsApi(Resource):
@console_ns.expect(console_ns.models[ParserLatest.__name__])
-834
View File
@@ -1,834 +0,0 @@
"""Console API for workspace-level Skill Management."""
from __future__ import annotations
import io
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from controllers.common.fields import BinaryFileResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from libs import helper
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from services.skill_management_service import (
SkillAssistMessagePayload,
SkillCreatePayload,
SkillDraftFileOperationPayload,
SkillDraftTreePayload,
SkillImportPayload,
SkillManagementService,
SkillManagementServiceError,
SkillMetadataPayload,
SkillPublishPayload,
SkillRestorePayload,
SkillVersionUpdatePayload,
)
_FILE_UPLOAD_PARAMS = {
"file": {
"description": "Skill draft file payload",
"in": "formData",
"type": "file",
"required": True,
},
}
class WorkspaceSkillsQuery(BaseModel):
keyword: str | None = Field(default=None, description="Search keyword matching skill name or description.")
page: int = Field(default=1, ge=1, le=99999, description="Page number.")
limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.")
tag: list[str] = Field(
default_factory=list,
description="Skill tag filters. Repeat the parameter for multiple tags.",
)
class SkillDeletePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
confirmation_name: str | None = Field(
default=None,
description="Required when deleting a referenced Skill. Must match the Skill name.",
)
class AgentSkillBindingsPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
skill_ids: list[str] = Field(default_factory=list, description="Ordered Skill IDs bound to the Agent.")
class SkillFileQuery(BaseModel):
path: str = Field(description="Skill file path relative to the Skill root.")
version_id: str | None = Field(default=None, description="Optional published version ID. Omit for current draft.")
class SkillResponse(ResponseModel):
id: str
name: str
display_name: str
icon: str
description: str
tags: list[str] = Field(default_factory=list)
name_manually_edited: bool = False
visibility: str
latest_published_version_id: str | None = None
reference_count: int = 0
created_by: str | None = None
created_by_name: str | None = None
updated_by: str | None = None
updated_by_name: str | None = None
created_at: int
updated_at: int
class SkillFileResponse(ResponseModel):
id: str | None = None
path: str
kind: str
storage: str | None = None
mime_type: str | None = None
content: str | None = None
tool_file_id: str | None = None
size: int | None = None
hash: str | None = None
class SkillFilePreviewResponse(ResponseModel):
path: str
mime_type: str
content: str
size: int
hash: str
class SkillFileUploadResponse(ResponseModel):
id: str
name: str
mime_type: str
size: int
hash: str
class SkillDetailResponse(SkillResponse):
files: list[SkillFileResponse] = Field(default_factory=list)
class SkillListResponse(ResponseModel):
data: list[SkillResponse] = Field(default_factory=list)
has_more: bool = False
limit: int = 20
page: int = 1
total: int = 0
class SkillTagResponse(ResponseModel):
tag: str
count: int
class SkillTagListResponse(ResponseModel):
data: list[SkillTagResponse] = Field(default_factory=list)
class SkillVersionResponse(ResponseModel):
id: str
skill_id: str
version_number: int
version_name: str
publish_note: str
hash_code: str
archive_size: int
published_by: str | None = None
published_by_name: str | None = None
is_latest: bool = False
created_at: int
class SkillVersionListResponse(ResponseModel):
data: list[SkillVersionResponse] = Field(default_factory=list)
class SkillVersionDetailResponse(SkillVersionResponse):
files: list[SkillFileResponse] = Field(default_factory=list)
class SkillVersionDeleteResponse(ResponseModel):
id: str
deleted: bool
latest_published_version_id: str | None = None
class SkillReferenceResponse(ResponseModel):
type: str
agent_id: str
agent_icon: str | None = None
agent_icon_background: str | None = None
agent_icon_type: str | None = None
app_id: str | None = None
name: str
display_name: str
workflow_id: str | None = None
workflow_name: str | None = None
workflow_icon: str | None = None
workflow_icon_background: str | None = None
workflow_icon_type: str | None = None
workflow_version: str | None = None
node_id: str | None = None
node_name: str | None = None
class SkillReferenceListResponse(ResponseModel):
data: list[SkillReferenceResponse] = Field(default_factory=list)
class SkillDeleteResponse(ResponseModel):
id: str
deleted: bool
class AgentSkillBindingItemResponse(ResponseModel):
id: str
priority: int
name: str
display_name: str
icon: str
description: str
tags: list[str] = Field(default_factory=list)
status: str
file_count: int
latest_published_version_id: str | None = None
latest_published_at: int | None = None
updated_at: int
class AgentSkillBindingsResponse(ResponseModel):
agent_id: str
skill_ids: list[str] = Field(default_factory=list)
data: list[AgentSkillBindingItemResponse] = Field(default_factory=list)
register_schema_models(
console_ns,
WorkspaceSkillsQuery,
SkillCreatePayload,
SkillAssistMessagePayload,
SkillMetadataPayload,
SkillDraftFileOperationPayload,
SkillDraftTreePayload,
SkillPublishPayload,
SkillRestorePayload,
SkillVersionUpdatePayload,
SkillDeletePayload,
SkillFileQuery,
AgentSkillBindingsPayload,
)
register_response_schema_models(
console_ns,
SkillResponse,
SkillFileResponse,
SkillFilePreviewResponse,
SkillFileUploadResponse,
SkillDetailResponse,
SkillListResponse,
SkillTagResponse,
SkillTagListResponse,
SkillVersionResponse,
SkillVersionListResponse,
SkillVersionDetailResponse,
SkillVersionDeleteResponse,
SkillReferenceResponse,
SkillReferenceListResponse,
SkillDeleteResponse,
AgentSkillBindingItemResponse,
AgentSkillBindingsResponse,
BinaryFileResponse,
)
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, object], int]:
body: dict[str, object] = {"code": exc.code, "message": exc.message}
if exc.details:
body["details"] = exc.details
return body, exc.status_code
@console_ns.route("/workspaces/current/skills")
class WorkspaceSkillsApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkspaceSkillsQuery))
@console_ns.response(200, "Workspace skills", console_ns.models[SkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
query_input: dict[str, object] = {
"keyword": request.args.get("keyword"),
"tag": request.args.getlist("tag"),
}
if "limit" in request.args:
query_input["limit"] = request.args.get("limit")
if "page" in request.args:
query_input["page"] = request.args.get("page")
query = WorkspaceSkillsQuery.model_validate(query_input)
result = SkillManagementService().list_skills(
tenant_id=current_tenant_id,
keyword=query.keyword,
page=query.page,
limit=query.limit,
tags=[tag for tag in query.tag if tag],
)
return dump_response(SkillListResponse, result)
@console_ns.expect(console_ns.models[SkillCreatePayload.__name__])
@console_ns.response(201, "Skill created", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
try:
payload = SkillCreatePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().create_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
payload=payload,
)
return dump_response(SkillDetailResponse, result), 201
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/files/upload")
class WorkspaceSkillFileUploadApi(Resource):
@console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS)
@console_ns.response(201, "Skill draft file uploaded", console_ns.models[SkillFileUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
if "file" not in request.files:
return {"code": "no_file_uploaded", "message": "no file uploaded"}, 400
file = request.files["file"]
if not file.filename:
return {"code": "filename_missing", "message": "filename is required"}, 400
result = SkillManagementService().upload_file(
tenant_id=current_tenant_id,
user_id=current_user.id,
filename=file.filename,
content=file.stream.read(),
mime_type=file.mimetype,
)
return dump_response(SkillFileUploadResponse, result), 201
@console_ns.route("/workspaces/current/skills/tags")
class WorkspaceSkillTagsApi(Resource):
@console_ns.response(200, "Workspace Skill tags", console_ns.models[SkillTagListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
result = SkillManagementService().list_tags(tenant_id=current_tenant_id)
return dump_response(SkillTagListResponse, result)
@console_ns.route("/workspaces/current/skills/import")
class WorkspaceSkillImportApi(Resource):
@console_ns.doc(description="Import a Skill zip package from multipart form field `file`.")
@console_ns.response(201, "Skill imported", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
upload = request.files.get("file")
if upload is None:
return {"code": "invalid_request", "message": "file is required"}, 400
try:
payload = SkillImportPayload(content=upload.read(), filename=upload.filename or "skill.zip")
result = SkillManagementService().import_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
payload=payload,
)
return dump_response(SkillDetailResponse, result), 201
except (ValidationError, ValueError) as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>")
class WorkspaceSkillApi(Resource):
@console_ns.response(200, "Skill detail", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().get_skill(tenant_id=current_tenant_id, skill_id=skill_id)
return dump_response(SkillDetailResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillMetadataPayload.__name__])
@console_ns.response(200, "Skill updated", console_ns.models[SkillResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillMetadataPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().update_metadata(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillDeletePayload.__name__])
@console_ns.response(200, "Skill deleted", console_ns.models[SkillDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, skill_id: str):
try:
payload = SkillDeletePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().delete_skill(
tenant_id=current_tenant_id,
skill_id=skill_id,
confirmation_name=payload.confirmation_name,
)
return dump_response(SkillDeleteResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/duplicate")
class WorkspaceSkillDuplicateApi(Resource):
@console_ns.response(201, "Skill duplicated", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
result = SkillManagementService().duplicate_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
)
return dump_response(SkillDetailResponse, result), 201
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/export")
class WorkspaceSkillExportApi(Resource):
@console_ns.response(200, "Published Skill zip archive")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().pull_published_archive(tenant_id=current_tenant_id, skill_id=skill_id)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/assist/messages")
class WorkspaceSkillAssistMessageApi(Resource):
"""Stream read-only Skill Authoring suggestions from the default workspace model."""
@console_ns.expect(console_ns.models[SkillAssistMessagePayload.__name__])
@console_ns.response(200, "Skill Authoring assistant event stream")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillAssistMessagePayload.model_validate(console_ns.payload or {})
response = SkillManagementService().create_assistant_action_stream(
tenant_id=current_tenant_id,
skill_id=skill_id,
user_id=current_user.id,
message=payload.message,
attachments=payload.attachments,
model_payload=payload.model,
target_path=payload.target_path,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
return helper.compact_generate_response(response)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files")
class WorkspaceSkillFilesApi(Resource):
@console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__])
@console_ns.response(200, "Draft file operation applied", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillDraftFileOperationPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().apply_draft_file_operation(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillDraftTreePayload.__name__])
@console_ns.response(200, "Draft files replaced", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def put(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillDraftTreePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().replace_draft_tree(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/preview")
class WorkspaceSkillFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(SkillFileQuery))
@console_ns.response(200, "Skill file text preview", console_ns.models[SkillFilePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
query = SkillFileQuery.model_validate(
{
"path": request.args.get("path"),
"version_id": request.args.get("version_id"),
}
)
result = SkillManagementService().preview_file(
tenant_id=current_tenant_id,
skill_id=skill_id,
path=query.path,
version_id=query.version_id,
)
return dump_response(SkillFilePreviewResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/content")
class WorkspaceSkillFileContentApi(Resource):
@console_ns.doc(params={**query_params_from_model(SkillFileQuery), "download": "Return as an attachment when 1."})
@console_ns.response(200, "Skill file content", console_ns.models[BinaryFileResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
query = SkillFileQuery.model_validate(
{
"path": request.args.get("path"),
"version_id": request.args.get("version_id"),
}
)
result = SkillManagementService().pull_file(
tenant_id=current_tenant_id,
skill_id=skill_id,
path=query.path,
version_id=query.version_id,
)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=request.args.get("download") == "1",
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/publish")
class WorkspaceSkillPublishApi(Resource):
@console_ns.expect(console_ns.models[SkillPublishPayload.__name__])
@console_ns.response(200, "Skill published", console_ns.models[SkillVersionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillPublishPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().publish_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/restore")
class WorkspaceSkillRestoreApi(Resource):
@console_ns.expect(console_ns.models[SkillRestorePayload.__name__])
@console_ns.response(200, "Skill version restored", console_ns.models[SkillVersionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillRestorePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().restore_version(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/references")
class WorkspaceSkillReferencesApi(Resource):
@console_ns.response(200, "Skill references", console_ns.models[SkillReferenceListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().list_skill_references(tenant_id=current_tenant_id, skill_id=skill_id)
return dump_response(SkillReferenceListResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions")
class WorkspaceSkillVersionsApi(Resource):
@console_ns.response(200, "Skill versions", console_ns.models[SkillVersionListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().list_versions(tenant_id=current_tenant_id, skill_id=skill_id)
return dump_response(SkillVersionListResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions/<string:version_id>")
class WorkspaceSkillVersionApi(Resource):
@console_ns.response(200, "Skill version detail", console_ns.models[SkillVersionDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str, version_id: str):
try:
result = SkillManagementService().get_version(
tenant_id=current_tenant_id,
skill_id=skill_id,
version_id=version_id,
)
return dump_response(SkillVersionDetailResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillVersionUpdatePayload.__name__])
@console_ns.response(200, "Skill version updated", console_ns.models[SkillVersionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def patch(self, current_tenant_id: str, skill_id: str, version_id: str):
try:
payload = SkillVersionUpdatePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().update_version(
tenant_id=current_tenant_id,
skill_id=skill_id,
version_id=version_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.response(200, "Skill version deleted", console_ns.models[SkillVersionDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def delete(self, current_tenant_id: str, current_user: Account, skill_id: str, version_id: str):
try:
result = SkillManagementService().delete_version(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
version_id=version_id,
)
return dump_response(SkillVersionDeleteResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/agents/<string:agent_id>/skills")
class WorkspaceAgentSkillBindingsApi(Resource):
@console_ns.response(200, "Agent Skill bindings", console_ns.models[AgentSkillBindingsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: str):
result = SkillManagementService().list_agent_bindings(tenant_id=current_tenant_id, agent_id=agent_id)
return dump_response(AgentSkillBindingsResponse, result)
@console_ns.expect(console_ns.models[AgentSkillBindingsPayload.__name__])
@console_ns.response(200, "Agent Skill bindings replaced", console_ns.models[AgentSkillBindingsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def put(self, current_tenant_id: str, current_user: Account, agent_id: str):
try:
payload = AgentSkillBindingsPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().replace_agent_bindings(
tenant_id=current_tenant_id,
user_id=current_user.id,
agent_id=agent_id,
skill_ids=payload.skill_ids,
)
return dump_response(AgentSkillBindingsResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
__all__ = [
"WorkspaceAgentSkillBindingsApi",
"WorkspaceSkillApi",
"WorkspaceSkillDuplicateApi",
"WorkspaceSkillExportApi",
"WorkspaceSkillFilesApi",
"WorkspaceSkillImportApi",
"WorkspaceSkillPublishApi",
"WorkspaceSkillReferencesApi",
"WorkspaceSkillRestoreApi",
"WorkspaceSkillTagsApi",
"WorkspaceSkillVersionApi",
"WorkspaceSkillVersionsApi",
"WorkspaceSkillsApi",
]
+2 -2
View File
@@ -23,7 +23,7 @@ from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_config as _agent_config
from .plugin import agent_drive as _agent_drive
from .plugin import plugin as _plugin
from .plugin import skills as _skills
from .workspace import plugin_model_providers as _plugin_model_providers
from .workspace import workspace as _workspace
api.add_namespace(inner_api_ns)
@@ -36,8 +36,8 @@ __all__ = [
"_knowledge_retrieval",
"_mail",
"_plugin",
"_plugin_model_providers",
"_runtime_credentials",
"_skills",
"_workspace",
"api",
"bp",
@@ -1,54 +0,0 @@
"""Inner API for published workspace Skills.
These endpoints are called by trusted runtime services. They expose only
published Skill artifacts, never draft files or editable metadata.
"""
from __future__ import annotations
import io
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ValidationError
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import plugin_inner_api_only
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
class _SkillTargetQuery(BaseModel):
tenant_id: str
def _target_query_from_request() -> _SkillTargetQuery:
return _SkillTargetQuery.model_validate({"tenant_id": request.args.get("tenant_id")})
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, str], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
@inner_api_ns.route("/skills/<string:skill_id>/pull")
class PublishedSkillPullApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("published_skill_pull")
def get(self, skill_id: str):
try:
query = _target_query_from_request()
result = SkillManagementService().pull_published_archive(tenant_id=query.tenant_id, skill_id=skill_id)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
__all__ = ["PublishedSkillPullApi"]
@@ -0,0 +1,39 @@
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from core.plugin.plugin_service import PluginService
class InvalidatePluginModelProvidersCachePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated")
register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload)
@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate")
class EnterprisePluginModelProvidersCacheInvalidate(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_invalidate_plugin_model_providers_cache",
responses={
200: "Cache invalidated",
400: "Invalid request",
401: "Unauthorized - invalid API key",
},
)
@inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__])
def post(self):
args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {})
for tenant_id in args.tenant_ids:
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
return {"result": "success"}, 200
@@ -538,7 +538,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider),
agent_backend_client=create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
@@ -43,7 +43,6 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
build_config_layer_config,
build_knowledge_layer_config,
build_shell_layer_config,
load_runtime_agent_skill_configs,
)
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
from models.provider_ids import ModelProviderID
@@ -126,22 +125,14 @@ class AgentAppRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
runtime_config_skills = load_runtime_agent_skill_configs(
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
)
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent_id,
config_version_id=context.agent_config_snapshot_id,
config_version_kind=context.agent_config_version_kind,
runtime_config_skills=runtime_config_skills,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
agent_soul,
runtime_config_skills=runtime_config_skills,
)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
knowledge_config = build_knowledge_layer_config(agent_soul)
request = self._request_builder.build_for_agent_app(
+17 -1
View File
@@ -12,7 +12,7 @@ from core.agent.plugin_entities import AgentProviderEntityWithPlugin
from core.datasource.entities.datasource_entities import DatasourceProviderEntityWithPlugin
from core.plugin.entities.base import BasePluginEntity
from core.plugin.entities.parameters import PluginParameterOption
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallationSource
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin
from core.trigger.entities.entities import TriggerProviderEntity
@@ -94,6 +94,18 @@ class PluginModelProviderEntity(BaseModel):
declaration: ProviderEntity = Field(description="The declaration of the model provider.")
class PluginModelProviderBinding(BaseModel):
"""Lightweight installation metadata for one model provider."""
provider: str
installation_id: str
plugin_id: str
plugin_unique_identifier: str
runtime_type: str
source: PluginInstallationSource
version: str
class PluginTextEmbeddingNumTokensResponse(BaseModel):
"""
Response for number of tokens.
@@ -207,6 +219,10 @@ class PluginListResponse(BaseModel):
total: int
class PluginInstalledIdsDaemonResponse(BaseModel):
plugin_ids: list[str]
class PluginListWithoutTotalResponse(BaseModel):
list: list[PluginEntity]
has_more: bool
+9
View File
@@ -6,6 +6,7 @@ from core.plugin.entities.plugin_daemon import (
PluginBasicBooleanResponse,
PluginDaemonInnerError,
PluginLLMNumTokensResponse,
PluginModelProviderBinding,
PluginModelProviderEntity,
PluginModelSchemaEntity,
PluginStringResultResponse,
@@ -47,6 +48,14 @@ class PluginModelClient(BasePluginClient):
)
return response
def fetch_model_provider_bindings(self, tenant_id: str) -> Sequence[PluginModelProviderBinding]:
"""Fetch only model-provider installation identities from the daemon."""
return self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/models/bindings",
list[PluginModelProviderBinding],
)
def get_model_schema(
self,
tenant_id: str,
+28 -2
View File
@@ -14,6 +14,7 @@ from core.plugin.entities.plugin import (
)
from core.plugin.entities.plugin_daemon import (
PluginDecodeResponse,
PluginInstalledIdsDaemonResponse,
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
@@ -68,6 +69,16 @@ class PluginInstaller(BasePluginClient):
)
return result.list
def list_installed_plugin_ids(self, tenant_id: str, category: PluginCategory) -> list[str]:
"""List all currently installed plugin IDs in one category."""
result = self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/installation/ids",
PluginInstalledIdsDaemonResponse,
params={"category": category.value},
)
return result.plugin_ids
def list_plugins_with_total(self, tenant_id: str, page: int, page_size: int) -> PluginListResponse:
return self._request_with_plugin_daemon_response(
"GET",
@@ -77,13 +88,28 @@ class PluginInstaller(BasePluginClient):
)
def list_plugins_by_category(
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
self,
tenant_id: str,
category: PluginCategory,
page: int,
page_size: int,
*,
query: str = "",
tags: Sequence[str] = (),
language: str = "en_US",
) -> PluginListWithoutTotalResponse:
return self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/{category.value}/list",
PluginListWithoutTotalResponse,
params={"page": page, "page_size": page_size, "response_type": "paged"},
params={
"page": page,
"page_size": page_size,
"response_type": "paged",
"query": query,
"tags": list(tags),
"language": language,
},
)
def upload_pkg(
+73 -45
View File
@@ -48,6 +48,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTaskStatus,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginModelProviderBinding,
PluginModelProviderEntity,
PluginVerification,
)
@@ -66,7 +67,7 @@ from services.enterprise.plugin_manager_service import (
PreUninstallPluginRequest,
)
from services.errors.plugin import PluginInstallationForbiddenError
from services.feature_service import FeatureService, PluginInstallationPermissionModel, PluginInstallationScope
from services.feature_service import FeatureService, PluginInstallationScope
logger = logging.getLogger(__name__)
_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity])
@@ -78,6 +79,12 @@ class _RedisLock(Protocol):
def release(self) -> None: ...
class _ModelPluginIdentity(Protocol):
plugin_id: str
plugin_unique_identifier: str
source: PluginInstallationSource
class PluginService:
class LatestPluginCache(BaseModel):
plugin_id: str
@@ -265,7 +272,7 @@ class PluginService:
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
@classmethod
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[PluginEntity]) -> str | None:
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[_ModelPluginIdentity]) -> str | None:
remote_model_plugins = sorted(
f"{plugin.plugin_id}:{plugin.plugin_unique_identifier}"
for plugin in plugins
@@ -350,7 +357,7 @@ class PluginService:
def _should_invalidate_model_provider_cache_for_remote_model_plugins(
cls,
tenant_id: str,
plugins: Sequence[PluginEntity],
plugins: Sequence[_ModelPluginIdentity],
) -> bool:
remote_model_plugin_marker = cls._get_remote_model_plugin_cache_marker(plugins)
cached_remote_model_plugin_marker = cls._load_cached_remote_model_plugin_marker(tenant_id)
@@ -434,18 +441,14 @@ class PluginService:
exc_info=True,
)
@classmethod
def _fetch_plugin_model_providers_uncached(
cls, tenant_id: str, client: PluginModelClient | None
) -> tuple[ProviderEntity, ...]:
model_client = client or PluginModelClient()
return tuple(cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id))
@classmethod
def _fetch_and_cache_plugin_model_providers(
cls, tenant_id: str, client: PluginModelClient | None, *, refresh_generation: int | None
) -> tuple[ProviderEntity, ...]:
providers = cls._fetch_plugin_model_providers_uncached(tenant_id, client)
model_client = client or PluginModelClient()
providers = tuple(
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
)
generation = cls._load_plugin_model_providers_generation(tenant_id)
if generation is not None and generation == refresh_generation:
cls._store_cached_plugin_model_providers(tenant_id, generation, providers)
@@ -475,9 +478,6 @@ class PluginService:
are intentionally owned by this service so tenant isolation and cache
expiry are handled in one place.
"""
if not dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED:
return cls._fetch_plugin_model_providers_uncached(tenant_id, client)
deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT
while True:
@@ -604,30 +604,22 @@ class PluginService:
return result
@staticmethod
def _check_marketplace_only_permission() -> None:
def _check_marketplace_only_permission():
"""
Check if the marketplace only permission is enabled
"""
permission = PluginService._get_plugin_installation_permission()
if permission.restrict_to_marketplace_only:
features = FeatureService.get_system_features()
if features.plugin_installation_permission.restrict_to_marketplace_only:
raise PluginInstallationForbiddenError("Plugin installation is restricted to marketplace only")
@staticmethod
def _get_plugin_installation_permission() -> PluginInstallationPermissionModel:
"""Resolve the validated policy and reject deny-all before any installation side effect."""
permission = FeatureService.get_plugin_installation_permission()
if permission.plugin_installation_scope == PluginInstallationScope.NONE:
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
return permission
@staticmethod
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None) -> None:
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None):
"""
Check the plugin installation scope
"""
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
match permission.plugin_installation_scope:
match features.plugin_installation_permission.plugin_installation_scope:
case PluginInstallationScope.OFFICIAL_ONLY:
if (
plugin_verification is None
@@ -642,10 +634,10 @@ class PluginService:
raise PluginInstallationForbiddenError(
"Plugin installation is restricted to official and specific partners"
)
case PluginInstallationScope.NONE:
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
case PluginInstallationScope.ALL:
pass
case _:
raise PluginInstallationForbiddenError("Plugin installation policy is invalid")
@staticmethod
def get_debugging_key(tenant_id: str) -> str:
@@ -671,6 +663,26 @@ class PluginService:
plugins = manager.list_plugins(tenant_id)
return plugins
@staticmethod
def list_installed_plugin_ids(tenant_id: str, category: PluginCategory) -> Sequence[str]:
"""List all currently installed plugin IDs in one category through the daemon's lightweight query."""
manager = PluginInstaller()
return manager.list_installed_plugin_ids(tenant_id, category)
@staticmethod
def list_model_provider_bindings(
tenant_id: str, *, client: PluginModelClient | None = None
) -> Sequence[PluginModelProviderBinding]:
"""Return fresh model bindings and reconcile remote-debug provider metadata before it is read."""
model_client = client or PluginModelClient()
bindings = model_client.fetch_model_provider_bindings(tenant_id)
if PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(tenant_id, bindings):
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
marker = PluginService._get_remote_model_plugin_cache_marker(bindings)
PluginService._store_cached_remote_model_plugin_marker(tenant_id, marker)
return bindings
@staticmethod
def list_with_total(tenant_id: str, user_id: str, page: int, page_size: int) -> PluginListResponse:
"""List tenant plugins with endpoint counts reconciled from live records.
@@ -687,17 +699,33 @@ class PluginService:
@staticmethod
def list_by_category(
tenant_id: str, category: PluginCategory, page: int, page_size: int
tenant_id: str,
category: PluginCategory,
page: int,
page_size: int,
*,
query: str = "",
tags: Sequence[str] = (),
language: str = "en_US",
) -> PluginListWithoutTotalResponse:
"""
List plugins in one category with a has-more cursor signal and without calculating total.
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
This keeps pagination usable before category is persisted on installation rows.
The daemon applies category, search, and tag filters before pagination, then stops once it finds one extra
match. Only a complete, unfiltered first page may reconcile the model-provider cache; the unpaginated model
binding read is the authoritative marker source for larger result sets.
"""
manager = PluginInstaller()
plugins = manager.list_plugins_by_category(tenant_id, category, page, page_size)
if category == PluginCategory.Model:
plugins = manager.list_plugins_by_category(
tenant_id,
category,
page,
page_size,
query=query,
tags=tags,
language=language,
)
if category == PluginCategory.Model and page == 1 and not plugins.has_more and not query and not tags:
should_invalidate_model_provider_cache = (
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
tenant_id,
@@ -915,7 +943,7 @@ class PluginService:
# check if plugin pkg is already downloaded
manager = PluginInstaller()
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
try:
manager.fetch_plugin_manifest(tenant_id, new_plugin_unique_identifier)
@@ -927,7 +955,7 @@ class PluginService:
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
# check if the plugin is available to install
@@ -982,11 +1010,11 @@ class PluginService:
"""
PluginService._check_marketplace_only_permission()
manager = PluginInstaller()
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
PluginService._check_plugin_installation_scope(response.verification)
@@ -1004,13 +1032,13 @@ class PluginService:
pkg = download_with_size_limit(
f"https://github.com/{repo}/releases/download/{version}/{package}", dify_config.PLUGIN_MAX_PACKAGE_SIZE
)
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
manager = PluginInstaller()
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
PluginService._check_plugin_installation_scope(response.verification)
@@ -1084,7 +1112,7 @@ class PluginService:
if not dify_config.MARKETPLACE_ENABLED:
raise ValueError("marketplace is not enabled")
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
manager = PluginInstaller()
try:
@@ -1094,7 +1122,7 @@ class PluginService:
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
# check if the plugin is available to install
PluginService._check_plugin_installation_scope(response.verification)
@@ -1116,7 +1144,7 @@ class PluginService:
# collect actual plugin_unique_identifiers
actual_plugin_unique_identifiers = []
metas = []
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
# check if already downloaded
for plugin_unique_identifier in plugin_unique_identifiers:
@@ -1134,7 +1162,7 @@ class PluginService:
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
# check if the plugin is available to install
PluginService._check_plugin_installation_scope(response.verification)
@@ -2023,8 +2023,6 @@ class DatasetRetrieval:
redis_client.zremrangebyscore(key, 0, current_time - 60000)
request_count = redis_client.zcard(key)
if request_count > knowledge_rate_limit.limit:
# The rate-limit exception is raised after this block, so commit the audit row
# explicitly instead of relying on the Session context, which only closes it.
with session_factory.create_session() as session:
rate_limit_log = RateLimitLog(
tenant_id=tenant_id,
@@ -2032,7 +2030,6 @@ class DatasetRetrieval:
operation="knowledge",
)
session.add(rate_limit_log)
session.commit()
raise exc.RateLimitExceededError(
"you have reached the knowledge base request rate limit of your subscription."
)
-2
View File
@@ -107,8 +107,6 @@ class MCPTool(Tool):
if self.entity.output_schema and result.structuredContent:
for k, v in result.structuredContent.items():
yield self.create_variable_message(k, v)
elif result.structuredContent:
yield self.create_json_message(result.structuredContent)
def _process_text_content(self, content: TextContent) -> Generator[ToolInvokeMessage, None, None]:
"""Process text content and yield appropriate messages."""
-1
View File
@@ -497,7 +497,6 @@ class DifyNodeFactory(NodeFactory):
),
"agent_backend_client": create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
@@ -38,7 +38,6 @@ from dify_agent.layers.shell import (
)
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
from pydantic import BaseModel, ValidationError
from sqlalchemy.exc import OperationalError
from clients.agent_backend import (
AgentBackendModelConfig,
@@ -207,22 +206,14 @@ class WorkflowAgentRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
runtime_config_skills = load_runtime_agent_skill_configs(
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent.id,
)
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent.id,
config_version_id=context.snapshot.id,
config_version_kind="snapshot",
runtime_config_skills=runtime_config_skills,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
agent_soul,
runtime_config_skills=runtime_config_skills,
)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
knowledge_config = build_knowledge_layer_config(agent_soul)
@@ -892,16 +883,11 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
existing.extend(warnings)
def build_config_aware_soul_mention_resolver(
agent_soul: AgentSoulConfig,
*,
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
):
def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
"""Resolve config skill/file mentions and delegate the rest to Agent Soul."""
base_resolver = build_soul_mention_resolver(agent_soul)
skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
skill_names.update(item.name for item in runtime_config_skills)
file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
def _resolve(mention: object) -> str | None:
@@ -919,34 +905,12 @@ def build_config_aware_soul_mention_resolver(
return _resolve
def load_runtime_agent_skill_configs(*, tenant_id: str, agent_id: str) -> list[DifyConfigSkillConfig]:
"""Return workspace-bound Skills as prompt-safe runtime config skills."""
from services.skill_management_service import SkillManagementService
try:
runtime_skills = SkillManagementService().list_runtime_agent_skills(tenant_id=tenant_id, agent_id=agent_id)
except OperationalError as exc:
if "no such table: agent_skill_bindings" not in str(exc.orig):
raise
runtime_skills = []
return [
DifyConfigSkillConfig(
name=str(item["name"]),
description=str(item.get("description") or ""),
size=cast(int | None, item.get("size")),
mime_type=cast(str | None, item.get("mime_type")),
)
for item in runtime_skills
]
def build_config_layer_config(
agent_soul: AgentSoulConfig,
*,
agent_id: str | None = None,
config_version_id: str | None = None,
config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]:
"""Build the always-present Agent config layer from Agent Soul state.
@@ -963,23 +927,8 @@ def build_config_layer_config(
)
)
available_skills = [skill for skill in agent_soul.config_skills if not skill.is_missing]
skill_configs = [
DifyConfigSkillConfig(
name=skill.name,
description=skill.description,
size=skill.size,
mime_type=skill.mime_type,
)
for skill in available_skills
]
seen_skill_names = {skill.name for skill in skill_configs}
for skill in runtime_config_skills:
if skill.name in seen_skill_names:
continue
seen_skill_names.add(skill.name)
skill_configs.append(skill)
available_files = [file_ref for file_ref in agent_soul.config_files if not file_ref.is_missing]
skill_names = {skill.name for skill in skill_configs}
skill_names = {skill.name for skill in available_skills}
file_names = {file_ref.name for file_ref in available_files}
warnings: list[dict[str, str]] = [
{
@@ -1016,7 +965,15 @@ def build_config_layer_config(
kind=config_version_kind,
writable=config_version_kind == "build_draft",
),
skills=skill_configs,
skills=[
DifyConfigSkillConfig(
name=skill.name,
description=skill.description,
size=skill.size,
mime_type=skill.mime_type,
)
for skill in available_skills
],
files=[
DifyConfigFileConfig(
name=file_ref.name,
+1 -5
View File
@@ -289,11 +289,7 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)]
def alphanumeric(value: str):
# check if the value is alphanumeric and underlined
# Use re.fullmatch instead of re.match to reject trailing newlines.
# In Python, '$' matches at end-of-string OR just before a trailing newline,
# so re.match accepts "tool_name\n". re.fullmatch requires the entire
# string to match. Regression for #39666 (sibling of #39234 / #39548).
if re.fullmatch(r"^[a-zA-Z0-9_]+$", value):
if re.match(r"^[a-zA-Z0-9_]+$", value):
return value
raise ValueError(f"{value} is not a valid alphanumeric value")
@@ -1,114 +0,0 @@
"""add workspace skill management
Revision ID: a4f8d2c9e1b0
Revises: 6f5a9c2d8e1b
Create Date: 2026-07-09 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
from models.types import StringUUID
# revision identifiers, used by Alembic.
revision = "a4f8d2c9e1b0"
down_revision = "6f5a9c2d8e1b"
branch_labels = None
depends_on = None
def _uuid_column(name: str, *, nullable: bool = False) -> sa.Column:
return sa.Column(name, StringUUID(), nullable=nullable)
def _long_text() -> sa.types.TypeEngine:
return sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
def upgrade() -> None:
op.create_table(
"skills",
_uuid_column("id"),
_uuid_column("tenant_id"),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("display_name", sa.String(length=128), nullable=False),
sa.Column("icon", sa.String(length=16), nullable=False, server_default="📄"),
sa.Column("description", sa.String(length=1024), nullable=False, server_default=""),
sa.Column("name_manually_edited", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("visibility", sa.String(length=32), nullable=False, server_default="workspace"),
_uuid_column("latest_published_version_id", nullable=True),
_uuid_column("created_by", nullable=True),
_uuid_column("updated_by", nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
sa.UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
)
op.create_index("skills_tenant_updated_at_idx", "skills", ["tenant_id", "updated_at"])
op.create_table(
"skill_draft_files",
_uuid_column("id"),
_uuid_column("skill_id"),
sa.Column("path", sa.String(length=512), nullable=False),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("storage", sa.String(length=32), nullable=True),
sa.Column("mime_type", sa.String(length=255), nullable=True),
sa.Column("content_text", _long_text(), nullable=True),
_uuid_column("tool_file_id", nullable=True),
sa.Column("size", sa.BigInteger(), nullable=True),
sa.Column("hash", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
sa.UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
)
op.create_index("skill_draft_files_skill_path_idx", "skill_draft_files", ["skill_id", "path"])
op.create_table(
"skill_versions",
_uuid_column("id"),
_uuid_column("skill_id"),
sa.Column("version_number", sa.Integer(), nullable=False),
sa.Column("version_name", sa.String(length=128), nullable=False, server_default=""),
sa.Column("publish_note", sa.String(length=1024), nullable=False, server_default=""),
sa.Column("manifest", _long_text(), nullable=False),
_uuid_column("archive_tool_file_id"),
sa.Column("hash_code", sa.String(length=255), nullable=False),
sa.Column("archive_size", sa.BigInteger(), nullable=False),
_uuid_column("published_by", nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
sa.UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
)
op.create_index("skill_versions_skill_created_at_idx", "skill_versions", ["skill_id", "created_at"])
op.create_table(
"agent_skill_bindings",
_uuid_column("id"),
_uuid_column("tenant_id"),
_uuid_column("agent_id"),
_uuid_column("skill_id"),
sa.Column("priority", sa.Integer(), nullable=False),
_uuid_column("created_by", nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
sa.UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
sa.UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
)
op.create_index("agent_skill_bindings_skill_idx", "agent_skill_bindings", ["tenant_id", "skill_id"])
def downgrade() -> None:
op.drop_index("agent_skill_bindings_skill_idx", table_name="agent_skill_bindings")
op.drop_table("agent_skill_bindings")
op.drop_index("skill_versions_skill_created_at_idx", table_name="skill_versions")
op.drop_table("skill_versions")
op.drop_index("skill_draft_files_skill_path_idx", table_name="skill_draft_files")
op.drop_table("skill_draft_files")
op.drop_index("skills_tenant_updated_at_idx", table_name="skills")
op.drop_table("skills")
@@ -18,109 +18,31 @@ branch_labels = None
depends_on = None
def _is_pg(conn) -> bool:
return conn.dialect.name == "postgresql"
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
kwargs = {"nullable": nullable, "primary_key": primary_key}
if primary_key and _is_pg(op.get_bind()):
kwargs["server_default"] = sa.text("uuidv7()")
return sa.Column(name, models.types.StringUUID(), **kwargs)
def _has_table(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _has_column(table_name: str, column_name: str) -> bool:
return any(
column["name"] == column_name for column in sa.inspect(op.get_bind()).get_columns(table_name)
)
def _has_unique_constraint(table_name: str, constraint_name: str) -> bool:
return any(
constraint["name"] == constraint_name
for constraint in sa.inspect(op.get_bind()).get_unique_constraints(table_name)
)
def upgrade():
if not _has_table("agent_debug_conversations"):
op.create_table(
"agent_debug_conversations",
_uuid_column("id", primary_key=True),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=False),
sa.Column("conversation_id", models.types.StringUUID(), nullable=False),
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("agent_debug_conversation_pkey")),
sa.UniqueConstraint(
"tenant_id",
"agent_id",
"account_id",
"draft_type",
name=op.f("agent_debug_conversation_agent_account_draft_type_unique"),
),
)
op.create_index(
"agent_debug_conversation_conversation_idx",
"agent_debug_conversations",
["conversation_id"],
)
op.create_index(
"agent_debug_conversation_account_idx",
"agent_debug_conversations",
["tenant_id", "account_id"],
)
return
# Existing pointers have always represented Build chat because the Agent
# detail API exposes them as ``debug_conversation_id`` for that surface.
if not _has_column("agent_debug_conversations", "draft_type"):
op.add_column(
"agent_debug_conversations",
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
)
if _has_unique_constraint(
op.add_column(
"agent_debug_conversations",
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
)
op.drop_constraint(
"agent_debug_conversation_agent_account_unique",
):
op.drop_constraint(
"agent_debug_conversation_agent_account_unique",
"agent_debug_conversations",
type_="unique",
)
if not _has_unique_constraint(
"agent_debug_conversations",
type_="unique",
)
op.create_unique_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
):
op.create_unique_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id", "draft_type"],
)
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id", "draft_type"],
)
def downgrade():
if not _has_table("agent_debug_conversations"):
return
debug_conversations = sa.table(
"agent_debug_conversations",
sa.column("tenant_id", models.types.StringUUID()),
@@ -142,23 +64,14 @@ def downgrade():
),
)
)
if _has_unique_constraint(
"agent_debug_conversations",
op.drop_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
):
op.drop_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
"agent_debug_conversations",
type_="unique",
)
if not _has_unique_constraint(
"agent_debug_conversations",
type_="unique",
)
op.create_unique_constraint(
"agent_debug_conversation_agent_account_unique",
):
op.create_unique_constraint(
"agent_debug_conversation_agent_account_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id"],
)
if _has_column("agent_debug_conversations", "draft_type"):
op.drop_column("agent_debug_conversations", "draft_type")
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id"],
)
op.drop_column("agent_debug_conversations", "draft_type")
@@ -1,21 +0,0 @@
"""merge skill and agent debug conversation heads
Revision ID: e9f4a1b2c3d5
Revises: a4f8d2c9e1b0, d2825e7b9c10
Create Date: 2026-07-23 15:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = "e9f4a1b2c3d5"
down_revision = ("a4f8d2c9e1b0", "d2825e7b9c10")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
-7
View File
@@ -113,7 +113,6 @@ from .provider import (
TenantDefaultModel,
TenantPreferredModelProvider,
)
from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, SkillFileStorage, SkillVersion
from .snippet import CustomizedSnippet, SnippetType
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from .task import CeleryTask, CeleryTaskSet
@@ -174,7 +173,6 @@ __all__ = [
"AgentRuntimeSessionOwnerType",
"AgentRuntimeSessionStatus",
"AgentScope",
"AgentSkillBinding",
"AgentSource",
"AgentStatus",
"ApiRequest",
@@ -248,11 +246,6 @@ __all__ = [
"RecommendedApp",
"SavedMessage",
"Site",
"Skill",
"SkillDraftFile",
"SkillFileKind",
"SkillFileStorage",
"SkillVersion",
"SnippetType",
"Tag",
"TagBinding",
-1
View File
@@ -249,7 +249,6 @@ class TagType(StrEnum):
KNOWLEDGE = "knowledge"
APP = "app"
SNIPPET = "snippet"
SKILL = "skill"
class DatasetMetadataType(StrEnum):
+3 -3
View File
@@ -1117,14 +1117,14 @@ class ExporleBanner(TypeBase):
status: Mapped[BannerStatus] = mapped_column(
EnumText(BannerStatus, length=255),
nullable=False,
server_default=sa.text("'enabled'"),
server_default=sa.text("'enabled'::character varying"),
default=BannerStatus.ENABLED,
)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
language: Mapped[str] = mapped_column(
String(255), nullable=False, server_default=sa.text("'en-US'"), default="en-US"
String(255), nullable=False, server_default=sa.text("'en-US'::character varying"), default="en-US"
)
@@ -2667,7 +2667,7 @@ class Tag(TypeBase):
sa.Index("tag_name_idx", "name"),
)
TAG_TYPE_LIST = ["knowledge", "app", "snippet", "skill"]
TAG_TYPE_LIST = ["knowledge", "app", "snippet"]
id: Mapped[str] = mapped_column(
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
-164
View File
@@ -1,164 +0,0 @@
"""Workspace-level Skill Management models.
These tables are the source of truth for reusable workspace Skills. Agent Soul
``config_skills`` and Agent Drive skill rows remain per-agent runtime/config
assets; they may consume a published Skill snapshot but do not own the Skill's
draft, metadata, version history, or Agent binding priority.
"""
from enum import StrEnum
import sqlalchemy as sa
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Index, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base, DefaultFieldsMixin
from models.types import EnumText, JSONModelColumn, LongText, StringUUID
class SkillFileKind(StrEnum):
"""Draft file entry kind."""
FILE = "file"
DIRECTORY = "directory"
class SkillFileStorage(StrEnum):
"""How a draft file's content is stored."""
TEXT = "text"
TOOL_FILE = "tool_file"
class SkillVersionManifestFile(BaseModel):
"""One file entry captured in a published Skill snapshot manifest."""
path: str
mime_type: str | None = None
size: int
hash: str
model_config = ConfigDict(extra="forbid")
class SkillVersionManifest(BaseModel):
"""Published Skill snapshot file index."""
files: list[SkillVersionManifestFile]
model_config = ConfigDict(extra="forbid")
class Skill(DefaultFieldsMixin, Base):
"""Workspace-level reusable Skill metadata and draft status."""
__tablename__ = "skills"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
Index("skills_tenant_updated_at_idx", "tenant_id", "updated_at"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
name: Mapped[str] = mapped_column(sa.String(64), nullable=False)
display_name: Mapped[str] = mapped_column(sa.String(128), nullable=False)
icon: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="📄", server_default="📄")
description: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
name_manually_edited: Mapped[bool] = mapped_column(
sa.Boolean,
nullable=False,
default=False,
server_default=sa.false(),
)
visibility: Mapped[str] = mapped_column(
sa.String(32),
nullable=False,
default="workspace",
server_default="workspace",
)
latest_published_version_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
class SkillDraftFile(DefaultFieldsMixin, Base):
"""One draft file or directory in a workspace Skill."""
__tablename__ = "skill_draft_files"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
Index("skill_draft_files_skill_path_idx", "skill_id", "path"),
)
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
path: Mapped[str] = mapped_column(sa.String(512), nullable=False)
kind: Mapped[SkillFileKind] = mapped_column(EnumText(SkillFileKind, length=32), nullable=False)
storage: Mapped[SkillFileStorage | None] = mapped_column(EnumText(SkillFileStorage, length=32), nullable=True)
mime_type: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
content_text: Mapped[str | None] = mapped_column(LongText, nullable=True)
tool_file_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
hash: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
class SkillVersion(DefaultFieldsMixin, Base):
"""Immutable published Skill snapshot.
``hash_code`` uniquely identifies a published version for downstream
execution audit. It includes Skill identity, version number, and archive
content digest instead of being only the archive content hash.
"""
__tablename__ = "skill_versions"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
Index("skill_versions_skill_created_at_idx", "skill_id", "created_at"),
)
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
version_number: Mapped[int] = mapped_column(sa.Integer, nullable=False)
version_name: Mapped[str] = mapped_column(sa.String(128), nullable=False, default="", server_default="")
publish_note: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
manifest: Mapped[SkillVersionManifest] = mapped_column(JSONModelColumn(SkillVersionManifest), nullable=False)
archive_tool_file_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
hash_code: Mapped[str] = mapped_column(sa.String(255), nullable=False)
archive_size: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
published_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
class AgentSkillBinding(DefaultFieldsMixin, Base):
"""Direct Agent-to-workspace-Skill binding.
``priority`` is retained as an internal ordering column for the current
schema constraints. Runtime Skill selection is Agent-driven and must not
treat it as a matching priority.
"""
__tablename__ = "agent_skill_bindings"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
Index("agent_skill_bindings_skill_idx", "tenant_id", "skill_id"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
priority: Mapped[int] = mapped_column(sa.Integer, nullable=False)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
__all__ = [
"AgentSkillBinding",
"Skill",
"SkillDraftFile",
"SkillFileKind",
"SkillFileStorage",
"SkillVersion",
"SkillVersionManifest",
"SkillVersionManifestFile",
]
+93 -767
View File
@@ -1672,23 +1672,6 @@ Create a new application
| 200 | Import confirmed | **application/json**: [Import](#import)<br> |
| 400 | Import failed | **application/json**: [Import](#import)<br> |
### [GET] /apps/recent
**Return the lightweight app cards needed by the Explore home page**
Get recently modified apps for the home Continue Work section
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| limit | query | Number of recently modified apps to return (1-8) | No | integer, <br>**Default:** 8 |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [RecentAppListResponse](#recentapplistresponse)<br> |
### [GET] /apps/starred
Get applications starred by the current account
@@ -9551,7 +9534,7 @@ Remove one or more tag bindings from a target.
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Search keyword | No | string |
| type | query | Tag type filter | No | string |
| type | query | Tag type filter | No | string, <br>**Available values:** "", "app", "knowledge", "snippet" |
#### Responses
@@ -10087,38 +10070,6 @@ Get list of available agent providers
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AgentProviderListResponse](#agentproviderlistresponse)<br> |
### [GET] /workspaces/current/agents/{agent_id}/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent Skill bindings | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
### [PUT] /workspaces/current/agents/{agent_id}/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentSkillBindingsPayload](#agentskillbindingspayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent Skill bindings replaced | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
### [GET] /workspaces/current/customized-snippets
**List customized snippets with pagination and search**
@@ -10616,6 +10567,13 @@ Update a plugin endpoint
| ---- | ----------- | ------ |
| 200 | Model providers retrieved successfully | **application/json**: [ModelProviderListResponse](#modelproviderlistresponse)<br> |
### [GET] /workspaces/current/model-providers/summary
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Model provider summaries retrieved successfully | **application/json**: [ModelProviderSummaryListResponse](#modelprovidersummarylistresponse)<br> |
### [GET] /workspaces/current/model-providers/{provider}/checkout-url
#### Parameters
@@ -11161,6 +11119,19 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginInstallTaskStartResponse](#plugininstalltaskstartresponse)<br> |
### [GET] /workspaces/current/plugin/installed-ids
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| category | query | Plugin category to include | Yes | string, <br>**Available values:** "agent-strategy", "datasource", "extension", "model", "tool", "trigger" |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginInstalledIdsResponse](#plugininstalledidsresponse)<br> |
### [GET] /workspaces/current/plugin/list
#### Parameters
@@ -11419,8 +11390,11 @@ Returns permission flags that control workspace features like member invitations
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| language | query | Language used for localized label and description search | No | string, <br>**Available values:** "en_US", "ja_JP", "pt_BR", "zh_Hans", <br>**Default:** en_US |
| page | query | Page number | No | integer, <br>**Default:** 1 |
| page_size | query | Page size (1-256) | No | integer, <br>**Default:** 256 |
| query | query | Case-insensitive search query | No | string |
| tags | query | Match any plugin tag | No | [ string ] |
| category | path | | Yes | string |
#### Responses
@@ -12020,341 +11994,6 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)<br> |
### [GET] /workspaces/current/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Search keyword matching skill name or description. | No | string |
| limit | query | Number of items per page. | No | integer, <br>**Default:** 20 |
| page | query | Page number. | No | integer, <br>**Default:** 1 |
| tag | query | Skill tag filters. Repeat the parameter for multiple tags. | No | [ string ] |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace skills | **application/json**: [SkillListResponse](#skilllistresponse)<br> |
### [POST] /workspaces/current/skills
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillCreatePayload](#skillcreatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill created | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [POST] /workspaces/current/skills/files/upload
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill draft file uploaded | **application/json**: [SkillFileUploadResponse](#skillfileuploadresponse)<br> |
### [POST] /workspaces/current/skills/import
Import a Skill zip package from multipart form field `file`.
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill imported | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [GET] /workspaces/current/skills/tags
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace Skill tags | **application/json**: [SkillTagListResponse](#skilltaglistresponse)<br> |
### [DELETE] /workspaces/current/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillDeletePayload](#skilldeletepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill deleted | **application/json**: [SkillDeleteResponse](#skilldeleteresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill detail | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [PATCH] /workspaces/current/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillMetadataPayload](#skillmetadatapayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill updated | **application/json**: [SkillResponse](#skillresponse)<br> |
### [POST] /workspaces/current/skills/{skill_id}/assist/messages
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillAssistMessagePayload](#skillassistmessagepayload)<br> |
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Skill Authoring assistant event stream |
### [POST] /workspaces/current/skills/{skill_id}/duplicate
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill duplicated | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/export
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Published Skill zip archive |
### [PATCH] /workspaces/current/skills/{skill_id}/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillDraftFileOperationPayload](#skilldraftfileoperationpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Draft file operation applied | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [PUT] /workspaces/current/skills/{skill_id}/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillDraftTreePayload](#skilldrafttreepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Draft files replaced | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/files/content
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| download | query | Return as an attachment when 1. | No | string |
| path | query | Skill file path relative to the Skill root. | Yes | string |
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill file content | **application/json**: [BinaryFileResponse](#binaryfileresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/files/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Skill file path relative to the Skill root. | Yes | string |
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill file text preview | **application/json**: [SkillFilePreviewResponse](#skillfilepreviewresponse)<br> |
### [POST] /workspaces/current/skills/{skill_id}/publish
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillPublishPayload](#skillpublishpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill published | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/references
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill references | **application/json**: [SkillReferenceListResponse](#skillreferencelistresponse)<br> |
### [POST] /workspaces/current/skills/{skill_id}/restore
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillRestorePayload](#skillrestorepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version restored | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/versions
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill versions | **application/json**: [SkillVersionListResponse](#skillversionlistresponse)<br> |
### [DELETE] /workspaces/current/skills/{skill_id}/versions/{version_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| version_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version deleted | **application/json**: [SkillVersionDeleteResponse](#skillversiondeleteresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/versions/{version_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| version_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version detail | **application/json**: [SkillVersionDetailResponse](#skillversiondetailresponse)<br> |
### [PATCH] /workspaces/current/skills/{skill_id}/versions/{version_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| version_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillVersionUpdatePayload](#skillversionupdatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version updated | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
### [GET] /workspaces/current/tool-labels
#### Responses
@@ -15076,37 +14715,6 @@ Visibility and lifecycle scope of an Agent record.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### AgentSkillBindingItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | Yes |
| display_name | string | | Yes |
| file_count | integer | | Yes |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_at | integer | | No |
| latest_published_version_id | string | | No |
| name | string | | Yes |
| priority | integer | | Yes |
| status | string | | Yes |
| tags | [ string ] | | No |
| updated_at | integer | | Yes |
#### AgentSkillBindingsPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| skill_ids | [ string ] | Ordered Skill IDs bound to the Agent. | No |
#### AgentSkillBindingsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | Yes |
| data | [ [AgentSkillBindingItemResponse](#agentskillbindingitemresponse) ] | | No |
| skill_ids | [ string ] | | No |
#### AgentSkillRefConfig
| Name | Type | Description | Required |
@@ -19722,6 +19330,16 @@ Enum class for model property key.
| ---- | ---- | ----------- | -------- |
| ModelPropertyKey | string | Enum class for model property key. | |
#### ModelProviderCustomConfigurationSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| available_credentials | [ [CredentialConfiguration](#credentialconfiguration) ] | | Yes |
| current_credential_id | string | | No |
| current_credential_name | string | | No |
| current_credential_usable | boolean | | Yes |
| status | [CustomConfigurationStatus](#customconfigurationstatus) | | Yes |
#### ModelProviderListResponse
| Name | Type | Description | Required |
@@ -19734,6 +19352,49 @@ Enum class for model property key.
| ---- | ---- | ----------- | -------- |
| payment_link | string | | Yes |
#### ModelProviderPluginSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| installation_id | string | | Yes |
| plugin_id | string | | Yes |
| plugin_unique_identifier | string | | Yes |
| runtime_type | string | | Yes |
| source | [PluginInstallationSource](#plugininstallationsource) | | Yes |
| version | string | | Yes |
#### ModelProviderSummaryListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [ModelProviderSummaryResponse](#modelprovidersummaryresponse) ] | | Yes |
| plugins | object | | Yes |
#### ModelProviderSummaryResponse
Fields required to render the collapsed model-provider list.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes |
| custom_configuration | [ModelProviderCustomConfigurationSummaryResponse](#modelprovidercustomconfigurationsummaryresponse) | | Yes |
| description | [I18nObject](#i18nobject) | | No |
| icon_small | [I18nObject](#i18nobject) | | No |
| icon_small_dark | [I18nObject](#i18nobject) | | No |
| is_configured | boolean | | Yes |
| label | [I18nObject](#i18nobject) | | Yes |
| plugin_id | string | | Yes |
| preferred_provider_type | [ProviderType](#providertype) | | Yes |
| provider | string | | Yes |
| supported_model_types | [ [ModelType](#modeltype) ] | | Yes |
| system_configuration | [ModelProviderSystemConfigurationSummaryResponse](#modelprovidersystemconfigurationsummaryresponse) | | Yes |
#### ModelProviderSystemConfigurationSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | | Yes |
#### ModelSelectorScope
| Name | Type | Description | Required |
@@ -20722,8 +20383,11 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| language | string, <br>**Available values:** "en_US", "ja_JP", "pt_BR", "zh_Hans", <br>**Default:** en_US | Language used for localized label and description search<br>*Enum:* `"en_US"`, `"ja_JP"`, `"pt_BR"`, `"zh_Hans"` | No |
| page | integer, <br>**Default:** 1 | Page number | No |
| page_size | integer, <br>**Default:** 256 | Page size (1-256) | No |
| query | string | Case-insensitive search query | No |
| tags | [ string ] | Match any plugin tag | No |
#### PluginCategoryListResponse
@@ -20924,6 +20588,18 @@ Shared permission levels for resources (datasets, credentials, etc.)
| ---- | ---- | ----------- | -------- |
| plugins | [ [PluginInstallationItemResponse](#plugininstallationitemresponse) ] | | Yes |
#### PluginInstalledIdsQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| category | [PluginCategory](#plugincategory) | Plugin category to include | Yes |
#### PluginInstalledIdsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| plugin_ids | [ string ] | | Yes |
#### PluginListResponse
| Name | Type | Description | Required |
@@ -21433,28 +21109,6 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| result | string | | Yes |
| updated_at | integer | | Yes |
#### RecentAppListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [RecentAppResponse](#recentappresponse) ] | | Yes |
#### RecentAppResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author_name | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [IconType](#icontype) | | No |
| icon_url | string | | Yes |
| id | string | | Yes |
| maintainer | string | | No |
| mode | string, <br>**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | *Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes |
| name | string | | Yes |
| permission_keys | [ string ] | | No |
| updated_at | integer | | Yes |
#### RecommendedAppDetailNullableResponse
| Name | Type | Description | Required |
@@ -22063,186 +21717,6 @@ Simple provider entity response.
| title | string | | Yes |
| use_icon_as_answer_icon | boolean | | Yes |
#### SkillAssistAttachmentPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
| tool_file_id | string | | Yes |
#### SkillAssistMessagePayload
One user message and optional uploaded context for the read-only Skill Authoring assistant.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| attachments | [ [SkillAssistAttachmentPayload](#skillassistattachmentpayload) ] | | No |
| message | string | | Yes |
| model | [SkillAssistModelPayload](#skillassistmodelpayload) | | No |
#### SkillAssistModelPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| model | string | | Yes |
| model_settings | object | | No |
| plugin_id | string | | No |
| provider | string | | Yes |
#### SkillCreatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| display_name | string | | No |
| icon | string, <br>**Default:** 📄 | | No |
| name | string | | No |
| tags | [ string ] | | No |
#### SkillDeletePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| confirmation_name | string | Required when deleting a referenced Skill. Must match the Skill name. | No |
#### SkillDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| deleted | boolean | | Yes |
| id | string | | Yes |
#### SkillDetailResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | Yes |
| created_by | string | | No |
| created_by_name | string | | No |
| description | string | | Yes |
| display_name | string | | Yes |
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | | No |
| name | string | | Yes |
| name_manually_edited | boolean | | No |
| reference_count | integer | | No |
| tags | [ string ] | | No |
| updated_at | integer | | Yes |
| updated_by | string | | No |
| updated_by_name | string | | No |
| visibility | string | | Yes |
#### SkillDraftFileOperation
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| SkillDraftFileOperation | string | | |
#### SkillDraftFileOperationPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | No |
| expected_updated_at | integer | | No |
| hash | string | | No |
| mime_type | string | | No |
| operation | [SkillDraftFileOperation](#skilldraftfileoperation) | | Yes |
| path | string | | Yes |
| size | integer | | No |
| target_path | string | | No |
| tool_file_id | string | | No |
#### SkillDraftTreeItemPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | No |
| hash | string | | No |
| kind | [SkillFileKind](#skillfilekind) | | No |
| mime_type | string | | No |
| path | string | | Yes |
| size | integer | | No |
| storage | [SkillFileStorage](#skillfilestorage) | | No |
| tool_file_id | string | | No |
#### SkillDraftTreePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| expected_updated_at | integer | | No |
| files | [ [SkillDraftTreeItemPayload](#skilldrafttreeitempayload) ] | | No |
#### SkillFileKind
Draft file entry kind.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| SkillFileKind | string | Draft file entry kind. | |
#### SkillFilePreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | Yes |
| hash | string | | Yes |
| mime_type | string | | Yes |
| path | string | | Yes |
| size | integer | | Yes |
#### SkillFileQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| path | string | Skill file path relative to the Skill root. | Yes |
| version_id | string | Optional published version ID. Omit for current draft. | No |
#### SkillFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | No |
| hash | string | | No |
| id | string | | No |
| kind | string | | Yes |
| mime_type | string | | No |
| path | string | | Yes |
| size | integer | | No |
| storage | string | | No |
| tool_file_id | string | | No |
#### SkillFileStorage
How a draft file's content is stored.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| SkillFileStorage | string | How a draft file's content is stored. | |
#### SkillFileUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| hash | string | | Yes |
| id | string | | Yes |
| mime_type | string | | Yes |
| name | string | | Yes |
| size | integer | | Yes |
#### SkillListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillResponse](#skillresponse) ] | | No |
| has_more | boolean | | No |
| limit | integer, <br>**Default:** 20 | | No |
| page | integer, <br>**Default:** 1 | | No |
| total | integer | | No |
#### SkillManifest
Validated metadata extracted from a Skill package.
@@ -22256,91 +21730,6 @@ Validated metadata extracted from a Skill package.
| name | string | | Yes |
| size | integer | | Yes |
#### SkillMetadataPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| display_name | string | | No |
| expected_updated_at | integer | | No |
| icon | string | | No |
| tags | [ string ] | | No |
#### SkillPublishPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | | No |
| version_name | string | | No |
#### SkillReferenceListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillReferenceResponse](#skillreferenceresponse) ] | | No |
#### SkillReferenceResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_icon | string | | No |
| agent_icon_background | string | | No |
| agent_icon_type | string | | No |
| agent_id | string | | Yes |
| app_id | string | | No |
| display_name | string | | Yes |
| name | string | | Yes |
| node_id | string | | No |
| node_name | string | | No |
| type | string | | Yes |
| workflow_icon | string | | No |
| workflow_icon_background | string | | No |
| workflow_icon_type | string | | No |
| workflow_id | string | | No |
| workflow_name | string | | No |
| workflow_version | string | | No |
#### SkillResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | Yes |
| created_by | string | | No |
| created_by_name | string | | No |
| description | string | | Yes |
| display_name | string | | Yes |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | | No |
| name | string | | Yes |
| name_manually_edited | boolean | | No |
| reference_count | integer | | No |
| tags | [ string ] | | No |
| updated_at | integer | | Yes |
| updated_by | string | | No |
| updated_by_name | string | | No |
| visibility | string | | Yes |
#### SkillRestorePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | | No |
| version_id | string | | Yes |
| version_name | string | | No |
#### SkillTagListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillTagResponse](#skilltagresponse) ] | | No |
#### SkillTagResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| count | integer | | Yes |
| tag | string | | Yes |
#### SkillToolInferenceResult
| Name | Type | Description | Required |
@@ -22349,60 +21738,6 @@ Validated metadata extracted from a Skill package.
| inferable | boolean | | Yes |
| reason | string | | No |
#### SkillVersionDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| deleted | boolean | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | | No |
#### SkillVersionDetailResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_size | integer | | Yes |
| created_at | integer | | Yes |
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
| hash_code | string | | Yes |
| id | string | | Yes |
| is_latest | boolean | | No |
| publish_note | string | | Yes |
| published_by | string | | No |
| published_by_name | string | | No |
| skill_id | string | | Yes |
| version_name | string | | Yes |
| version_number | integer | | Yes |
#### SkillVersionListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillVersionResponse](#skillversionresponse) ] | | No |
#### SkillVersionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_size | integer | | Yes |
| created_at | integer | | Yes |
| hash_code | string | | Yes |
| id | string | | Yes |
| is_latest | boolean | | No |
| publish_note | string | | Yes |
| published_by | string | | No |
| published_by_name | string | | No |
| skill_id | string | | Yes |
| version_name | string | | Yes |
| version_number | integer | | Yes |
#### SkillVersionUpdatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | | No |
| version_name | string | | No |
#### SnippetDependencyCheckResponse
| Name | Type | Description | Required |
@@ -22870,7 +22205,7 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Search keyword | No |
| type | [TagType](#tagtype)<br>string | Tag type filter | No |
| type | string, <br>**Available values:** "", "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No |
#### TagListResponse
@@ -25056,15 +24391,6 @@ Workflow tool configuration
| ---- | ---- | ----------- | -------- |
| permission_keys | [ string ] | | No |
#### WorkspaceSkillsQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Search keyword matching skill name or description. | No |
| limit | integer, <br>**Default:** 20 | Number of items per page. | No |
| page | integer, <br>**Default:** 1 | Page number. | No |
| tag | [ string ] | Skill tag filters. Repeat the parameter for multiple tags. | No |
#### WorkspaceTenantResultResponse
| Name | Type | Description | Required |
@@ -1,5 +1,3 @@
"""Unit tests for Aliyun trace utility transformations and database lookups."""
import json
from collections.abc import Mapping
from typing import Any, cast
@@ -27,13 +25,11 @@ from dify_trace_aliyun.utils import (
serialize_json_data,
)
from opentelemetry.trace import Link, StatusCode
from sqlalchemy.orm import Session
from core.rag.models.document import Document
from graphon.entities import WorkflowNodeExecution
from graphon.enums import WorkflowNodeExecutionStatus
from models import EndUser
from models.enums import EndUserType
def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch):
@@ -44,40 +40,35 @@ def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPat
assert get_user_id_from_message_data(message_data) == "account_id"
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch):
message_data = MagicMock()
message_data.from_account_id = "account_id"
message_data.from_end_user_id = "end_user_id"
end_user_data = EndUser(
id="end_user_id",
tenant_id="tenant_id",
app_id="app_id",
type=EndUserType.BROWSER,
session_id="session_id",
)
sqlite3_session.add(end_user_data)
sqlite3_session.commit()
end_user_data = MagicMock(spec=EndUser)
end_user_data.session_id = "session_id"
mock_session = MagicMock()
mock_session.get.return_value = end_user_data
from dify_trace_aliyun.utils import db
monkeypatch.setattr(db, "session", sqlite3_session)
monkeypatch.setattr(db, "session", mock_session)
assert get_user_id_from_message_data(message_data) == "session_id"
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
def test_get_user_id_from_message_data_end_user_not_found(
monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch):
message_data = MagicMock()
message_data.from_account_id = "account_id"
message_data.from_end_user_id = "end_user_id"
mock_session = MagicMock()
mock_session.get.return_value = None
from dify_trace_aliyun.utils import db
monkeypatch.setattr(db, "session", sqlite3_session)
monkeypatch.setattr(db, "session", mock_session)
assert get_user_id_from_message_data(message_data) == "account_id"
@@ -1,8 +1,5 @@
"""Unit tests for LangSmith trace translation with SQLite-backed lookups."""
import collections
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import override
from unittest.mock import MagicMock
@@ -14,7 +11,6 @@ from dify_trace_langsmith.entities.langsmith_trace_entity import (
LangSmithRunUpdateModel,
)
from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace
from sqlalchemy.orm import Session
from core.ops.entities.trace_entity import (
DatasetRetrievalTraceInfo,
@@ -28,7 +24,6 @@ from core.ops.entities.trace_entity import (
)
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey
from models import EndUser
from models.enums import EndUserType
def _dt() -> datetime:
@@ -113,8 +108,7 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
mocks["generate_name_trace"].assert_called_once_with(info)
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
# Setup trace info
workflow_data = MagicMock()
workflow_data.created_at = _dt()
@@ -143,10 +137,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3
workflow_data=workflow_data,
)
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
# Mock dependencies
mock_session = MagicMock()
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
# Mock node executions
node_llm = MagicMock()
@@ -234,10 +228,7 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3
assert call_args[4].run_type == LangSmithRunType.retriever
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_no_start_time(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch):
workflow_data = MagicMock()
workflow_data.created_at = _dt()
workflow_data.finished_at = _dt() + timedelta(seconds=1)
@@ -265,10 +256,9 @@ def test_workflow_trace_no_start_time(
workflow_data=workflow_data,
)
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
mock_session = MagicMock()
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
repo = MagicMock()
repo.get_by_workflow_execution.return_value = []
mock_factory = MagicMock()
@@ -281,10 +271,7 @@ def test_workflow_trace_no_start_time(
assert trace_instance.add_run.called
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_missing_app_id(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.trace_id = "trace-1"
trace_info.message_id = None
@@ -300,17 +287,15 @@ def test_workflow_trace_missing_app_id(
trace_info.workflow_run_outputs = {}
trace_info.error = ""
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
mock_session = MagicMock()
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
with pytest.raises(ValueError, match="No app_id found in trace_info metadata"):
trace_instance.workflow_trace(trace_info)
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
message_data = MagicMock()
message_data.id = "msg-1"
message_data.from_account_id = "acc-1"
@@ -336,19 +321,10 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_
message_file_data=MagicMock(url="file-url"),
)
end_user = EndUser(
id="end-user-1",
tenant_id="tenant-1",
app_id="app-1",
type=EndUserType.BROWSER,
session_id="session-id-123",
)
sqlite3_session.add(end_user)
sqlite3_session.commit()
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
# Mock EndUser lookup
mock_end_user = MagicMock(spec=EndUser)
mock_end_user.session_id = "session-id-123"
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db.session.get", lambda model, pk: mock_end_user)
trace_instance.add_run = MagicMock()
@@ -545,13 +521,9 @@ def test_update_run_error(trace_instance):
trace_instance.update_run(update_data)
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_usage_extraction_error(
trace_instance,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
sqlite3_session: Session,
) -> None:
trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
workflow_data = MagicMock()
workflow_data.created_at = _dt()
workflow_data.finished_at = _dt() + timedelta(seconds=1)
@@ -604,10 +576,8 @@ def test_workflow_trace_usage_extraction_error(
mock_factory = MagicMock()
mock_factory.create_workflow_node_execution_repository.return_value = repo
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory)
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock())
trace_instance.add_run = MagicMock()
@@ -674,11 +644,9 @@ def _make_workflow_trace_info(
)
def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Session) -> None:
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
def _patch_workflow_trace_deps(monkeypatch, trace_instance):
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
repo = MagicMock()
repo.get_by_workflow_execution.return_value = []
factory = MagicMock()
@@ -688,17 +656,14 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Ses
trace_instance.add_run = MagicMock()
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_id_uses_message_id_not_external(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.MonkeyPatch):
"""Chatflow with external trace_id: LangSmith trace_id must be message_id, not external."""
trace_info = _make_workflow_trace_info(
message_id="msg-abc",
workflow_run_id="run-xyz",
trace_id="external-999",
)
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
_patch_workflow_trace_deps(monkeypatch, trace_instance)
trace_instance.workflow_trace(trace_info)
@@ -712,17 +677,14 @@ def test_workflow_trace_id_uses_message_id_not_external(
assert trace_info.metadata.get("external_trace_id") == "external-999"
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_id_pure_workflow_uses_run_id(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
"""Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id."""
trace_info = _make_workflow_trace_info(
message_id=None,
workflow_run_id="run-xyz",
trace_id="external-999",
)
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
_patch_workflow_trace_deps(monkeypatch, trace_instance)
trace_instance.workflow_trace(trace_info)
+4 -4
View File
@@ -1,12 +1,12 @@
[project]
name = "dify-api"
version = "1.16.1"
version = "1.16.0"
requires-python = "~=3.12.0"
dependencies = [
# Legacy: mature and widely deployed
"bleach>=6.4.0,<7.0.0",
"boto3>=1.43.56,<2.0.0",
"boto3>=1.43.46,<2.0.0",
"celery>=5.6.3,<6.0.0",
"croniter>=6.2.2,<7.0.0",
"dify-agent",
@@ -193,10 +193,10 @@ dev = [
############################################################
storage = [
"azure-storage-blob>=12.30.0,<13.0.0",
"bce-python-sdk==0.9.76",
"bce-python-sdk==0.9.72",
"cos-python-sdk-v5>=1.9.44,<2.0.0",
"esdk-obs-python>=3.26.6,<4.0.0",
"google-cloud-storage>=3.13.0,<4.0.0",
"google-cloud-storage>=3.12.1,<4.0.0",
"opendal==0.46.0",
"oss2>=2.19.1,<3.0.0",
"supabase>=2.31.0,<3.0.0",
+1 -3
View File
@@ -1599,9 +1599,7 @@ class TenantService:
return updated_accounts
@staticmethod
def iter_member_account_id_batches(
tenant_id: str, batch_size: int, *, session: Session
) -> Iterator[list[str]]:
def iter_member_account_id_batches(tenant_id: str, batch_size: int, *, session: Session) -> Iterator[list[str]]:
"""Yield workspace member account ids in bounded, ordered batches."""
offset = 0
while True:
+20 -46
View File
@@ -19,11 +19,12 @@ from __future__ import annotations
import hashlib
import io
import posixpath
import re
import zipfile
import zlib
import yaml
from pydantic import BaseModel, Field, ValidationError, field_validator
from pydantic import BaseModel
# Bounds — generous but finite so a hostile upload can't exhaust memory/disk.
_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
@@ -32,8 +33,7 @@ _MAX_SKILL_MD_BYTES = 1 * 1024 * 1024
_MAX_ENTRIES = 5000
_ALLOWED_EXTENSIONS = (".zip", ".skill")
_SKILL_MD_NAME = "SKILL.md"
_SKILL_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
_MAX_SKILL_DESCRIPTION_LENGTH = 1024
_HEADING_RE = re.compile(r"^\s*#\s+(.+?)\s*$", re.MULTILINE)
class SkillPackageError(Exception):
@@ -53,18 +53,13 @@ class SkillPackageError(Exception):
class SkillManifest(BaseModel):
"""Validated metadata extracted from a Skill package."""
name: str = Field(min_length=1, max_length=64, pattern=_SKILL_NAME_PATTERN)
description: str = Field(min_length=1, max_length=_MAX_SKILL_DESCRIPTION_LENGTH)
name: str
description: str
entry_path: str # path of SKILL.md inside the archive
files: list[str] # all (safe) file paths inside the archive
size: int # total uncompressed bytes
hash: str # sha256 of the archive bytes
@field_validator("name", "description", mode="before")
@classmethod
def _strip_required_string(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
class NormalizedSkillPackage(BaseModel):
"""Canonical skill package bytes and metadata ready to store in agent drive."""
@@ -113,17 +108,14 @@ class SkillPackageService:
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
name, description = self._parse_skill_md(skill_md)
try:
manifest = SkillManifest(
name=name,
description=description,
entry_path=_SKILL_MD_NAME,
files=sorted(normalized_members),
size=normalized_size,
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
)
except ValidationError as exc:
raise self._manifest_validation_error(exc) from exc
manifest = SkillManifest(
name=name,
description=description,
entry_path=_SKILL_MD_NAME,
files=sorted(normalized_members),
size=normalized_size,
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
)
return NormalizedSkillPackage(
manifest=manifest,
archive_bytes=normalized_archive_bytes,
@@ -131,31 +123,6 @@ class SkillPackageService:
strip_prefix=strip_prefix,
)
@staticmethod
def _manifest_validation_error(exc: ValidationError) -> SkillPackageError:
first_error = exc.errors()[0]
loc = first_error["loc"]
field = loc[0] if loc else "manifest"
error_type = first_error["type"]
if field == "name":
code = "missing_skill_name" if error_type == "string_too_short" else "invalid_skill_name"
message = (
"SKILL.md frontmatter name is required"
if code == "missing_skill_name"
else "SKILL.md frontmatter name must be lowercase letters, numbers, and hyphens only, "
"must not start or end with a hyphen, and must be at most 64 characters"
)
return SkillPackageError(code, message, status_code=400)
if field == "description":
code = "missing_skill_description" if error_type == "string_too_short" else "invalid_skill_description"
message = (
"SKILL.md frontmatter description is required"
if code == "missing_skill_description"
else f"SKILL.md frontmatter description must be at most {_MAX_SKILL_DESCRIPTION_LENGTH} characters"
)
return SkillPackageError(code, message, status_code=400)
return SkillPackageError("invalid_skill_manifest", "SKILL.md frontmatter is invalid", status_code=400)
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
self._check_extension(filename)
if not content:
@@ -313,6 +280,13 @@ class SkillPackageService:
frontmatter = cls._parse_frontmatter(content)
name = str(frontmatter.get("name") or "").strip()
description = str(frontmatter.get("description") or "").strip()
if not name:
heading = _HEADING_RE.search(content)
name = heading.group(1).strip() if heading else ""
if not name:
raise SkillPackageError(
"missing_skill_name", "SKILL.md must declare a name (frontmatter or top heading)", status_code=400
)
return name, description
@staticmethod
+12 -81
View File
@@ -46,7 +46,6 @@ from models.tools import ToolFile
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
from services.agent.skill_package_service import SkillPackageError
from services.agent_drive_service import DriveFileRef
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
class AgentConfigVersionKind(StrEnum):
@@ -99,7 +98,6 @@ class ConfigPushPayload(BaseModel):
@dataclass(slots=True)
class AgentConfigTarget:
tenant_id: str
agent_id: str
version_id: str
kind: AgentConfigVersionKind
@@ -148,7 +146,6 @@ class AgentConfigService:
user_id=user_id,
)
return AgentConfigTarget(
tenant_id=tenant_id,
agent_id=target.agent_id,
version_id=target.version_id,
kind=target.kind,
@@ -194,7 +191,7 @@ class AgentConfigService:
return {
"agent_id": target.agent_id,
"config_version": self._config_version_payload(target),
"items": self._skill_items_for_target(target),
"items": [self._serialize_skill_item(skill) for skill in target.agent_soul.config_skills],
}
def list_files(
@@ -236,27 +233,10 @@ class AgentConfigService:
config_version_kind=config_version_kind,
user_id=user_id,
)
try:
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
return ConfigDownload(
filename=f"{skill.name}.zip",
mime_type=mime_type or "application/zip",
payload=payload,
)
except AgentConfigServiceError as exc:
if exc.code != "config_skill_not_found":
raise
try:
result = SkillManagementService().pull_runtime_agent_skill(
tenant_id=tenant_id,
agent_id=agent_id,
name=name,
)
return ConfigDownload(filename=result.filename, mime_type=result.mime_type, payload=result.payload)
except SkillManagementServiceError as exc:
raise AgentConfigServiceError("config_skill_not_found", "config skill not found", status_code=404) from exc
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
return ConfigDownload(filename=f"{skill.name}.zip", mime_type=mime_type or "application/zip", payload=payload)
def download_skill_url(
self,
@@ -299,45 +279,9 @@ class AgentConfigService:
config_version_kind=config_version_kind,
user_id=user_id,
)
try:
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
skill_item = self._serialize_skill_item(skill)
except AgentConfigServiceError as exc:
if exc.code != "config_skill_not_found":
raise
try:
workspace_archive = SkillManagementService().pull_runtime_agent_skill(
tenant_id=tenant_id,
agent_id=agent_id,
name=name,
)
except SkillManagementServiceError as skill_exc:
raise AgentConfigServiceError(
"config_skill_not_found",
"config skill not found",
status_code=404,
) from skill_exc
archive_bytes = workspace_archive.payload
skill_item = next(
(
item
for item in SkillManagementService().list_runtime_agent_skills(
tenant_id=tenant_id,
agent_id=agent_id,
)
if item["name"] == name
),
{
"id": name,
"name": name,
"description": "",
"size": None,
"hash": None,
"mime_type": "application/zip",
},
)
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
try:
archive_items, skill_md = self._inspect_skill_archive(archive_bytes)
except (OSError, ValueError, zipfile.BadZipFile) as exc:
@@ -347,7 +291,7 @@ class AgentConfigService:
status_code=500,
) from exc
return {
**skill_item,
**self._serialize_skill_item(skill),
"source": "config_skill_zip",
"files": archive_items,
"skill_md": skill_md,
@@ -895,7 +839,6 @@ class AgentConfigService:
status_code=404,
)
return AgentConfigTarget(
tenant_id=tenant_id,
agent_id=agent_id,
version_id=version.id,
kind=config_version_kind,
@@ -1190,7 +1133,9 @@ class AgentConfigService:
return {
"agent_id": target.agent_id,
"config_version": AgentConfigService._config_version_payload(target),
"skills": {"items": AgentConfigService._skill_items_for_target(target)},
"skills": {
"items": [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
},
"files": {
"items": [
AgentConfigService._serialize_file_item(file_ref) for file_ref in target.agent_soul.config_files
@@ -1200,20 +1145,6 @@ class AgentConfigService:
"note": target.agent_soul.config_note,
}
@staticmethod
def _skill_items_for_target(target: AgentConfigTarget) -> list[dict[str, object]]:
items = [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
seen_names = {str(item["name"]) for item in items}
for item in SkillManagementService().list_runtime_agent_skills(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
):
if item["name"] in seen_names:
continue
seen_names.add(str(item["name"]))
items.append(item)
return items
@staticmethod
def _config_version_payload(target: AgentConfigTarget) -> dict[str, object]:
return {
-84
View File
@@ -1,7 +1,6 @@
import json
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal, NotRequired, TypedDict, cast, override
@@ -42,20 +41,6 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t
logger = logging.getLogger(__name__)
AppListSortBy = Literal["last_modified", "recently_created", "earliest_created"]
RecentAppMode = Literal[
AppMode.COMPLETION,
AppMode.WORKFLOW,
AppMode.CHAT,
AppMode.ADVANCED_CHAT,
AppMode.AGENT_CHAT,
]
RECENT_APP_MODES: tuple[RecentAppMode, ...] = (
AppMode.COMPLETION,
AppMode.WORKFLOW,
AppMode.CHAT,
AppMode.ADVANCED_CHAT,
AppMode.AGENT_CHAT,
)
class AppListBaseParams(BaseModel):
@@ -80,19 +65,6 @@ class StarredAppListParams(AppListBaseParams):
pass
@dataclass(frozen=True)
class RecentAppListItem:
id: str
name: str
icon_type: IconType | None
icon: str | None
icon_background: str | None
mode: RecentAppMode
author_name: str | None
updated_at: datetime
maintainer: str | None
class CreateAppParams(BaseModel):
name: str = Field(min_length=1)
description: str | None = None
@@ -351,62 +323,6 @@ class AppService:
return app_models
def get_recent_apps(
self,
user_id: str,
tenant_id: str,
params: AppListParams,
session: Session,
) -> list[RecentAppListItem]:
"""Return recently modified apps as one lightweight, non-paginated projection."""
filters = self._build_app_list_filters(user_id, tenant_id, params, session)
if not filters:
return []
stmt = (
sa.select(
App.id,
App.name,
App.icon_type,
App.icon,
App.icon_background,
App.mode,
Account.name.label("author_name"),
App.updated_at,
App.maintainer,
)
.outerjoin(Account, Account.id == App.created_by)
.where(*filters, App.mode.in_(RECENT_APP_MODES))
.order_by(App.updated_at.desc())
.limit(params.limit)
)
rows = session.execute(stmt).all()
return [
RecentAppListItem(
id=str(app_id),
name=name,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
mode=cast(RecentAppMode, mode),
author_name=author_name,
updated_at=updated_at,
maintainer=maintainer,
)
for (
app_id,
name,
icon_type,
icon,
icon_background,
mode,
author_name,
updated_at,
maintainer,
) in rows
]
def get_paginate_starred_apps(
self,
user_id: str,
@@ -17,6 +17,7 @@ from core.entities.provider_entities import (
QuotaConfiguration,
UnaddedModelConfiguration,
)
from core.plugin.entities.plugin import PluginInstallationSource
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.model_entities import (
FetchFrom,
@@ -69,6 +70,64 @@ class SystemConfigurationResponse(BaseModel):
quota_configurations: list[QuotaConfiguration] = []
class ModelProviderCustomConfigurationSummaryResponse(BaseModel):
status: CustomConfigurationStatus
available_credentials: list[CredentialConfiguration]
current_credential_id: str | None = None
current_credential_name: str | None = None
current_credential_usable: bool
class ModelProviderSystemConfigurationSummaryResponse(BaseModel):
enabled: bool
class ModelProviderPluginSummaryResponse(BaseModel):
installation_id: str
plugin_id: str
plugin_unique_identifier: str
runtime_type: str
source: PluginInstallationSource
version: str
class ModelProviderSummaryResponse(BaseModel):
"""Fields required to render the collapsed model-provider list."""
tenant_id: str = Field(exclude=True)
provider: str
plugin_id: str
label: I18nObject
description: I18nObject | None = None
icon_small: I18nObject | None = None
icon_small_dark: I18nObject | None = None
supported_model_types: Sequence[ModelType]
configurate_methods: list[ConfigurateMethod]
preferred_provider_type: ProviderType
is_configured: bool
custom_configuration: ModelProviderCustomConfigurationSummaryResponse
system_configuration: ModelProviderSystemConfigurationSummaryResponse
model_config = ConfigDict(protected_namespaces=())
@model_validator(mode="after")
def build_icon_urls(self):
url_prefix = (
dify_config.CONSOLE_API_URL + f"/console/api/workspaces/{self.tenant_id}/model-providers/{self.provider}"
)
if self.icon_small is not None:
self.icon_small = I18nObject(
en_US=f"{url_prefix}/icon_small/en_US",
zh_Hans=f"{url_prefix}/icon_small/zh_Hans",
)
if self.icon_small_dark is not None:
self.icon_small_dark = I18nObject(
en_US=f"{url_prefix}/icon_small_dark/en_US",
zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans",
)
return self
class ProviderResponse(BaseModel):
"""
Model class for provider response.
+9 -48
View File
@@ -1,8 +1,6 @@
import logging
from collections.abc import Mapping
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field
from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
@@ -12,8 +10,6 @@ from enums.hosted_provider import HostedTrialProvider
from services.billing_service import BillingInfo, BillingService
from services.enterprise.enterprise_service import EnterpriseService
logger = logging.getLogger(__name__)
class FeatureResponseModel(BaseModel):
model_config = ConfigDict(json_schema_serialization_defaults_required=True, protected_namespaces=())
@@ -135,13 +131,6 @@ class PluginInstallationPermissionModel(FeatureResponseModel):
restrict_to_marketplace_only: bool = False
class _EnterprisePluginInstallationPermission(BaseModel):
model_config = ConfigDict(extra="ignore")
plugin_installation_scope: PluginInstallationScope = Field(alias="pluginInstallationScope")
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
class FeatureModel(FeatureResponseModel):
billing: BillingModel = BillingModel()
education: EducationModel = EducationModel()
@@ -296,14 +285,6 @@ class FeatureService:
"""Return whether Enterprise plugin credential policies must be enforced."""
return dify_config.ENTERPRISE_ENABLED
@classmethod
def get_plugin_installation_permission(cls) -> PluginInstallationPermissionModel:
"""Resolve the validated deployment-wide plugin installation policy."""
if not dify_config.ENTERPRISE_ENABLED:
return PluginInstallationPermissionModel()
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
@classmethod
def get_license(cls) -> LicenseModel:
"""Return full license detail. Enterprise-only; requires an authenticated caller.
@@ -471,33 +452,6 @@ class FeatureService:
)
return license_model
@classmethod
def _resolve_plugin_installation_permission(
cls, enterprise_info: Mapping[str, object]
) -> PluginInstallationPermissionModel:
if "PluginInstallationPermission" not in enterprise_info:
return PluginInstallationPermissionModel()
try:
permission = _EnterprisePluginInstallationPermission.model_validate(
enterprise_info["PluginInstallationPermission"]
)
except ValidationError as exc:
# Do not attach the exception because it may contain raw Enterprise configuration values.
logger.error( # noqa: TRY400
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
exc.errors(include_input=False),
)
return PluginInstallationPermissionModel(
plugin_installation_scope=PluginInstallationScope.NONE,
restrict_to_marketplace_only=True,
)
return PluginInstallationPermissionModel(
plugin_installation_scope=permission.plugin_installation_scope,
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
)
@classmethod
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
enterprise_info = EnterpriseService.get_info()
@@ -545,4 +499,11 @@ class FeatureService:
status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
)
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)
if "PluginInstallationPermission" in enterprise_info:
plugin_installation_info = enterprise_info["PluginInstallationPermission"]
features.plugin_installation_permission.plugin_installation_scope = plugin_installation_info[
"pluginInstallationScope"
]
features.plugin_installation_permission.restrict_to_marketplace_only = plugin_installation_info[
"restrictToMarketplaceOnly"
]
+272 -1
View File
@@ -1,18 +1,42 @@
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from sqlalchemy import and_, select
if TYPE_CHECKING:
from models.account import Account
from configs import dify_config
from core.db.session_factory import session_factory
from core.entities.model_entities import ModelWithProviderEntity, ProviderModelWithStatusEntity
from core.entities.provider_entities import CredentialConfiguration
from core.helper.position_helper import is_filtered
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginModelProviderBinding
from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory, create_plugin_provider_manager
from core.plugin.plugin_service import PluginService
from core.provider_manager import ProviderManager
from extensions import ext_hosting_provider
from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule
from models.provider import ProviderType
from models.provider import (
Provider,
ProviderCredential,
ProviderModel,
ProviderModelCredential,
ProviderType,
TenantPreferredModelProvider,
)
from models.provider_ids import ModelProviderID
from services.entities.model_provider_entities import (
CustomConfigurationResponse,
CustomConfigurationStatus,
DefaultModelResponse,
ModelProviderCustomConfigurationSummaryResponse,
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ModelProviderSystemConfigurationSummaryResponse,
ModelWithProviderEntityResponse,
ProviderResponse,
ProviderWithModelsResponse,
@@ -24,6 +48,17 @@ from services.errors.app_model_config import ProviderNotFoundError
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _ProviderSummaryState:
has_custom_provider: bool = False
available_credentials: list[CredentialConfiguration] = field(default_factory=list)
has_custom_models: bool = False
current_credential_id: str | None = None
current_credential_name: str | None = None
current_credential_usable: bool = False
preferred_provider_type: ProviderType | None = None
class ModelProviderService:
"""
Model Provider Service
@@ -132,6 +167,242 @@ class ModelProviderService:
return provider_responses
@staticmethod
def _load_provider_summary_states(tenant_id: str) -> dict[str, _ProviderSummaryState]:
"""Load only the workspace columns required by the collapsed provider list."""
with session_factory.create_session() as session:
custom_provider_rows = session.execute(
select(
Provider.provider_name,
Provider.credential_id,
ProviderCredential.provider_name.label("credential_provider_name"),
ProviderCredential.credential_name,
)
.outerjoin(
ProviderCredential,
and_(
ProviderCredential.id == Provider.credential_id,
ProviderCredential.tenant_id == tenant_id,
),
)
.where(
Provider.tenant_id == tenant_id,
Provider.provider_type == ProviderType.CUSTOM,
Provider.is_valid.is_(True),
)
).all()
credential_rows = session.execute(
select(
ProviderCredential.id,
ProviderCredential.provider_name,
ProviderCredential.credential_name,
)
.where(ProviderCredential.tenant_id == tenant_id)
.order_by(
ProviderCredential.created_at.desc(),
ProviderCredential.id.desc(),
)
).all()
custom_model_rows = session.execute(
select(ProviderModel.provider_name.label("provider_name"))
.where(
ProviderModel.tenant_id == tenant_id,
ProviderModel.is_valid.is_(True),
)
.union(
select(ProviderModelCredential.provider_name.label("provider_name")).where(
ProviderModelCredential.tenant_id == tenant_id
)
)
).all()
preferred_provider_rows = session.execute(
select(
TenantPreferredModelProvider.provider_name,
TenantPreferredModelProvider.preferred_provider_type,
).where(TenantPreferredModelProvider.tenant_id == tenant_id)
).all()
states: defaultdict[str, _ProviderSummaryState] = defaultdict(_ProviderSummaryState)
for credential in credential_rows:
provider_name = str(ModelProviderID(credential.provider_name))
states[provider_name].available_credentials.append(
CredentialConfiguration(
credential_id=credential.id,
credential_name=credential.credential_name,
)
)
selected_provider_priorities: dict[str, bool] = {}
for provider in custom_provider_rows:
provider_name = str(ModelProviderID(provider.provider_name))
state = states[provider_name]
state.has_custom_provider = True
is_canonical_row = provider.provider_name == provider_name
if provider_name in selected_provider_priorities and not is_canonical_row:
continue
selected_provider_priorities[provider_name] = is_canonical_row
state.current_credential_id = provider.credential_id
if (
provider.credential_provider_name is not None
and str(ModelProviderID(provider.credential_provider_name)) == provider_name
):
state.current_credential_name = provider.credential_name
state.current_credential_usable = True
else:
state.current_credential_name = None
state.current_credential_usable = False
for model in custom_model_rows:
states[str(ModelProviderID(model.provider_name))].has_custom_models = True
preferred_provider_priorities: dict[str, bool] = {}
for preferred_provider in preferred_provider_rows:
provider_name = str(ModelProviderID(preferred_provider.provider_name))
is_canonical_row = preferred_provider.provider_name == provider_name
if provider_name in preferred_provider_priorities and not is_canonical_row:
continue
preferred_provider_priorities[provider_name] = is_canonical_row
states[provider_name].preferred_provider_type = preferred_provider.preferred_provider_type
return dict(states)
@staticmethod
def _is_system_provider_enabled(provider: str) -> bool:
configuration = ext_hosting_provider.hosting_configuration.provider_map.get(provider)
return bool(configuration and configuration.enabled and configuration.quotas)
@staticmethod
def _select_binding(
current_binding: PluginModelProviderBinding | None,
candidate_binding: PluginModelProviderBinding,
) -> PluginModelProviderBinding:
"""Prefer a remote-debug runtime when one shadows an installed plugin."""
if current_binding is None:
return candidate_binding
if (
candidate_binding.source == PluginInstallationSource.Remote
and current_binding.source != PluginInstallationSource.Remote
):
return candidate_binding
return current_binding
@staticmethod
def _get_preferred_provider_type(
state: _ProviderSummaryState,
*,
custom_present: bool,
system_enabled: bool,
) -> ProviderType:
if state.preferred_provider_type is not None:
return state.preferred_provider_type
if dify_config.EDITION == "CLOUD" and system_enabled:
return ProviderType.SYSTEM
if custom_present:
return ProviderType.CUSTOM
if system_enabled:
return ProviderType.SYSTEM
return ProviderType.CUSTOM
def get_provider_summary_list(
self, tenant_id: str
) -> tuple[list[ModelProviderSummaryResponse], dict[str, ModelProviderPluginSummaryResponse]]:
"""Build the complete first-screen provider projection without assembling provider configurations."""
# Read bindings first: remote-debug identity changes invalidate provider metadata
# before the provider cache is consulted.
bindings = PluginService.list_model_provider_bindings(tenant_id)
provider_entities = PluginService.fetch_plugin_model_providers(tenant_id=tenant_id)
states = self._load_provider_summary_states(tenant_id)
bindings_by_provider: dict[str, PluginModelProviderBinding] = {}
for binding in bindings:
provider_name = (
str(ModelProviderID(binding.provider))
if binding.provider.count("/") == 2
else str(ModelProviderID(f"{binding.plugin_id}/{binding.provider}"))
)
bindings_by_provider[provider_name] = self._select_binding(
bindings_by_provider.get(provider_name),
binding,
)
provider_summaries: list[ModelProviderSummaryResponse] = []
emitted_provider_names: set[str] = set()
for provider_entity in provider_entities:
if is_filtered(
include_set=dify_config.POSITION_PROVIDER_INCLUDES_SET,
exclude_set=dify_config.POSITION_PROVIDER_EXCLUDES_SET,
data=provider_entity,
name_func=lambda provider: provider.provider,
):
continue
provider_id = ModelProviderID(provider_entity.provider)
provider_name = str(provider_id)
if provider_name in emitted_provider_names:
continue
emitted_provider_names.add(provider_name)
state = states.get(provider_name, _ProviderSummaryState())
custom_configured = (
state.has_custom_provider and bool(state.available_credentials)
) or state.has_custom_models
custom_present = state.has_custom_provider or state.has_custom_models
system_enabled = self._is_system_provider_enabled(provider_name)
preferred_provider_type = self._get_preferred_provider_type(
state,
custom_present=custom_present,
system_enabled=system_enabled,
)
provider_summaries.append(
ModelProviderSummaryResponse(
tenant_id=tenant_id,
provider=provider_name,
plugin_id=provider_id.plugin_id,
label=provider_entity.label,
description=provider_entity.description,
icon_small=provider_entity.icon_small,
icon_small_dark=provider_entity.icon_small_dark,
supported_model_types=provider_entity.supported_model_types,
configurate_methods=provider_entity.configurate_methods,
preferred_provider_type=preferred_provider_type,
is_configured=custom_configured or system_enabled,
custom_configuration=ModelProviderCustomConfigurationSummaryResponse(
status=CustomConfigurationStatus.ACTIVE
if custom_configured
else CustomConfigurationStatus.NO_CONFIGURE,
available_credentials=state.available_credentials,
current_credential_id=state.current_credential_id,
current_credential_name=state.current_credential_name,
current_credential_usable=state.current_credential_usable,
),
system_configuration=ModelProviderSystemConfigurationSummaryResponse(
enabled=system_enabled,
),
)
)
plugin_bindings: dict[str, PluginModelProviderBinding] = {}
for binding in bindings_by_provider.values():
plugin_bindings[binding.plugin_id] = self._select_binding(
plugin_bindings.get(binding.plugin_id),
binding,
)
plugin_summaries = {
plugin_id: ModelProviderPluginSummaryResponse(
installation_id=binding.installation_id,
plugin_id=binding.plugin_id,
plugin_unique_identifier=binding.plugin_unique_identifier,
runtime_type=binding.runtime_type,
source=binding.source,
version=binding.version,
)
for plugin_id, binding in plugin_bindings.items()
}
return provider_summaries, plugin_summaries
def get_models_by_provider(self, tenant_id: str, provider: str) -> list[ModelWithProviderEntityResponse]:
"""
get provider models.
File diff suppressed because it is too large Load Diff
-9
View File
@@ -12,7 +12,6 @@ from werkzeug.exceptions import NotFound
from models.dataset import Dataset
from models.enums import TagType
from models.model import App, Tag, TagBinding
from models.skill import Skill
from models.snippet import CustomizedSnippet
type _TagTypeLike = TagType | str
@@ -283,13 +282,5 @@ class TagService:
)
if not snippet:
raise NotFound("Snippet not found")
elif type == "skill":
skill = session.scalar(
select(Skill)
.where(Skill.tenant_id == current_user.current_tenant_id, Skill.id == target_id)
.limit(1)
)
if not skill:
raise NotFound("Skill not found")
else:
raise NotFound("Invalid binding type")
@@ -22,7 +22,6 @@ def _create_agent_backend_client():
return None
return create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
@@ -1,85 +1,494 @@
"""Integration coverage for Notion page bindings backed by persisted documents."""
"""Testcontainers integration tests for controllers.console.datasets.data_source endpoints."""
from inspect import unwrap
from unittest.mock import MagicMock, patch
from __future__ import annotations
import inspect
from collections.abc import Iterator
from datetime import UTC, datetime
from unittest.mock import MagicMock, PropertyMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from controllers.console.datasets.data_source import DataSourceNotionListApi
from models import Account
from controllers.console.datasets import data_source
from controllers.console.datasets.data_source import (
DataSourceApi,
DataSourceNotionDatasetSyncApi,
DataSourceNotionDocumentSyncApi,
DataSourceNotionIndexingEstimateApi,
DataSourceNotionListApi,
DataSourceNotionPreviewApi,
)
from core.rag.index_processor.constant.index_type import IndexStructureType
from models import Account, DataSourceOauthBinding
from models.dataset import Document
from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus
def test_notion_page_is_marked_bound_from_persisted_document(
flask_app_with_containers: Flask,
db_session_with_containers: Session,
) -> None:
tenant_id = str(uuid4())
dataset_id = str(uuid4())
account = Account(name="Test User", email="user@example.com")
account.id = str(uuid4())
document = Document(
tenant_id=tenant_id,
dataset_id=dataset_id,
position=1,
data_source_type=DataSourceType.NOTION_IMPORT,
data_source_info='{"notion_page_id": "page-1"}',
batch=f"batch-{uuid4()}",
name="Notion Page",
created_from=DocumentCreatedFrom.WEB,
created_by=str(uuid4()),
indexing_status=IndexingStatus.COMPLETED,
enabled=True,
)
db_session_with_containers.add(document)
db_session_with_containers.commit()
runtime = MagicMock(
get_online_document_pages=lambda **_kwargs: iter(
[
MagicMock(
result=[
MagicMock(
workspace_id="workspace-1",
workspace_name="Workspace",
workspace_icon=None,
pages=[
MagicMock(
page_id="page-1",
page_name="Page",
type="page",
parent_id="parent",
page_icon=None,
)
],
)
]
)
]
),
datasource_provider_type=lambda: None,
)
@pytest.fixture
def current_user() -> Account:
account = Account(name="Test User", email="u1@example.com")
account.id = "u1"
return account
with (
flask_app_with_containers.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"),
patch(
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
return_value={"token": "token"},
),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=MagicMock(data_source_type="notion_import"),
),
patch(
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
return_value=runtime,
),
@pytest.fixture
def mock_engine() -> Iterator[None]:
with patch.object(
type(data_source.db),
"engine",
new_callable=PropertyMock,
return_value=MagicMock(),
):
response, status = unwrap(DataSourceNotionListApi().get)(
DataSourceNotionListApi(), db_session_with_containers, tenant_id, account
yield
class TestDataSourceApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask) -> Flask:
return flask_app_with_containers
def test_get_success(self, app: Flask) -> None:
api = DataSourceApi()
method = inspect.unwrap(api.get)
binding = DataSourceOauthBinding(
tenant_id="tenant-1",
access_token="token",
provider="notion",
source_info={
"workspace_name": "Workspace",
"workspace_id": "workspace-1",
"workspace_icon": None,
"total": 1,
"pages": [
{
"page_id": "page-1",
"page_name": "Page",
"page_icon": {"type": "emoji", "emoji": "P", "url": None},
"parent_id": "parent-1",
"type": "page",
}
],
},
)
binding.id = "b1"
binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC)
binding.disabled = False
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.db.session.scalars",
return_value=MagicMock(all=lambda: [binding]),
),
):
response, status = method(api, "tenant-1")
assert status == 200
assert response["data"][0] == {
"id": "b1",
"provider": "notion",
"created_at": 1779670923,
"is_bound": True,
"disabled": False,
"source_info": {
"workspace_name": "Workspace",
"workspace_id": "workspace-1",
"workspace_icon": None,
"pages": [
{
"page_name": "Page",
"page_id": "page-1",
"page_icon": {"type": "emoji", "url": None, "emoji": "P"},
"parent_id": "parent-1",
"type": "page",
}
],
"total": 1,
},
"link": "http://localhost/console/api/oauth/data-source/notion",
}
def test_get_no_bindings(self, app: Flask) -> None:
api = DataSourceApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.db.session.scalars",
return_value=MagicMock(all=lambda: []),
),
):
response, status = method(api, "tenant-1")
assert status == 200
assert response["data"] == []
def test_patch_enable_binding(self, app: Flask) -> None:
api = DataSourceApi()
method = inspect.unwrap(api.patch)
binding = MagicMock(id="b1", disabled=True)
session = MagicMock()
session.scalar.return_value = binding
with app.test_request_context("/"):
response, status = method(api, session, "tenant-1", "b1", "enable")
assert status == 200
assert binding.disabled is False
def test_patch_disable_binding(self, app: Flask) -> None:
api = DataSourceApi()
method = inspect.unwrap(api.patch)
binding = MagicMock(id="b1", disabled=False)
session = MagicMock()
session.scalar.return_value = binding
with app.test_request_context("/"):
response, status = method(api, session, "tenant-1", "b1", "disable")
assert status == 200
assert binding.disabled is True
def test_patch_binding_not_found(self, app: Flask) -> None:
api = DataSourceApi()
method = inspect.unwrap(api.patch)
session = MagicMock()
session.scalar.return_value = None
with app.test_request_context("/"):
with pytest.raises(NotFound):
method(api, session, "tenant-1", "b1", "enable")
def test_patch_enable_already_enabled(self, app: Flask) -> None:
api = DataSourceApi()
method = inspect.unwrap(api.patch)
binding = MagicMock(id="b1", disabled=False)
session = MagicMock()
session.scalar.return_value = binding
with app.test_request_context("/"):
with pytest.raises(ValueError):
method(api, session, "tenant-1", "b1", "enable")
def test_patch_disable_already_disabled(self, app: Flask) -> None:
api = DataSourceApi()
method = inspect.unwrap(api.patch)
binding = MagicMock(id="b1", disabled=True)
session = MagicMock()
session.scalar.return_value = binding
with app.test_request_context("/"):
with pytest.raises(ValueError):
method(api, session, "tenant-1", "b1", "disable")
class TestDataSourceNotionListApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask) -> Flask:
return flask_app_with_containers
def test_get_credential_not_found(self, app: Flask, current_user: Account) -> None:
api = DataSourceNotionListApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/?credential_id=c1"),
patch(
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
return_value=None,
),
):
with pytest.raises(NotFound):
method(api, MagicMock(), "tenant-1", current_user)
def test_get_success_no_dataset_id(self, app: Flask, current_user: Account, mock_engine: None) -> None:
api = DataSourceNotionListApi()
method = inspect.unwrap(api.get)
page = MagicMock(
page_id="p1",
page_name="Page 1",
type="page",
parent_id="parent",
page_icon=None,
)
assert status == 200
assert response["notion_info"][0]["pages"][0]["is_bound"] is True
online_document_message = MagicMock(
result=[
MagicMock(
workspace_id="w1",
workspace_name="My Workspace",
workspace_icon="icon",
pages=[page],
)
]
)
with (
app.test_request_context("/?credential_id=c1"),
patch(
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
return_value={"token": "t"},
),
patch(
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
return_value=MagicMock(
get_online_document_pages=lambda **kw: iter([online_document_message]),
datasource_provider_type=lambda: None,
),
),
):
response, status = method(api, MagicMock(), "tenant-1", current_user)
assert status == 200
def test_get_success_with_dataset_id(
self, app: Flask, current_user: Account, mock_engine: None, db_session_with_containers: Session
) -> None:
api = DataSourceNotionListApi()
method = inspect.unwrap(api.get)
tenant_id = str(uuid4())
dataset_id = str(uuid4())
page = MagicMock(
page_id="p1",
page_name="Page 1",
type="page",
parent_id="parent",
page_icon=None,
)
online_document_message = MagicMock(
result=[
MagicMock(
workspace_id="w1",
workspace_name="My Workspace",
workspace_icon="icon",
pages=[page],
)
]
)
dataset = MagicMock(data_source_type="notion_import")
document = Document(
tenant_id=tenant_id,
dataset_id=dataset_id,
position=1,
data_source_type=DataSourceType.NOTION_IMPORT,
data_source_info='{"notion_page_id": "p1"}',
batch=f"batch-{uuid4()}",
name="Notion Page",
created_from=DocumentCreatedFrom.WEB,
created_by=str(uuid4()),
indexing_status=IndexingStatus.COMPLETED,
enabled=True,
)
db_session_with_containers.add(document)
db_session_with_containers.commit()
with (
app.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"),
patch(
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
return_value={"token": "t"},
),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=dataset,
),
patch(
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
return_value=MagicMock(
get_online_document_pages=lambda **kw: iter([online_document_message]),
datasource_provider_type=lambda: None,
),
),
):
response, status = method(api, db_session_with_containers, tenant_id, current_user)
assert status == 200
def test_get_invalid_dataset_type(self, app: Flask, current_user: Account, mock_engine: None) -> None:
api = DataSourceNotionListApi()
method = inspect.unwrap(api.get)
dataset = MagicMock(data_source_type="other_type")
with (
app.test_request_context("/?credential_id=c1&dataset_id=ds1"),
patch(
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
return_value={"token": "t"},
),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=dataset,
),
):
with pytest.raises(ValueError):
method(api, MagicMock(), "tenant-1", current_user)
class TestDataSourceNotionPreviewApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask) -> Flask:
return flask_app_with_containers
def test_get_preview_success(self, app: Flask) -> None:
api = DataSourceNotionPreviewApi()
method = inspect.unwrap(api.get)
extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")])
with (
app.test_request_context("/?credential_id=c1"),
patch(
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
return_value={"integration_secret": "t"},
),
patch(
"controllers.console.datasets.data_source.NotionExtractor",
return_value=extractor,
),
):
response, status = method(api, "tenant-1", "p1", "page")
assert status == 200
class TestDataSourceNotionIndexingEstimateApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask) -> Flask:
return flask_app_with_containers
def test_post_indexing_estimate_success(self, app: Flask) -> None:
api = DataSourceNotionIndexingEstimateApi()
method = inspect.unwrap(api.post)
empty_rules: dict[str, object] = {}
payload: dict[str, object] = {
"notion_info_list": [
{
"workspace_id": "w1",
"credential_id": "c1",
"pages": [{"page_id": "p1", "type": "page"}],
}
],
"process_rule": {"rules": empty_rules},
"doc_form": IndexStructureType.PARAGRAPH_INDEX,
"doc_language": "English",
}
with (
app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}),
patch(
"controllers.console.datasets.data_source.DocumentService.estimate_args_validate",
),
patch(
"controllers.console.datasets.data_source.IndexingRunner.indexing_estimate",
return_value=MagicMock(model_dump=lambda: {"total_pages": 1}),
),
):
response, status = method(api, MagicMock(), "tenant-1")
assert status == 200
class TestDataSourceNotionDatasetSyncApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask) -> Flask:
return flask_app_with_containers
def test_get_success(self, app: Flask) -> None:
api = DataSourceNotionDatasetSyncApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=MagicMock(),
),
patch(
"controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id",
return_value=[MagicMock(id="d1")],
),
patch(
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
return_value=None,
),
):
response, status = method(api, MagicMock(), "ds-1")
assert status == 200
def test_get_dataset_not_found(self, app: Flask) -> None:
api = DataSourceNotionDatasetSyncApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=None,
),
):
with pytest.raises(NotFound):
method(api, MagicMock(), "ds-1")
class TestDataSourceNotionDocumentSyncApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask) -> Flask:
return flask_app_with_containers
def test_get_success(self, app: Flask) -> None:
api = DataSourceNotionDocumentSyncApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=MagicMock(),
),
patch(
"controllers.console.datasets.data_source.DocumentService.get_document",
return_value=MagicMock(),
),
patch(
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
return_value=None,
),
):
response, status = method(api, MagicMock(), "ds-1", "doc-1")
assert status == 200
def test_get_document_not_found(self, app: Flask) -> None:
api = DataSourceNotionDocumentSyncApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=MagicMock(),
),
patch(
"controllers.console.datasets.data_source.DocumentService.get_document",
return_value=None,
),
):
with pytest.raises(NotFound):
method(api, MagicMock(), "ds-1", "doc-1")
@@ -4,7 +4,7 @@ import datetime
import json
import uuid
from decimal import Decimal
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from faker import Faker
@@ -1172,8 +1172,65 @@ class TestMessagesCleanServiceIntegration:
# Verify all messages were deleted
assert db_session_with_containers.query(Message).where(Message.id.in_(msg_ids)).count() == 0
def test_from_time_range_validation(self):
"""Test that from_time_range raises ValueError for invalid inputs."""
policy = MagicMock(spec=BillingDisabledPolicy)
now = datetime.datetime.now()
with pytest.raises(ValueError, match="start_from .* must be less than end_before"):
MessagesCleanService.from_time_range(policy, now, now)
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
MessagesCleanService.from_time_range(policy, now - datetime.timedelta(days=1), now, batch_size=0)
def test_from_time_range_success(self):
"""Test that from_time_range creates a service with correct parameters."""
policy = MagicMock(spec=BillingDisabledPolicy)
start = datetime.datetime(2024, 1, 1)
end = datetime.datetime(2024, 2, 1)
service = MessagesCleanService.from_time_range(policy, start, end)
assert service._start_from == start
assert service._end_before == end
def test_from_days_validation(self):
"""Test that from_days raises ValueError for invalid inputs."""
policy = MagicMock(spec=BillingDisabledPolicy)
with pytest.raises(ValueError, match="days .* must be greater than or equal to 0"):
MessagesCleanService.from_days(policy, days=-1)
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
MessagesCleanService.from_days(policy, days=30, batch_size=0)
def test_from_days_success(self):
"""Test that from_days creates a service with correct parameters."""
policy = MagicMock(spec=BillingDisabledPolicy)
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
fixed_now = datetime.datetime(2024, 6, 1)
mock_now.return_value = fixed_now
service = MessagesCleanService.from_days(policy, days=10)
assert service._start_from is None
assert service._end_before == fixed_now - datetime.timedelta(days=10)
def test_batch_delete_message_relations_empty(self, db_session_with_containers: Session):
"""Test that batch_delete_message_relations with empty list does nothing."""
# Get execute call count before
MessagesCleanService._batch_delete_message_relations(db_session_with_containers, [])
# No exception means success — empty list is a no-op
def test_run_calls_clean_messages(self):
"""Test that run() delegates to _clean_messages_by_time_range."""
policy = MagicMock(spec=BillingDisabledPolicy)
service = MessagesCleanService(
policy=policy,
end_before=datetime.datetime.now(),
batch_size=10,
)
with patch.object(service, "_clean_messages_by_time_range") as mock_clean:
mock_clean.return_value = {"total_deleted": 5}
result = service.run()
assert result == {"total_deleted": 5}
mock_clean.assert_called_once()
@@ -1,20 +1,16 @@
"""Unit tests for OAuthServerService with SQLite-backed database access."""
"""Testcontainers integration tests for OAuthServerService."""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import cast
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest
from models.engine import db
from models.model import OAuthProviderApp
from services.oauth_server import (
OAUTH_ACCESS_TOKEN_EXPIRES_IN,
@@ -27,24 +23,10 @@ from services.oauth_server import (
)
@pytest.fixture
def oauth_db() -> Iterator[Session]:
"""Provide the production database extension with an isolated SQLite provider table."""
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
OAuthProviderApp.__table__.create(db.engine)
with Session(db.engine, expire_on_commit=False) as session:
yield session
class TestOAuthServerServiceGetProviderApp:
"""Verify provider lookup against a real SQLAlchemy database."""
"""DB-backed tests for get_oauth_provider_app."""
def test_get_oauth_provider_app_returns_app_when_exists(self, oauth_db: Session) -> None:
client_id = f"client-{uuid4()}"
def _create_oauth_provider_app(self, db_session_with_containers: Session, *, client_id: str) -> OAuthProviderApp:
app = OAuthProviderApp(
app_icon="icon.png",
client_id=client_id,
@@ -53,30 +35,35 @@ class TestOAuthServerServiceGetProviderApp:
redirect_uris=["https://example.com/callback"],
scope="read",
)
oauth_db.add(app)
oauth_db.commit()
db_session_with_containers.add(app)
db_session_with_containers.commit()
return app
def test_get_oauth_provider_app_returns_app_when_exists(self, db_session_with_containers: Session):
client_id = f"client-{uuid4()}"
created = self._create_oauth_provider_app(db_session_with_containers, client_id=client_id)
result = OAuthServerService.get_oauth_provider_app(client_id)
assert result is not None
assert result.client_id == client_id
assert result.id == app.id
assert result.id == created.id
def test_get_oauth_provider_app_returns_none_when_not_exists(self, oauth_db: Session) -> None:
def test_get_oauth_provider_app_returns_none_when_not_exists(self, db_session_with_containers: Session):
result = OAuthServerService.get_oauth_provider_app(f"nonexistent-{uuid4()}")
assert result is None
class TestOAuthServerServiceTokenOperations:
"""Verify Redis-backed token signing and validation branches."""
"""Redis-backed tests for token sign/validate operations."""
@pytest.fixture
def mock_redis(self):
with patch("services.oauth_server.redis_client") as mock:
yield mock
def test_sign_authorization_code_stores_and_returns_code(self, mock_redis) -> None:
def test_sign_authorization_code_stores_and_returns_code(self, mock_redis):
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000111")
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
code = OAuthServerService.sign_oauth_authorization_code("client-1", "user-1")
@@ -88,7 +75,7 @@ class TestOAuthServerServiceTokenOperations:
ex=600,
)
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis) -> None:
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis):
mock_redis.get.return_value = None
with pytest.raises(BadRequest, match="invalid code"):
@@ -98,13 +85,14 @@ class TestOAuthServerServiceTokenOperations:
client_id="client-1",
)
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis) -> None:
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis):
token_uuids = [
uuid.UUID("00000000-0000-0000-0000-000000000201"),
uuid.UUID("00000000-0000-0000-0000-000000000202"),
]
with patch("services.oauth_server.uuid.uuid4", side_effect=token_uuids):
mock_redis.get.return_value = b"user-1"
access_token, refresh_token = OAuthServerService.sign_oauth_access_token(
grant_type=OAuthGrantType.AUTHORIZATION_CODE,
code="code-1",
@@ -126,7 +114,7 @@ class TestOAuthServerServiceTokenOperations:
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
)
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis) -> None:
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis):
mock_redis.get.return_value = None
with pytest.raises(BadRequest, match="invalid refresh token"):
@@ -136,10 +124,11 @@ class TestOAuthServerServiceTokenOperations:
client_id="client-1",
)
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis) -> None:
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis):
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000301")
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
mock_redis.get.return_value = b"user-1"
access_token, returned_refresh = OAuthServerService.sign_oauth_access_token(
grant_type=OAuthGrantType.REFRESH_TOKEN,
refresh_token="refresh-1",
@@ -149,14 +138,14 @@ class TestOAuthServerServiceTokenOperations:
assert access_token == str(deterministic_uuid)
assert returned_refresh == "refresh-1"
def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis) -> None:
def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis):
grant_type = cast(OAuthGrantType, "invalid-grant-type")
result = OAuthServerService.sign_oauth_access_token(grant_type=grant_type, client_id="client-1")
assert result is None
def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis) -> None:
def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis):
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000401")
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
refresh_token = OAuthServerService._sign_oauth_refresh_token("client-2", "user-2")
@@ -168,21 +157,22 @@ class TestOAuthServerServiceTokenOperations:
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
)
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, sqlite_engine: Engine) -> None:
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, db_session_with_containers: Session):
mock_redis.get.return_value = None
session = MagicMock()
with Session(sqlite_engine) as session:
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", session)
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", db_session_with_containers)
assert result is None
def test_validate_access_token_loads_user_when_exists(self, mock_redis, sqlite_engine: Engine) -> None:
def test_validate_access_token_loads_user_when_exists(self, mock_redis, db_session_with_containers: Session):
mock_redis.get.return_value = b"user-88"
expected_user = MagicMock()
with Session(sqlite_engine) as session:
with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load:
result = OAuthServerService.validate_oauth_access_token("client-1", "access-token", session)
mock_load.assert_called_once_with("user-88", session)
with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load:
result = OAuthServerService.validate_oauth_access_token(
"client-1", "access-token", db_session_with_containers
)
assert result is expected_user
mock_load.assert_called_once_with("user-88", db_session_with_containers)
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import uuid
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -11,13 +12,13 @@ from sqlalchemy.orm import Session
from graphon.enums import WorkflowExecutionStatus
from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
from models.workflow import WorkflowAppLogCreatedFrom
from services.account_service import AccountService, TenantService
# Delay import of AppService to avoid circular dependency
# from services.app_service import AppService, CreateAppParams
from services.workflow_app_service import WorkflowAppService
from services.workflow_app_service import LogView, WorkflowAppService
from tests.test_containers_integration_tests.helpers import generate_valid_password
@@ -1626,3 +1627,73 @@ class TestWorkflowAppService:
end_user_item = next(d for d in result["data"] if d["created_by_end_user"] is not None)
assert account_item["created_by_account"].id == account.id
assert end_user_item["created_by_end_user"].id == end_user.id
class TestLogView:
def test_details_and_proxy_attributes(self):
log = SimpleNamespace(id="log-1", status="succeeded")
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
assert view.details == {"trigger_metadata": {"type": "plugin"}}
assert view.status == "succeeded"
class TestHandleTriggerMetadata:
def test_returns_empty_dict_when_metadata_missing(self):
service = WorkflowAppService()
assert service.handle_trigger_metadata("tenant-1", None) == {}
def test_enriches_plugin_icons(self):
service = WorkflowAppService()
meta = {
"type": AppTriggerType.TRIGGER_PLUGIN.value,
"icon_filename": "light.png",
"icon_dark_filename": "dark.png",
}
with patch(
"services.workflow_app_service.PluginService.get_plugin_icon_url",
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
) as mock_icon:
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
assert result["icon"] == "https://cdn/light.png"
assert result["icon_dark"] == "https://cdn/dark.png"
assert mock_icon.call_count == 2
def test_non_plugin_metadata_without_icon_lookup(self):
service = WorkflowAppService()
meta = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
mock_icon.assert_not_called()
class TestSafeJsonLoads:
@pytest.mark.parametrize(
("value", "expected"),
[
(None, None),
("", None),
('{"k":"v"}', {"k": "v"}),
("not-json", None),
({"raw": True}, {"raw": True}),
],
)
def test_handles_various_inputs(self, value, expected):
assert WorkflowAppService._safe_json_loads(value) == expected
class TestSafeParseUuid:
def test_returns_none_for_short_or_invalid_values(self):
service = WorkflowAppService()
assert service._safe_parse_uuid("short") is None
assert service._safe_parse_uuid("x" * 40) is None
def test_returns_uuid_for_valid_string(self):
service = WorkflowAppService()
raw = str(uuid.uuid4())
result = service._safe_parse_uuid(raw)
assert result is not None
assert str(result) == raw
@@ -6,7 +6,6 @@ import json
import os
import threading
import time
from collections.abc import Iterator
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
@@ -16,12 +15,9 @@ import pytest
import sqlalchemy as sa
from click.testing import CliRunner
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from graphon.model_runtime.entities.model_entities import ModelType
from models import Dataset, DatasetPermission, DatasetPermissionEnum
from models.account import Tenant
from models.base import TypeBase
from models.enums import CredentialSourceType
from models.provider import ProviderModel
from tests.helpers.legacy_model_type_migration import (
@@ -63,40 +59,6 @@ def command_module():
)
@pytest.fixture
def rbac_session(sqlite_engine: sa.Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
"""Bind RBAC command reads to persisted SQLite dataset rows."""
TypeBase.metadata.create_all(
sqlite_engine,
tables=[Dataset.__table__, DatasetPermission.__table__],
)
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr("commands.rbac.session_factory.create_session", factory)
with factory() as session:
yield session
def _persist_dataset(
session: Session,
*,
dataset_id: str = "dataset-1",
tenant_id: str = "tenant-1",
permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME,
created_by: str = "creator-account-1",
) -> Dataset:
dataset = Dataset(
id=dataset_id,
tenant_id=tenant_id,
name=f"Dataset {dataset_id}",
permission=permission,
created_by=created_by,
)
session.add(dataset)
session.commit()
return dataset
def _parse_json_lines(output: io.StringIO) -> list[dict[str, object]]:
return [json.loads(line) for line in output.getvalue().splitlines() if line.strip()]
@@ -401,35 +363,56 @@ def test_dataset_permission_rbac_migration_maps_legacy_permissions_to_enum_scope
def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator(
command_module,
rbac_session: Session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
rbac_module = importlib.import_module("commands.rbac")
_persist_dataset(rbac_session)
dataset_row = SimpleNamespace(
id="dataset-1",
tenant_id="tenant-1",
permission="only_me",
created_by="creator-account-1",
)
execute_results = [[dataset_row], [], []]
calls: list[dict[str, object]] = []
read_transaction_ended = False
session_closed = False
class FakeExecuteResult:
def __init__(self, rows: list[object]) -> None:
self._rows = rows
def all(self) -> list[object]:
return self._rows
class FakeSession:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback) -> None:
nonlocal session_closed
session_closed = True
pass
def execute(self, stmt):
return FakeExecuteResult(execute_results.pop(0))
class FakeSessionFactory:
@staticmethod
def create_session() -> FakeSession:
return FakeSession()
def fake_replace_whitelist(**kwargs):
assert read_transaction_ended is True
assert session_closed is True
calls.append(kwargs)
def _record_transaction_end(session: Session, transaction: object) -> None:
nonlocal read_transaction_ended
del transaction
if session.get_bind() is rbac_session.get_bind():
read_transaction_ended = True
sa.event.listen(Session, "after_transaction_end", _record_transaction_end)
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
monkeypatch.setattr(rbac_module.RBACService.DatasetAccess, "replace_whitelist", fake_replace_whitelist)
try:
command_module.migrate_dataset_permissions_to_rbac.callback(
tenant_id=None,
dataset_id=None,
batch_size=500,
dry_run=False,
)
finally:
sa.event.remove(Session, "after_transaction_end", _record_transaction_end)
command_module.migrate_dataset_permissions_to_rbac.callback(
tenant_id=None,
dataset_id=None,
batch_size=500,
dry_run=False,
)
assert calls[0]["tenant_id"] == "tenant-1"
assert calls[0]["account_id"] == "creator-account-1"
@@ -439,19 +422,41 @@ def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator(
def test_dataset_permission_rbac_migration_dry_run_outputs_structured_proposed_changes(
command_module,
rbac_session: Session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
rbac_module = importlib.import_module("commands.rbac")
dataset = _persist_dataset(rbac_session, permission=DatasetPermissionEnum.PARTIAL_TEAM)
rbac_session.add(
DatasetPermission(
dataset_id=dataset.id,
account_id="member-account-1",
tenant_id=dataset.tenant_id,
)
dataset_row = SimpleNamespace(
id="dataset-1",
tenant_id="tenant-1",
permission="partial_members",
created_by="creator-account-1",
)
rbac_session.commit()
permission_row = SimpleNamespace(dataset_id="dataset-1", account_id="member-account-1")
execute_results = [[dataset_row], [permission_row], []]
class FakeExecuteResult:
def __init__(self, rows: list[object]) -> None:
self._rows = rows
def all(self) -> list[object]:
return self._rows
class FakeSession:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback) -> None:
pass
def execute(self, stmt):
return FakeExecuteResult(execute_results.pop(0))
class FakeSessionFactory:
@staticmethod
def create_session() -> FakeSession:
return FakeSession()
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
monkeypatch.setattr(
rbac_module.RBACService.DatasetAccess,
"replace_whitelist",
@@ -1301,36 +1306,50 @@ def test_provider_models_processing_uses_same_plan_locking_and_transaction_entry
begin_calls: list[str] = []
configure_calls: list[str] = []
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
if session.get_bind() is sqlite_engine and transaction.parent is None:
begin_calls.append(current_phase["name"])
class _FakeBeginContext:
def __init__(self, phase: str) -> None:
self._phase = phase
def _fake_build_plan(self, session: Session, candidate, *, lock_rows: bool):
assert session.get_bind() is sqlite_engine
def __enter__(self) -> None:
begin_calls.append(self._phase)
def __exit__(self, exc_type, exc, tb) -> bool:
return False
class _FakeSession:
def __init__(self, phase: str) -> None:
self._phase = phase
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb) -> bool:
return False
def begin(self) -> _FakeBeginContext:
return _FakeBeginContext(self._phase)
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
return _FakeSession(current_phase["name"])
def _fake_build_plan(self, session, candidate, *, lock_rows: bool):
lock_rows_seen.append((current_phase["name"], lock_rows))
return migration_module._ProviderModelGroupPlan(
group_row_ids=[str(candidate.row.id)],
winner=None,
loser_rows=[],
)
return SimpleNamespace(group_row_ids=[str(candidate.row.id)], winner=None, loser_rows=[])
def _fake_emit_plan(self, plan, *, session, tx_id: str, business_key: dict[str, object]) -> None:
return None
def _fake_configure(self, session: Session) -> None:
assert session.get_bind() is sqlite_engine
def _fake_configure(self, session) -> None:
configure_calls.append(current_phase["name"])
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
monkeypatch.setattr(migration_module.Migration, "_build_provider_model_group_plan", _fake_build_plan)
monkeypatch.setattr(migration_module.Migration, "_emit_provider_model_group_plan", _fake_emit_plan)
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure)
sa.event.listen(Session, "after_transaction_create", _record_begin)
try:
dry_migration._process_provider_model_group(candidate, business_key)
current_phase["name"] = "apply"
apply_migration._process_provider_model_group(candidate, business_key)
finally:
sa.event.remove(Session, "after_transaction_create", _record_begin)
dry_migration._process_provider_model_group(candidate, business_key)
current_phase["name"] = "apply"
apply_migration._process_provider_model_group(candidate, business_key)
assert [phase for phase, _ in lock_rows_seen] == ["dry", "apply"]
assert lock_rows_seen[0][1] == lock_rows_seen[1][1]
@@ -1373,22 +1392,6 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
sqlite_engine: sa.Engine,
monkeypatch: pytest.MonkeyPatch,
) -> None:
create_minimal_legacy_model_type_schema(sqlite_engine)
created_at = datetime(2025, 1, 1, 12, 0, 0)
_insert_load_balancing_model_config(
sqlite_engine,
row_id="40000000-0000-0000-0000-000000000001",
tenant_id="tenant-1",
provider_name="openai",
model_name="gpt-4o-mini",
model_type="text-generation",
name="credential",
encrypted_config="{}",
credential_id="50000000-0000-0000-0000-000000000001",
enabled=True,
created_at=created_at,
updated_at=created_at,
)
output = io.StringIO()
migration = migration_module.Migration(
tenant_id="tenant-1",
@@ -1398,18 +1401,37 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
model_types=(ModelType.LLM,),
orm_models=(migration_module.LoadBalancingModelConfig,),
)
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
candidate = migration_module._RowWithRawModelType(
row=SimpleNamespace(id="lb-row-1"),
raw_model_type="text-generation",
canonical_model_type=ModelType.LLM,
)
lock_timeout_exc = OperationalError("SELECT 1", {}, SimpleNamespace(pgcode="55P03"))
transaction_begins = 0
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
nonlocal transaction_begins
if session.get_bind() is sqlite_engine and transaction.parent is None:
transaction_begins += 1
class _FakeBeginContext:
def __enter__(self) -> None:
return None
def __exit__(self, exc_type, exc, tb) -> bool:
return False
class _FakeSession:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb) -> bool:
return False
def begin(self) -> _FakeBeginContext:
return _FakeBeginContext()
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
return _FakeSession()
def _fake_reload(self, session, original_candidate, *, lock_rows: bool):
raise lock_timeout_exc
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", lambda self, session: None)
monkeypatch.setattr(
migration_module.Migration,
@@ -1417,22 +1439,17 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
_fake_reload,
)
sa.event.listen(Session, "after_transaction_create", _record_begin)
try:
migration._process_load_balancing_model_config_row(candidate)
finally:
sa.event.remove(Session, "after_transaction_create", _record_begin)
migration._process_load_balancing_model_config_row(candidate)
lines = _parse_json_lines(output)
assert len(lines) == 1
assert lines[0]["event"] == "lock_timeout_skipped"
attrs = cast(dict[str, object], lines[0]["attrs"])
assert attrs["table_name"] == "load_balancing_model_configs"
assert attrs["id"] == str(candidate.row.id)
assert attrs["id"] == "lb-row-1"
assert attrs["error"] == str(lock_timeout_exc)
assert isinstance(attrs["stacktrace"], str)
assert "OperationalError" in attrs["stacktrace"]
assert transaction_begins == 1
def test_process_load_balancing_model_config_row_logs_update_after_sql_execution(
@@ -1440,23 +1457,6 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
sqlite_engine: sa.Engine,
monkeypatch: pytest.MonkeyPatch,
) -> None:
create_minimal_legacy_model_type_schema(sqlite_engine)
created_at = datetime(2025, 1, 1, 12, 0, 0)
row_id = "40000000-0000-0000-0000-000000000002"
_insert_load_balancing_model_config(
sqlite_engine,
row_id=row_id,
tenant_id="tenant-1",
provider_name="openai",
model_name="gpt-4o-mini",
model_type="text-generation",
name="credential",
encrypted_config="{}",
credential_id="50000000-0000-0000-0000-000000000002",
enabled=True,
created_at=created_at,
updated_at=created_at,
)
migration = migration_module.Migration(
tenant_id="tenant-1",
engine=sqlite_engine,
@@ -1465,33 +1465,42 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
model_types=(ModelType.LLM,),
orm_models=(migration_module.LoadBalancingModelConfig,),
)
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
candidate = migration_module._RowWithRawModelType(
row=SimpleNamespace(id="lb-row-1"),
raw_model_type="text-generation",
canonical_model_type=ModelType.LLM,
)
action_log: list[str] = []
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
if session.get_bind() is sqlite_engine and transaction.parent is None:
class _FakeBeginContext:
def __enter__(self) -> None:
action_log.append("begin")
def _record_sql(
connection: sa.Connection,
cursor: object,
statement: str,
parameters: object,
context: object,
executemany: bool,
) -> None:
del connection, cursor, parameters, context, executemany
if statement.lstrip().upper().startswith("UPDATE"):
def __exit__(self, exc_type, exc, tb) -> bool:
return False
class _FakeSession:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb) -> bool:
return False
def begin(self) -> _FakeBeginContext:
return _FakeBeginContext()
def execute(self, stmt) -> None:
action_log.append("sql_execute")
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
return _FakeSession()
def _fake_configure(self, session) -> None:
action_log.append("configure_lock_timeout")
original_reload = migration_module.Migration._reload_load_balancing_model_config_candidate
def _record_reload(self, session: Session, original_candidate, *, lock_rows: bool):
def _fake_reload(self, session, original_candidate, *, lock_rows: bool):
action_log.append(f"reload_candidate:{lock_rows}")
return original_reload(self, session, original_candidate, lock_rows=lock_rows)
return candidate
def _fake_log_row_updated(self, *args, **kwargs) -> None:
action_log.append("log_row_updated")
@@ -1499,11 +1508,12 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
def _fake_cache_cleanup(self, *, row_id: str, tx_id: str) -> None:
action_log.append("cache_cleanup")
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure)
monkeypatch.setattr(
migration_module.Migration,
"_reload_load_balancing_model_config_candidate",
_record_reload,
_fake_reload,
)
monkeypatch.setattr(migration_module.Migration, "_log_row_updated", _fake_log_row_updated)
monkeypatch.setattr(
@@ -1512,13 +1522,7 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
_fake_cache_cleanup,
)
sa.event.listen(Session, "after_transaction_create", _record_begin)
sa.event.listen(sqlite_engine, "before_cursor_execute", _record_sql)
try:
migration._process_load_balancing_model_config_row(candidate)
finally:
sa.event.remove(sqlite_engine, "before_cursor_execute", _record_sql)
sa.event.remove(Session, "after_transaction_create", _record_begin)
migration._process_load_balancing_model_config_row(candidate)
assert action_log == [
"begin",
@@ -1528,10 +1532,6 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
"log_row_updated",
"cache_cleanup",
]
with Session(sqlite_engine) as session:
persisted = session.get(migration_module.LoadBalancingModelConfig, row_id)
assert persisted is not None
assert persisted.model_type == ModelType.LLM
def test_load_balancing_model_config_cache_delete_failure_logs_stacktrace(
@@ -4,7 +4,6 @@ from dotenv import dotenv_values
BASE_API_AND_DOCKER_CONFIG_SET_DIFF: frozenset[str] = frozenset(
(
"AGENT_BACKEND_API_TOKEN",
"APP_MAX_EXECUTION_TIME",
"BATCH_UPLOAD_LIMIT",
"CELERY_BEAT_SCHEDULER_TIME",
@@ -44,7 +43,6 @@ BASE_API_AND_DOCKER_CONFIG_SET_DIFF: frozenset[str] = frozenset(
BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset(
(
"AGENT_BACKEND_API_TOKEN",
"BATCH_UPLOAD_LIMIT",
"CELERY_BEAT_SCHEDULER_TIME",
"HTTP_REQUEST_MAX_CONNECT_TIMEOUT",
+26 -56
View File
@@ -1,13 +1,11 @@
import os
import shutil
from collections.abc import Iterator
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy import create_engine
from sqlalchemy.engine import URL, Engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
# Getting the absolute path of the current file's directory
@@ -37,7 +35,7 @@ os.environ.setdefault("OPENDAL_SCHEME", "fs")
os.environ.setdefault("OPENDAL_FS_ROOT", "/tmp/dify-storage")
os.environ.setdefault("STORAGE_TYPE", "opendal")
import core.db.session_factory as session_factory_module
from core.db.session_factory import configure_session_factory, session_factory
from extensions import ext_redis
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.base import TypeBase
@@ -113,70 +111,42 @@ def reset_secret_key():
dify_config.SECRET_KEY = original
@pytest.fixture(scope="session")
def _unit_test_engine():
engine = create_engine("sqlite:///:memory:")
yield engine
engine.dispose()
@pytest.fixture
def _sqlite_engine(_sqlite_database_template: Path, tmp_path: Path) -> Iterator[Engine]:
"""Create an engine over a pristine per-test copy of the SQLite schema."""
database_path = tmp_path / "unit-tests.sqlite3"
shutil.copyfile(_sqlite_database_template, database_path)
engine = create_engine(URL.create("sqlite", database=str(database_path)))
def sqlite_engine() -> Iterator[Engine]:
"""Create an isolated in-memory SQLite engine for tests that need a disposable database."""
engine = create_engine("sqlite:///:memory:")
try:
yield engine
finally:
engine.dispose()
database_path.unlink(missing_ok=True)
@pytest.fixture(scope="session")
def _sqlite_database_template(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create one empty full-schema SQLite database per pytest worker."""
@pytest.fixture
def sqlite_session(request: pytest.FixtureRequest, sqlite_engine: Engine) -> Iterator[Session]:
"""Yield a SQLite session after creating the model tables passed through ``request.param``."""
database_path = tmp_path_factory.mktemp("sqlite-template") / "unit-tests.sqlite3"
engine = create_engine(URL.create("sqlite", database=str(database_path)))
try:
TypeBase.metadata.create_all(engine)
finally:
engine.dispose()
return database_path
models: tuple[type[TypeBase], ...] = request.param
tables = [model.metadata.tables[model.__tablename__] for model in models]
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
with session_factory() as session:
yield session
@pytest.fixture(autouse=True)
def _sqlite_session_factory(
_sqlite_engine: Engine,
monkeypatch: pytest.MonkeyPatch,
) -> sessionmaker[Session]:
"""Bind all unit-test Sessions to the pristine full-schema SQLite database."""
factory = sessionmaker(bind=_sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(session_factory_module, "_session_maker", factory)
return factory
@pytest.fixture
def sqlite_engine(_sqlite_engine: Engine) -> Engine:
"""Expose the pristine full-schema SQLite engine to tests."""
return _sqlite_engine
@pytest.fixture
def sqlite_session_factory(_sqlite_session_factory: sessionmaker[Session]) -> sessionmaker[Session]:
"""Expose the shared SQLite session factory to tests."""
return _sqlite_session_factory
@pytest.fixture
def sqlite_session(_sqlite_session_factory: sessionmaker[Session]) -> Iterator[Session]:
"""Yield a session over the pristine full-schema SQLite database.
Legacy indirect model parameters remain accepted by pytest but are ignored.
Remove those decorators as their test files receive individual review.
"""
with _sqlite_session_factory() as session:
yield session
def _configure_session_factory(_unit_test_engine):
try:
session_factory.get_session_maker()
except RuntimeError:
configure_session_factory(_unit_test_engine, expire_on_commit=False)
def persist_service_api_tenant_owner(session: Session, tenant: Tenant, owner: Account) -> TenantAccountJoin:
@@ -708,121 +708,6 @@ def test_app_list_api_attaches_permission_keys(app, app_module):
assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
def test_recent_app_list_api_returns_only_home_card_fields(app, app_module):
method = app_module.RecentAppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
recent_app = SimpleNamespace(
id="app-1",
name="Recent App",
icon_type="emoji",
icon="🚀",
icon_background="#FFFFFF",
mode="chat",
author_name="Recent Author",
updated_at=_ts(15),
maintainer="acct-1",
)
get_recent_apps = MagicMock(return_value=[recent_app])
with app.test_request_context("/apps/recent?limit=8"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", False)
monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.MyPermissions,
"get",
lambda tenant_id, account_id, session: app_module.enterprise_rbac_service.MyPermissionsResponse(
app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot(
overrides=[
app_module.enterprise_rbac_service.ResourcePermissionKeys(
resource_id="app-1",
permission_keys=["app.acl.monitor"],
)
]
)
),
)
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock())
assert status == 200
assert resp == {
"data": [
{
"id": "app-1",
"name": "Recent App",
"icon_type": "emoji",
"icon": "🚀",
"icon_background": "#FFFFFF",
"mode": "chat",
"author_name": "Recent Author",
"updated_at": int(_ts(15).timestamp()),
"permission_keys": ["app.acl.monitor"],
"maintainer": "acct-1",
"icon_url": None,
}
]
}
params = get_recent_apps.call_args.args[2]
assert params.limit == 8
assert "total" not in resp
assert "description" not in resp["data"][0]
assert "tags" not in resp["data"][0]
assert "workflow" not in resp["data"][0]
@pytest.mark.parametrize("mode", ["channel", "rag-pipeline", "agent"])
def test_recent_app_response_rejects_non_home_app_modes(app_module, mode: str) -> None:
with pytest.raises(ValidationError):
app_module.RecentAppResponse.model_validate(
{
"id": "app-1",
"name": "Recent App",
"mode": mode,
"updated_at": _ts(),
}
)
def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module):
method = app_module.RecentAppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
get_recent_apps = MagicMock(return_value=[])
with app.test_request_context("/apps/recent"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.MyPermissions,
"get",
lambda tenant_id, account_id, session: app_module.enterprise_rbac_service.MyPermissionsResponse(
workspace=app_module.enterprise_rbac_service.WorkspacePermissionSnapshot(
permission_keys=["app.create_and_management"]
)
),
)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.AppAccess,
"whitelist_resources",
lambda tenant_id, account_id: SimpleNamespace(
unrestricted=False,
resource_ids=["app-shared"],
),
)
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock())
assert status == 200
assert resp == {"data": []}
params = get_recent_apps.call_args.args[2]
assert params.accessible_app_ids == ["app-shared"]
assert params.include_own_apps is True
def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permission(app, app_module):
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
@@ -1,22 +1,18 @@
from __future__ import annotations
import inspect
from collections.abc import Callable, Iterator
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Literal, cast
from typing import cast
from unittest.mock import MagicMock, PropertyMock, patch
from uuid import UUID
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from controllers.console.datasets import data_source as module
from controllers.console.datasets.data_source import DataSourceApi, DataSourceNotionListApi
from models import Account, DataSourceOauthBinding
from models.engine import db
ControllerMethod = Callable[..., tuple[dict[str, object], int]]
@@ -26,15 +22,10 @@ def unwrap(func: object) -> ControllerMethod:
@pytest.fixture
def flask_app() -> Iterator[Flask]:
def flask_app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
DataSourceOauthBinding.__table__.create(db.engine)
yield app
return app
@pytest.fixture
@@ -44,13 +35,9 @@ def current_user() -> Account:
return account
TENANT_ID = "11111111-1111-1111-1111-111111111111"
BINDING_ID = "22222222-2222-2222-2222-222222222222"
def _add_binding(session: Session, *, disabled: bool) -> DataSourceOauthBinding:
def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) -> None:
binding = DataSourceOauthBinding(
tenant_id=TENANT_ID,
tenant_id="tenant-1",
access_token="token",
provider="notion",
source_info={
@@ -68,31 +55,24 @@ def _add_binding(session: Session, *, disabled: bool) -> DataSourceOauthBinding:
}
],
},
disabled=disabled,
)
binding.id = BINDING_ID
binding.id = "binding-1"
binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC)
session.add(binding)
session.commit()
return binding
binding.disabled = False
def test_get_data_source_integrates_serializes_orm_binding(
flask_app: Flask,
) -> None:
binding = _add_binding(db.session, disabled=False)
expected_created_at = int(binding.created_at.timestamp())
with flask_app.test_request_context("/"):
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID)
with (
flask_app.test_request_context("/"),
patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [binding])),
):
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1")
assert status == 200
assert response == {
"data": [
{
"id": BINDING_ID,
"id": "binding-1",
"provider": "notion",
"created_at": expected_created_at,
"created_at": 1779670923,
"is_bound": True,
"disabled": False,
"source_info": {
@@ -116,75 +96,34 @@ def test_get_data_source_integrates_serializes_orm_binding(
}
def test_get_data_source_integrates_preserves_empty_list_when_no_binding(
flask_app: Flask,
) -> None:
with flask_app.test_request_context("/"):
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID)
def test_get_data_source_integrates_preserves_empty_list_when_no_binding(flask_app: Flask) -> None:
with (
flask_app.test_request_context("/"),
patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [])),
):
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1")
assert status == 200
assert response == {"data": []}
@pytest.mark.parametrize(
("disabled", "action", "expected_disabled"),
[(True, "enable", False), (False, "disable", True)],
)
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
def test_patch_data_source_binding_updates_state(
flask_app: Flask,
sqlite_session: Session,
disabled: bool,
action: Literal["enable", "disable"],
expected_disabled: bool,
) -> None:
_add_binding(sqlite_session, disabled=disabled)
sqlite_session.expunge_all()
def test_patch_data_source_binding_uses_injected_session(flask_app: Flask) -> None:
binding = MagicMock(disabled=True)
session = MagicMock()
session.scalar.return_value = binding
with flask_app.test_request_context("/"):
response, status = unwrap(DataSourceApi().patch)(
DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action
)
response, status = unwrap(DataSourceApi().patch)(DataSourceApi(), session, "tenant-1", uuid4(), "enable")
sqlite_session.flush()
sqlite_session.expire_all()
binding = sqlite_session.scalar(select(DataSourceOauthBinding).where(DataSourceOauthBinding.id == BINDING_ID))
assert status == 200
assert response == {"result": "success"}
assert binding is not None
assert binding.disabled is expected_disabled
assert binding.disabled is False
session.scalar.assert_called_once()
session.add.assert_not_called()
session.commit.assert_not_called()
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
def test_patch_data_source_binding_rejects_unknown_binding(
flask_app: Flask,
sqlite_session: Session,
) -> None:
with flask_app.test_request_context("/"), pytest.raises(NotFound, match="Data source binding not found"):
unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), "enable")
@pytest.mark.parametrize(("disabled", "action"), [(False, "enable"), (True, "disable")])
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
def test_patch_data_source_binding_rejects_current_state(
flask_app: Flask,
sqlite_session: Session,
disabled: bool,
action: Literal["enable", "disable"],
) -> None:
_add_binding(sqlite_session, disabled=disabled)
sqlite_session.expunge_all()
with flask_app.test_request_context("/"), pytest.raises(ValueError):
unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action)
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_notion_pre_import_pages_serializes_frontend_list_shape(
flask_app: Flask,
current_user: Account,
sqlite_session: Session,
) -> None:
def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask, current_user: Account) -> None:
page = MagicMock(
page_id="page-1",
page_name="Page",
@@ -206,6 +145,8 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(
get_online_document_pages=MagicMock(return_value=iter([online_document_message])),
datasource_provider_type=MagicMock(return_value="online_document"),
)
session = MagicMock()
with (
flask_app.test_request_context("/?credential_id=credential-1"),
patch.object(
@@ -217,7 +158,7 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(
patch("core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", return_value=runtime),
):
response, status = unwrap(DataSourceNotionListApi().get)(
DataSourceNotionListApi(), sqlite_session, "tenant-1", current_user
DataSourceNotionListApi(), session, "tenant-1", current_user
)
assert status == 200
@@ -242,38 +183,3 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(
}
runtime.get_online_document_pages.assert_called_once()
assert runtime.get_online_document_pages.call_args.kwargs["datasource_parameters"] == {}
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_notion_pre_import_pages_rejects_missing_credential(
flask_app: Flask,
current_user: Account,
sqlite_session: Session,
) -> None:
with (
flask_app.test_request_context("/?credential_id=credential-1"),
patch.object(module.DatasourceProviderService, "get_datasource_credentials", return_value=None),
pytest.raises(NotFound, match="Credential not found"),
):
unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user)
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_notion_pre_import_pages_rejects_non_notion_dataset(
flask_app: Flask,
current_user: Account,
sqlite_session: Session,
) -> None:
dataset = MagicMock(data_source_type="other_type")
with (
flask_app.test_request_context("/?credential_id=credential-1&dataset_id=dataset-1"),
patch.object(
module.DatasourceProviderService,
"get_datasource_credentials",
return_value={"token": "token"},
),
patch.object(module.DatasetService, "get_dataset", return_value=dataset),
pytest.raises(ValueError, match="Dataset is not notion type"),
):
unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user)
@@ -1,171 +0,0 @@
"""Unit tests for controllers.console.datasets.data_source Notion endpoints."""
from __future__ import annotations
import inspect
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from controllers.console.datasets.data_source import (
DataSourceNotionDatasetSyncApi,
DataSourceNotionDocumentSyncApi,
DataSourceNotionIndexingEstimateApi,
DataSourceNotionPreviewApi,
)
from core.rag.index_processor.constant.index_type import IndexStructureType
from models import Account
@pytest.fixture
def current_user() -> Account:
account = Account(name="Test User", email="u1@example.com")
account.id = "u1"
return account
class TestDataSourceNotionPreviewApi:
def test_get_preview_success(self, app: Flask) -> None:
api = DataSourceNotionPreviewApi()
method = inspect.unwrap(api.get)
extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")])
with (
app.test_request_context("/?credential_id=c1"),
patch(
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
return_value={"integration_secret": "t"},
),
patch(
"controllers.console.datasets.data_source.NotionExtractor",
return_value=extractor,
),
):
response, status = method(api, "tenant-1", "p1", "page")
assert status == 200
class TestDataSourceNotionIndexingEstimateApi:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_post_indexing_estimate_success(self, app: Flask, sqlite_session: Session) -> None:
api = DataSourceNotionIndexingEstimateApi()
method = inspect.unwrap(api.post)
empty_rules: dict[str, object] = {}
payload: dict[str, object] = {
"notion_info_list": [
{
"workspace_id": "w1",
"credential_id": "c1",
"pages": [{"page_id": "p1", "type": "page"}],
}
],
"process_rule": {"rules": empty_rules},
"doc_form": IndexStructureType.PARAGRAPH_INDEX,
"doc_language": "English",
}
with (
app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}),
patch(
"controllers.console.datasets.data_source.DocumentService.estimate_args_validate",
),
patch(
"controllers.console.datasets.data_source.IndexingRunner.indexing_estimate",
return_value=MagicMock(model_dump=lambda: {"total_pages": 1}),
),
):
response, status = method(api, sqlite_session, "tenant-1")
assert status == 200
class TestDataSourceNotionDatasetSyncApi:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_success(self, app: Flask, sqlite_session: Session) -> None:
api = DataSourceNotionDatasetSyncApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=MagicMock(),
),
patch(
"controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id",
return_value=[MagicMock(id="d1")],
),
patch(
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
return_value=None,
),
):
response, status = method(api, sqlite_session, "ds-1")
assert status == 200
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_dataset_not_found(self, app: Flask, sqlite_session: Session) -> None:
api = DataSourceNotionDatasetSyncApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=None,
),
):
with pytest.raises(NotFound):
method(api, sqlite_session, "ds-1")
class TestDataSourceNotionDocumentSyncApi:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_success(self, app: Flask, sqlite_session: Session) -> None:
api = DataSourceNotionDocumentSyncApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=MagicMock(),
),
patch(
"controllers.console.datasets.data_source.DocumentService.get_document",
return_value=MagicMock(),
),
patch(
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
return_value=None,
),
):
response, status = method(api, sqlite_session, "ds-1", "doc-1")
assert status == 200
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_document_not_found(self, app: Flask, sqlite_session: Session) -> None:
api = DataSourceNotionDocumentSyncApi()
method = inspect.unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.data_source.DatasetService.get_dataset",
return_value=MagicMock(),
),
patch(
"controllers.console.datasets.data_source.DocumentService.get_document",
return_value=None,
),
):
with pytest.raises(NotFound):
method(api, sqlite_session, "ds-1", "doc-1")
@@ -15,9 +15,11 @@ from controllers.console.workspace.model_providers import (
ModelProviderIconApi,
ModelProviderListApi,
ModelProviderPaymentCheckoutUrlApi,
ModelProviderSummaryListApi,
ModelProviderValidateApi,
PreferredProviderTypeUpdateApi,
)
from core.entities.provider_entities import CredentialConfiguration
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
@@ -27,6 +29,10 @@ from models.provider import ProviderType
from services.entities.model_provider_entities import (
CustomConfigurationResponse,
CustomConfigurationStatus,
ModelProviderCustomConfigurationSummaryResponse,
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ModelProviderSystemConfigurationSummaryResponse,
ProviderResponse,
SystemConfigurationResponse,
)
@@ -140,6 +146,80 @@ class TestModelProviderListApi:
assert result == {"data": []}
class TestModelProviderSummaryListApi:
def test_get_success(self, app: Flask):
api = ModelProviderSummaryListApi()
method = unwrap(api.get)
provider = ModelProviderSummaryResponse(
tenant_id="tenant1",
provider="langgenius/openai/openai",
plugin_id="langgenius/openai",
label=I18nObject(en_US="OpenAI"),
description=I18nObject(en_US="OpenAI models"),
icon_small=I18nObject(en_US="icon.svg"),
icon_small_dark=None,
supported_model_types=[ModelType.LLM],
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
preferred_provider_type=ProviderType.CUSTOM,
is_configured=True,
custom_configuration=ModelProviderCustomConfigurationSummaryResponse(
status=CustomConfigurationStatus.ACTIVE,
available_credentials=[
CredentialConfiguration(
credential_id=VALID_UUID,
credential_name="production",
),
CredentialConfiguration(
credential_id="223e4567-e89b-12d3-a456-426614174000",
credential_name="backup",
),
],
current_credential_id=VALID_UUID,
current_credential_name="production",
current_credential_usable=True,
),
system_configuration=ModelProviderSystemConfigurationSummaryResponse(enabled=False),
)
plugin = ModelProviderPluginSummaryResponse(
installation_id="installation-1",
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:1.0.0@checksum",
runtime_type="local",
source="marketplace",
version="1.0.0",
)
with (
app.test_request_context("/"),
patch(
"controllers.console.workspace.model_providers.ModelProviderService.get_provider_summary_list",
return_value=([provider], {"langgenius/openai": plugin}),
) as get_provider_summary_list,
):
result = method(api, "tenant1")
get_provider_summary_list.assert_called_once_with(tenant_id="tenant1")
assert result["data"][0]["provider"] == "langgenius/openai/openai"
assert "tenant_id" not in result["data"][0]
assert result["data"][0]["custom_configuration"] == {
"status": "active",
"available_credentials": [
{
"credential_id": VALID_UUID,
"credential_name": "production",
},
{
"credential_id": "223e4567-e89b-12d3-a456-426614174000",
"credential_name": "backup",
},
],
"current_credential_id": VALID_UUID,
"current_credential_name": "production",
"current_credential_usable": True,
}
assert result["plugins"]["langgenius/openai"]["installation_id"] == "installation-1"
class TestModelProviderCredentialApi:
def test_get_success(self, app: Flask):
api = ModelProviderCredentialApi()
@@ -28,6 +28,7 @@ from controllers.console.workspace.plugin import (
PluginFetchMarketplacePkgApi,
PluginFetchPermissionApi,
PluginIconApi,
PluginInstalledIdsApi,
PluginInstallFromGithubApi,
PluginInstallFromMarketplaceApi,
PluginInstallFromPkgApi,
@@ -41,12 +42,16 @@ from controllers.console.workspace.plugin import (
PluginUploadFromBundleApi,
PluginUploadFromGithubApi,
PluginUploadFromPkgApi,
_list_hardcoded_builtin_tool_providers,
)
from core.plugin.entities.parameters import PluginParameterOption
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallation
from core.plugin.entities.plugin import PluginCategory, PluginDeclaration, PluginEntity, PluginInstallation
from core.plugin.entities.plugin_daemon import PluginInstallTask
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.entities.api_entities import ToolProviderApiEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from models.account import (
Account,
TenantAccountRole,
@@ -445,7 +450,7 @@ class TestPluginCategoryListApi:
mock_list = MagicMock(list=[plugin_item], has_more=True)
with (
app.test_request_context("/?page=2&page_size=10"),
app.test_request_context("/?page=2&page_size=10&query=weather&tags=search&tags=rag&language=zh_Hans"),
patch(
"controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list
) as list_mock,
@@ -456,18 +461,75 @@ class TestPluginCategoryListApi:
):
result = method(api, "t1", "tool")
list_mock.assert_called_once()
assert list_mock.call_args.args[0] == "t1"
assert list_mock.call_args.args[1] == "tool"
assert list_mock.call_args.args[2] == 2
assert list_mock.call_args.args[3] == 10
list_mock.assert_called_once_with(
"t1",
"tool",
2,
10,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
assert result["plugins"][0]["id"] == "entity-1"
assert result["plugins"][0]["plugin_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum"
assert result["builtin_tools"][0]["id"] == "builtin"
assert result["builtin_tools"][0]["type"] == "builtin"
assert result["has_more"] is True
assert "total" not in result
builtin_mock.assert_called_once_with("t1")
builtin_mock.assert_called_once_with(
"t1",
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
def test_builtin_tool_providers_use_the_category_list_filters(self):
search_provider = ToolProviderApiEntity(
id="search-provider",
author="dify",
name="search-provider",
description=I18nObject(en_US="Search provider", zh_Hans="搜索工具"),
icon="icon.svg",
label=I18nObject(en_US="Search", zh_Hans="搜索"),
type=ToolProviderType.BUILT_IN,
labels=["search"],
)
rag_provider = ToolProviderApiEntity(
id="rag-provider",
author="dify",
name="rag-provider",
description=I18nObject(en_US="RAG provider", zh_Hans="知识库工具"),
icon="icon.svg",
label=I18nObject(en_US="RAG", zh_Hans="知识库"),
type=ToolProviderType.BUILT_IN,
labels=["rag"],
)
with (
patch("controllers.console.workspace.plugin.ToolManager.list_default_builtin_providers", return_value=[]),
patch(
"controllers.console.workspace.plugin.ToolManager.list_hardcoded_providers",
return_value=[MagicMock(), MagicMock()],
),
patch("controllers.console.workspace.plugin.is_filtered", return_value=False),
patch(
"controllers.console.workspace.plugin.ToolTransformService.builtin_provider_to_user_provider",
side_effect=[search_provider, rag_provider],
),
patch("controllers.console.workspace.plugin.ToolTransformService.repack_provider"),
patch(
"controllers.console.workspace.plugin.BuiltinToolProviderSort.sort",
side_effect=lambda providers: providers,
),
):
result = _list_hardcoded_builtin_tool_providers(
"t1",
query="搜索",
tags=["search", "weather"],
language="zh_Hans",
)
assert [provider["id"] for provider in result] == ["search-provider"]
def test_non_tool_category_does_not_include_builtin_tools(self, app: Flask):
api = PluginCategoryListApi()
@@ -730,6 +792,39 @@ class TestPluginListInstallationsFromIdsApi:
assert result == ({"code": "plugin_error", "message": "error"}, 400)
class TestPluginInstalledIdsApi:
def test_success(self, app: Flask):
api = PluginInstalledIdsApi()
method = unwrap(api.get)
with (
app.test_request_context("/?category=tool"),
patch(
"controllers.console.workspace.plugin.PluginService.list_installed_plugin_ids",
return_value=["langgenius/openai", "langgenius/anthropic"],
) as list_installed_plugin_ids,
):
result = method(api, "t1")
assert result == {"plugin_ids": ["langgenius/openai", "langgenius/anthropic"]}
list_installed_plugin_ids.assert_called_once_with("t1", PluginCategory.Tool)
def test_daemon_error(self, app: Flask):
api = PluginInstalledIdsApi()
method = unwrap(api.get)
with (
app.test_request_context("/?category=tool"),
patch(
"controllers.console.workspace.plugin.PluginService.list_installed_plugin_ids",
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
class TestPluginUploadFromGithubApi:
def test_success(self, app: Flask, user):
api = PluginUploadFromGithubApi()
@@ -1,394 +0,0 @@
from __future__ import annotations
from inspect import unwrap
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
from flask import Flask
from controllers.console import console_ns
from controllers.console.workspace.skills import (
WorkspaceAgentSkillBindingsApi,
WorkspaceSkillAssistMessageApi,
WorkspaceSkillFilesApi,
WorkspaceSkillsApi,
WorkspaceSkillTagsApi,
WorkspaceSkillVersionApi,
)
from models.account import Account
from services.skill_management_service import SkillAssistAttachmentPayload, SkillManagementServiceError
@pytest.fixture
def app() -> Flask:
flask_app = Flask("test_workspace_skills")
flask_app.config["TESTING"] = True
return flask_app
@pytest.fixture
def current_user() -> Account:
user = Account(name="Test User", email="test@example.com")
user.id = "user-1"
return user
def _skill_detail() -> dict:
return {
"id": "skill-1",
"name": "finance-sop",
"display_name": "Finance SOP",
"icon": "📄",
"description": "",
"tags": [],
"name_manually_edited": False,
"visibility": "workspace",
"latest_published_version_id": None,
"reference_count": 0,
"created_by": "user-1",
"created_by_name": "Test User",
"updated_by": "user-1",
"updated_by_name": "Test User",
"created_at": 1,
"updated_at": 1,
"files": [
{
"id": "file-1",
"path": "SKILL.md",
"kind": "file",
"storage": "text",
"mime_type": "text/markdown",
"content": "---\nname: finance-sop\n---\n# Body",
"tool_file_id": None,
"size": 32,
"hash": "hash",
}
],
}
def test_create_skill_validates_payload_and_returns_detail(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillsApi()
method = unwrap(api.post)
service = MagicMock()
service.create_skill.return_value = _skill_detail()
with (
app.test_request_context("/", method="POST"),
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value={}),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload, status = method(api, "tenant-1", current_user)
assert status == 201
assert payload["id"] == "skill-1"
assert payload["files"][0]["path"] == "SKILL.md"
service.create_skill.assert_called_once()
assert service.create_skill.call_args.kwargs["tenant_id"] == "tenant-1"
assert service.create_skill.call_args.kwargs["user_id"] == "user-1"
def test_list_skills_uses_default_pagination_when_query_omits_page_and_limit(app: Flask) -> None:
api = WorkspaceSkillsApi()
method = unwrap(api.get)
service = MagicMock()
service.list_skills.return_value = {
"data": [],
"has_more": False,
"limit": 20,
"page": 1,
"total": 0,
}
with (
app.test_request_context("/?keyword=finance&tag=ops&tag=", method="GET"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1")
assert payload == {
"data": [],
"has_more": False,
"limit": 20,
"page": 1,
"total": 0,
}
service.list_skills.assert_called_once_with(
tenant_id="tenant-1",
keyword="finance",
page=1,
limit=20,
tags=["ops"],
)
def test_get_agent_skill_bindings_returns_card_data(app: Flask) -> None:
api = WorkspaceAgentSkillBindingsApi()
method = unwrap(api.get)
service = MagicMock()
service.list_agent_bindings.return_value = {
"agent_id": "agent-1",
"skill_ids": ["skill-1"],
"data": [
{
"id": "skill-1",
"priority": 0,
"name": "finance-sop",
"display_name": "Finance SOP",
"icon": "📄",
"description": "Handle finance.",
"tags": ["Finance"],
"status": "published",
"file_count": 2,
"latest_published_version_id": "version-1",
"latest_published_at": 123,
"updated_at": 124,
}
],
}
with (
app.test_request_context("/", method="GET"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", "agent-1")
assert payload["skill_ids"] == ["skill-1"]
assert payload["data"][0]["display_name"] == "Finance SOP"
assert payload["data"][0]["file_count"] == 2
service.list_agent_bindings.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
def test_patch_skill_file_operation_validates_payload_and_returns_detail(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillFilesApi()
method = unwrap(api.patch)
service = MagicMock()
service.apply_draft_file_operation.return_value = _skill_detail()
request_payload = {
"operation": "upsert_text",
"path": "references/policy.md",
"content": "Policy",
}
with (
app.test_request_context("/", method="PATCH"),
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=request_payload),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", current_user, "skill-1")
assert payload["id"] == "skill-1"
service.apply_draft_file_operation.assert_called_once()
call = service.apply_draft_file_operation.call_args.kwargs
assert call["tenant_id"] == "tenant-1"
assert call["user_id"] == "user-1"
assert call["skill_id"] == "skill-1"
assert call["payload"].operation == "upsert_text"
def test_patch_skill_file_operation_returns_error_details(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillFilesApi()
method = unwrap(api.patch)
service = MagicMock()
service.apply_draft_file_operation.side_effect = SkillManagementServiceError(
"missing_skill_name",
"SKILL.md frontmatter name is required",
details={"path": "SKILL.md", "field": "name", "line": 2},
)
with (
app.test_request_context("/", method="PATCH"),
patch.object(
type(console_ns),
"payload",
new_callable=PropertyMock,
return_value={"operation": "delete", "path": "SKILL.md"},
),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload, status = method(api, "tenant-1", current_user, "skill-1")
assert status == 400
assert payload == {
"code": "missing_skill_name",
"message": "SKILL.md frontmatter name is required",
"details": {"path": "SKILL.md", "field": "name", "line": 2},
}
def test_list_skill_tags_returns_filter_options(app: Flask) -> None:
api = WorkspaceSkillTagsApi()
method = unwrap(api.get)
service = MagicMock()
service.list_tags.return_value = {"data": [{"tag": "finance", "count": 2}]}
with (
app.test_request_context("/", method="GET"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1")
assert payload == {"data": [{"tag": "finance", "count": 2}]}
service.list_tags.assert_called_once_with(tenant_id="tenant-1")
def test_get_skill_version_returns_version_detail(app: Flask) -> None:
api = WorkspaceSkillVersionApi()
method = unwrap(api.get)
service = MagicMock()
service.get_version.return_value = {
"id": "version-1",
"skill_id": "skill-1",
"version_number": 1,
"version_name": "Initial finance policy",
"publish_note": "Initial finance policy",
"hash_code": "hash-code",
"archive_size": 123,
"published_by": "user-1",
"published_by_name": "Li Wei",
"is_latest": True,
"created_at": 1,
"files": [
{
"id": None,
"path": "SKILL.md",
"kind": "file",
"storage": "text",
"mime_type": "text/markdown",
"content": "# Version",
"tool_file_id": None,
"size": 9,
"hash": "file-hash",
}
],
}
with (
app.test_request_context("/", method="GET"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", "skill-1", "version-1")
assert payload["files"][0]["content"] == "# Version"
service.get_version.assert_called_once_with(
tenant_id="tenant-1",
skill_id="skill-1",
version_id="version-1",
)
def test_patch_skill_version_renames_version(app: Flask) -> None:
api = WorkspaceSkillVersionApi()
method = unwrap(api.patch)
service = MagicMock()
service.update_version.return_value = {
"id": "version-1",
"skill_id": "skill-1",
"version_number": 1,
"version_name": "Approval threshold",
"publish_note": "",
"hash_code": "hash-code",
"archive_size": 123,
"published_by": "user-1",
"published_by_name": "Li Wei",
"is_latest": True,
"created_at": 1,
}
with (
app.test_request_context("/", method="PATCH"),
patch.object(
type(console_ns),
"payload",
new_callable=PropertyMock,
return_value={"version_name": "Approval threshold"},
),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", "skill-1", "version-1")
assert payload["version_name"] == "Approval threshold"
service.update_version.assert_called_once()
assert service.update_version.call_args.kwargs["payload"].version_name == "Approval threshold"
def test_delete_skill_version_returns_new_latest(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillVersionApi()
method = unwrap(api.delete)
service = MagicMock()
service.delete_version.return_value = {
"id": "version-2",
"deleted": True,
"latest_published_version_id": "version-1",
}
with (
app.test_request_context("/", method="DELETE"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", current_user, "skill-1", "version-2")
assert payload == {"id": "version-2", "deleted": True, "latest_published_version_id": "version-1"}
service.delete_version.assert_called_once_with(
tenant_id="tenant-1",
user_id="user-1",
skill_id="skill-1",
version_id="version-2",
)
def test_skill_assistant_runs_agent_app_stream(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillAssistMessageApi()
method = unwrap(api.post)
service = MagicMock()
assistant_app = MagicMock()
assistant_app.id = "assistant-app-1"
service.get_or_create_assistant_app.return_value = (assistant_app, "<skill_draft>draft</skill_draft>")
app_model = MagicMock()
app_response = MagicMock()
compact_response = MagicMock()
with (
app.test_request_context("/", method="POST"),
patch.object(
type(console_ns),
"payload",
new_callable=PropertyMock,
return_value={
"attachments": [
{
"tool_file_id": "tool-file-1",
"name": "requirements.md",
"mime_type": "text/markdown",
"size": 128,
}
],
"message": "Create an approval checklist.",
},
),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
patch(
"controllers.console.workspace.skills.db.session",
return_value=MagicMock(get=MagicMock(return_value=app_model)),
),
patch("controllers.console.workspace.skills.AppGenerateService.generate", return_value=app_response),
patch("controllers.console.workspace.skills.helper.compact_generate_response", return_value=compact_response),
):
response = method(api, "tenant-1", current_user, "skill-1")
assert response is compact_response
service.get_or_create_assistant_app.assert_called_once_with(
tenant_id="tenant-1",
skill_id="skill-1",
user_id="user-1",
attachments=[
SkillAssistAttachmentPayload(
tool_file_id="tool-file-1",
name="requirements.md",
mime_type="text/markdown",
size=128,
)
],
message="Create an approval checklist.",
model_payload=None,
)
@@ -0,0 +1,64 @@
import inspect
from unittest.mock import call, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.inner_api.workspace.plugin_model_providers import (
EnterprisePluginModelProvidersCacheInvalidate,
InvalidatePluginModelProvidersCachePayload,
)
class TestInvalidatePluginModelProvidersCachePayload:
def test_valid_payload(self):
payload = InvalidatePluginModelProvidersCachePayload.model_validate(
{"tenant_ids": ["tenant-alpha", "tenant-beta"]}
)
assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"]
def test_missing_tenant_ids_defaults_to_empty(self):
payload = InvalidatePluginModelProvidersCachePayload.model_validate({})
assert payload.tenant_ids == []
def test_unknown_field_rejected(self):
with pytest.raises(ValidationError):
InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7})
class TestEnterprisePluginModelProvidersCacheInvalidate:
@pytest.fixture
def api_instance(self):
return EnterprisePluginModelProvidersCacheInvalidate()
def _post(self, api_instance, app: Flask, payload):
unwrapped_post = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns:
mock_ns.payload = payload
return unwrapped_post(api_instance)
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask):
result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]})
assert result == ({"result": "success"}, 200)
assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [
call("tenant-alpha"),
call("tenant-beta"),
]
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
result = self._post(api_instance, app, {"tenant_ids": []})
assert result == ({"result": "success"}, 200)
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
result = self._post(api_instance, app, None)
assert result == ({"result": "success"}, 200)
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
@@ -1,31 +1,17 @@
"""Unit tests for the Service API file-preview endpoint.
Ownership checks run against persisted message, file, app, and upload rows so the
tests exercise the same SQLAlchemy statements and tenant boundary as production.
Storage remains mocked because it is the external I/O boundary of the endpoint.
"""
Unit tests for Service API File Preview endpoint
"""
import logging
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
import uuid
from typing import Protocol, cast
from unittest.mock import Mock, patch
from uuid import uuid4
import pytest
from sqlalchemy import event
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from controllers.service_api.app.error import FileAccessDeniedError, FileNotFoundError
from controllers.service_api.app.file_preview import FilePreviewApi
from extensions.storage.storage_type import StorageType
from graphon.file import FileTransferMethod, FileType
from models.base import TypeBase
from models.enums import ConversationFromSource, CreatorUserRole
from models.model import App, AppMode, Message, MessageFile, UploadFile
from models.model import App, EndUser, Message, MessageFile, UploadFile
class _FilePreviewLogRecord(Protocol):
@@ -34,252 +20,367 @@ class _FilePreviewLogRecord(Protocol):
error: str
@dataclass(frozen=True)
class _Database:
"""Expose the real test session through the interface used by the controller."""
session: Session
@dataclass(frozen=True)
class _PreviewRecords:
app: App
message: Message
message_file: MessageFile
upload_file: UploadFile
@pytest.fixture
def database(sqlite_engine: Engine) -> Iterator[_Database]:
"""Create only the tables required by file ownership validation."""
models = (App, Message, MessageFile, UploadFile)
tables = [TypeBase.metadata.tables[model.__tablename__] for model in models]
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
with Session(sqlite_engine, expire_on_commit=False) as session:
yield _Database(session)
@pytest.fixture
def file_preview_api() -> FilePreviewApi:
"""Create the resource instance under test."""
return FilePreviewApi()
def _upload_file(*, tenant_id: str, file_id: str | None = None) -> UploadFile:
upload_file = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.LOCAL,
key="storage/key/test_file.jpg",
name="test_file.jpg",
size=1024,
extension="jpg",
mime_type="image/jpeg",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=str(uuid4()),
created_at=datetime(2026, 1, 1),
used=True,
)
if file_id is not None:
upload_file.id = file_id
return upload_file
def _persist_preview_records(
session: Session,
*,
app_id: str | None = None,
app_tenant_id: str | None = None,
upload_tenant_id: str | None = None,
) -> _PreviewRecords:
app_id = app_id or str(uuid4())
app_tenant_id = app_tenant_id or str(uuid4())
upload_file = _upload_file(tenant_id=upload_tenant_id or app_tenant_id)
app = App(
id=app_id,
tenant_id=app_tenant_id,
name="Preview app",
description="",
mode=AppMode.CHAT,
icon_type=None,
icon="",
icon_background=None,
enable_site=True,
enable_api=True,
)
message = Message(
id=str(uuid4()),
app_id=app_id,
conversation_id=str(uuid4()),
_inputs={},
query="preview",
message={},
message_unit_price=Decimal(0),
answer="answer",
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.API,
)
message_file = MessageFile(
message_id=message.id,
type=FileType.IMAGE,
transfer_method=FileTransferMethod.LOCAL_FILE,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=str(uuid4()),
upload_file_id=upload_file.id,
)
session.add_all([app, message, message_file, upload_file])
session.commit()
return _PreviewRecords(app=app, message=message, message_file=message_file, upload_file=upload_file)
class TestFilePreviewApi:
"""Exercise ownership validation and response construction."""
"""Test suite for FilePreviewApi"""
def test_validate_file_ownership_success(self, file_preview_api: FilePreviewApi, database: _Database):
records = _persist_preview_records(database.session)
@pytest.fixture
def file_preview_api(self):
"""Create FilePreviewApi instance for testing"""
return FilePreviewApi()
with patch("controllers.service_api.app.file_preview.db", database):
message_file, upload_file = file_preview_api._validate_file_ownership(
records.upload_file.id, records.app.id
)
@pytest.fixture
def mock_app(self):
"""Mock App model"""
app = Mock(spec=App)
app.id = str(uuid.uuid4())
app.tenant_id = str(uuid.uuid4())
return app
assert message_file.id == records.message_file.id
assert upload_file.id == records.upload_file.id
assert upload_file.tenant_id == records.app.tenant_id
@pytest.fixture
def mock_end_user(self):
"""Mock EndUser model"""
end_user = Mock(spec=EndUser)
end_user.id = str(uuid.uuid4())
return end_user
def test_validate_file_ownership_file_not_found(self, file_preview_api: FilePreviewApi, database: _Database):
with patch("controllers.service_api.app.file_preview.db", database):
with pytest.raises(FileNotFoundError, match="File not found in message context"):
file_preview_api._validate_file_ownership(str(uuid4()), str(uuid4()))
@pytest.fixture
def mock_upload_file(self):
"""Mock UploadFile model"""
upload_file = Mock(spec=UploadFile)
upload_file.id = str(uuid.uuid4())
upload_file.name = "test_file.jpg"
upload_file.extension = "jpg"
upload_file.mime_type = "image/jpeg"
upload_file.size = 1024
upload_file.key = "storage/key/test_file.jpg"
upload_file.tenant_id = str(uuid.uuid4())
return upload_file
def test_validate_file_ownership_access_denied(self, file_preview_api: FilePreviewApi, database: _Database):
records = _persist_preview_records(database.session)
@pytest.fixture
def mock_message_file(self):
"""Mock MessageFile model"""
message_file = Mock(spec=MessageFile)
message_file.id = str(uuid.uuid4())
message_file.upload_file_id = str(uuid.uuid4())
message_file.message_id = str(uuid.uuid4())
return message_file
with patch("controllers.service_api.app.file_preview.db", database):
with pytest.raises(FileAccessDeniedError, match="not owned by requesting app"):
file_preview_api._validate_file_ownership(records.upload_file.id, str(uuid4()))
@pytest.fixture
def mock_message(self):
"""Mock Message model"""
message = Mock(spec=Message)
message.id = str(uuid.uuid4())
message.app_id = str(uuid.uuid4())
return message
def test_validate_file_ownership_upload_file_not_found(self, file_preview_api: FilePreviewApi, database: _Database):
records = _persist_preview_records(database.session)
database.session.delete(records.upload_file)
database.session.commit()
def test_validate_file_ownership_success(
self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message
):
"""Test successful file ownership validation"""
file_id = str(uuid.uuid4())
app_id = mock_app.id
with patch("controllers.service_api.app.file_preview.db", database):
with pytest.raises(FileNotFoundError, match="Upload file record not found"):
file_preview_api._validate_file_ownership(records.upload_file.id, records.app.id)
# Set up the mocks
mock_upload_file.tenant_id = mock_app.tenant_id
mock_message.app_id = app_id
mock_message_file.upload_file_id = file_id
mock_message_file.message_id = mock_message.id
def test_validate_file_ownership_tenant_mismatch(self, file_preview_api: FilePreviewApi, database: _Database):
records = _persist_preview_records(database.session, upload_tenant_id=str(uuid4()))
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock scalar() for MessageFile and Message queries
mock_db.session.scalar.side_effect = [
mock_message_file, # MessageFile query
mock_message, # Message query
]
# Mock get() for UploadFile and App PK lookups
mock_db.session.get.side_effect = [
mock_upload_file, # UploadFile query
mock_app, # App query for tenant validation
]
with patch("controllers.service_api.app.file_preview.db", database):
with pytest.raises(FileAccessDeniedError, match="tenant mismatch"):
file_preview_api._validate_file_ownership(records.upload_file.id, records.app.id)
# Execute the method
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
# Assertions
assert result_message_file == mock_message_file
assert result_upload_file == mock_upload_file
def test_validate_file_ownership_file_not_found(self, file_preview_api: FilePreviewApi):
"""Test file ownership validation when MessageFile not found"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock MessageFile not found via scalar()
mock_db.session.scalar.return_value = None
# Execute and assert exception
with pytest.raises(FileNotFoundError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
assert "File not found in message context" in str(exc_info.value)
def test_validate_file_ownership_access_denied(self, file_preview_api: FilePreviewApi, mock_message_file):
"""Test file ownership validation when Message not owned by app"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock MessageFile found but Message not owned by app via scalar()
mock_db.session.scalar.side_effect = [
mock_message_file, # MessageFile query - found
None, # Message query - not found (access denied)
]
# Execute and assert exception
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
assert "not owned by requesting app" in str(exc_info.value)
def test_validate_file_ownership_upload_file_not_found(
self, file_preview_api: FilePreviewApi, mock_message_file, mock_message
):
"""Test file ownership validation when UploadFile not found"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock scalar() for MessageFile and Message
mock_db.session.scalar.side_effect = [
mock_message_file, # MessageFile query - found
mock_message, # Message query - found
]
# Mock get() for UploadFile - not found
mock_db.session.get.return_value = None
# Execute and assert exception
with pytest.raises(FileNotFoundError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
assert "Upload file record not found" in str(exc_info.value)
def test_validate_file_ownership_tenant_mismatch(
self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message
):
"""Test file ownership validation with tenant mismatch"""
file_id = str(uuid.uuid4())
app_id = mock_app.id
# Set up tenant mismatch
mock_upload_file.tenant_id = "different_tenant_id"
mock_app.tenant_id = "app_tenant_id"
mock_message.app_id = app_id
mock_message_file.upload_file_id = file_id
mock_message_file.message_id = mock_message.id
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock scalar() for MessageFile and Message queries
mock_db.session.scalar.side_effect = [
mock_message_file, # MessageFile query
mock_message, # Message query
]
# Mock get() for UploadFile and App PK lookups
mock_db.session.get.side_effect = [
mock_upload_file, # UploadFile query
mock_app, # App query for tenant validation
]
# Execute and assert exception
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
assert "tenant mismatch" in str(exc_info.value)
def test_validate_file_ownership_invalid_input(self, file_preview_api: FilePreviewApi):
with pytest.raises(FileAccessDeniedError, match="Invalid file or app identifier"):
"""Test file ownership validation with invalid input"""
# Test with empty file_id
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership("", "app_id")
assert "Invalid file or app identifier" in str(exc_info.value)
with pytest.raises(FileAccessDeniedError, match="Invalid file or app identifier"):
# Test with empty app_id
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership("file_id", "")
assert "Invalid file or app identifier" in str(exc_info.value)
@pytest.mark.parametrize(
("as_attachment", "mime_type", "name", "extension", "size"),
[
(False, "image/jpeg", "test_file.jpg", "jpg", 1024),
(True, "image/jpeg", "test_file.jpg", "jpg", 1024),
(False, "text/html", "unsafe.html", "html", 1024),
(False, "video/mp4", "test_file.mp4", "mp4", 1024),
(False, "image/jpeg", "test_file.jpg", "jpg", 0),
],
)
def test_build_file_response(
self,
file_preview_api: FilePreviewApi,
as_attachment: bool,
mime_type: str,
name: str,
extension: str,
size: int,
):
upload_file = _upload_file(tenant_id=str(uuid4()))
upload_file.mime_type = mime_type
upload_file.name = name
upload_file.extension = extension
upload_file.size = size
def test_build_file_response_basic(self, file_preview_api: FilePreviewApi, mock_upload_file):
"""Test basic file response building"""
mock_generator = Mock()
response = file_preview_api._build_file_response(Mock(), upload_file, as_attachment)
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
# Check response properties
assert response.mimetype == mock_upload_file.mime_type
assert response.direct_passthrough is True
assert response.headers["Content-Length"] == str(mock_upload_file.size)
assert "Cache-Control" in response.headers
assert ("Content-Length" in response.headers) is bool(size)
if as_attachment or mime_type == "text/html":
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
else:
assert response.mimetype == mime_type
if mime_type == "text/html":
assert response.headers["X-Content-Type-Options"] == "nosniff"
if mime_type.startswith("video/"):
assert response.headers["Accept-Ranges"] == "bytes"
def test_build_file_response_as_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file):
"""Test file response building with attachment flag"""
mock_generator = Mock()
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, True)
# Check attachment-specific headers
assert "attachment" in response.headers["Content-Disposition"]
assert mock_upload_file.name in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
def test_build_file_response_html_forces_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file):
"""Test HTML files are forced to download"""
mock_generator = Mock()
mock_upload_file.mime_type = "text/html"
mock_upload_file.name = "unsafe.html"
mock_upload_file.extension = "html"
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
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_build_file_response_audio_video(self, file_preview_api: FilePreviewApi, mock_upload_file):
"""Test file response building for audio/video files"""
mock_generator = Mock()
mock_upload_file.mime_type = "video/mp4"
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
# Check Range support for media files
assert response.headers["Accept-Ranges"] == "bytes"
def test_build_file_response_no_size(self, file_preview_api: FilePreviewApi, mock_upload_file):
"""Test file response building when size is unknown"""
mock_generator = Mock()
mock_upload_file.size = 0 # Unknown size
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
# Content-Length should not be set when size is unknown
assert "Content-Length" not in response.headers
@patch("controllers.service_api.app.file_preview.storage")
def test_components_use_validated_file(
self, mock_storage: Mock, file_preview_api: FilePreviewApi, database: _Database
def test_get_method_integration(
self,
mock_storage,
file_preview_api: FilePreviewApi,
mock_app,
mock_end_user,
mock_upload_file,
mock_message_file,
mock_message,
):
records = _persist_preview_records(database.session)
generator = Mock()
"""Test the full GET method integration (without decorator)"""
file_id = str(uuid.uuid4())
app_id = mock_app.id
with patch("controllers.service_api.app.file_preview.db", database):
message_file, upload_file = file_preview_api._validate_file_ownership(
records.upload_file.id, records.app.id
)
response = file_preview_api._build_file_response(generator, upload_file, False)
# Set up mocks
mock_upload_file.tenant_id = mock_app.tenant_id
mock_message.app_id = app_id
mock_message_file.upload_file_id = file_id
mock_message_file.message_id = mock_message.id
assert message_file.id == records.message_file.id
assert response.mimetype == "image/jpeg"
mock_storage.load.assert_not_called()
mock_generator = Mock()
mock_storage.load.return_value = mock_generator
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock scalar() for MessageFile and Message queries
mock_db.session.scalar.side_effect = [
mock_message_file, # MessageFile query
mock_message, # Message query
]
# Mock get() for UploadFile and App PK lookups
mock_db.session.get.side_effect = [
mock_upload_file, # UploadFile query
mock_app, # App query for tenant validation
]
# Test the core logic directly without Flask decorators
# Validate file ownership
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
assert result_message_file == mock_message_file
assert result_upload_file == mock_upload_file
# Test file response building
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
assert response is not None
# Verify storage was called correctly
mock_storage.load.assert_not_called() # Since we're testing components separately
@patch("controllers.service_api.app.file_preview.storage")
def test_storage_error_remains_external(
self, mock_storage: Mock, file_preview_api: FilePreviewApi, database: _Database
def test_storage_error_handling(
self,
mock_storage,
file_preview_api: FilePreviewApi,
mock_app,
mock_upload_file,
mock_message_file,
mock_message,
):
records = _persist_preview_records(database.session)
mock_storage.load.side_effect = OSError("Storage error")
"""Test storage error handling in the core logic"""
file_id = str(uuid.uuid4())
app_id = mock_app.id
with patch("controllers.service_api.app.file_preview.db", database):
_, upload_file = file_preview_api._validate_file_ownership(records.upload_file.id, records.app.id)
# Set up mocks
mock_upload_file.tenant_id = mock_app.tenant_id
mock_message.app_id = app_id
mock_message_file.upload_file_id = file_id
mock_message_file.message_id = mock_message.id
with pytest.raises(OSError, match="Storage error"):
mock_storage.load(upload_file.key, stream=True)
# Mock storage error
mock_storage.load.side_effect = Exception("Storage error")
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock scalar() for MessageFile and Message queries
mock_db.session.scalar.side_effect = [
mock_message_file, # MessageFile query
mock_message, # Message query
]
# Mock get() for UploadFile and App PK lookups
mock_db.session.get.side_effect = [
mock_upload_file, # UploadFile query
mock_app, # App query for tenant validation
]
# First validate file ownership works
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
assert result_message_file == mock_message_file
assert result_upload_file == mock_upload_file
# Test storage error handling
with pytest.raises(Exception) as exc_info:
mock_storage.load(mock_upload_file.key, stream=True)
assert "Storage error" in str(exc_info.value)
def test_validate_file_ownership_unexpected_error_logging(
self,
file_preview_api: FilePreviewApi,
database: _Database,
sqlite_engine: Engine,
caplog: pytest.LogCaptureFixture,
self, file_preview_api: FilePreviewApi, caplog: pytest.LogCaptureFixture
):
file_id = str(uuid4())
app_id = str(uuid4())
"""Test that unexpected errors are logged properly"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
def fail_statement(*_args: object) -> None:
raise RuntimeError("Unexpected database error")
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock database scalar to raise unexpected exception
mock_db.session.scalar.side_effect = Exception("Unexpected database error")
event.listen(sqlite_engine, "before_cursor_execute", fail_statement)
try:
with patch("controllers.service_api.app.file_preview.db", database):
with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"):
with pytest.raises(FileAccessDeniedError, match="File access validation failed"):
file_preview_api._validate_file_ownership(file_id, app_id)
finally:
event.remove(sqlite_engine, "before_cursor_execute", fail_statement)
# Execute and assert exception
with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"):
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
assert len(caplog.records) == 1
log_record = caplog.records[0]
assert log_record.getMessage() == "Unexpected error during file ownership validation"
record = cast(_FilePreviewLogRecord, log_record)
assert record.file_id == file_id
assert record.app_id == app_id
assert record.error == "Unexpected database error"
# Verify error message
assert "File access validation failed" in str(exc_info.value)
# Verify logging was called with the structured context fields. The ``extra`` keys
# are attached to the LogRecord as attributes, so they are not in ``caplog.text``.
assert len(caplog.records) == 1
log_record = caplog.records[0]
assert log_record.getMessage() == "Unexpected error during file ownership validation"
record = cast(_FilePreviewLogRecord, log_record)
assert record.file_id == file_id
assert record.app_id == app_id
assert record.error == "Unexpected database error"
@@ -15,15 +15,12 @@ Focus on:
"""
import uuid
from collections.abc import Iterator
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from flask import Flask
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from controllers.service_api.app.error import NotChatAppError
@@ -47,14 +44,6 @@ from services.errors.message import (
from services.message_service import MessageService
@pytest.fixture
def orm_session(sqlite_engine: Engine) -> Iterator[Session]:
"""Provide a real caller-owned session for MessageService interface tests."""
with Session(sqlite_engine, expire_on_commit=False) as session:
yield session
class TestMessageListQuery:
"""Test suite for MessageListQuery Pydantic model."""
@@ -264,7 +253,7 @@ class TestMessageService:
assert callable(MessageService.get_suggested_questions_after_answer)
@patch.object(MessageService, "pagination_by_first_id")
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination, orm_session: Session):
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination):
"""Test pagination_by_first_id returns expected format."""
mock_result = Mock()
mock_result.data = []
@@ -278,7 +267,7 @@ class TestMessageService:
conversation_id=str(uuid.uuid4()),
first_id=None,
limit=20,
session=orm_session,
session=Mock(),
)
assert hasattr(result, "data")
@@ -286,7 +275,7 @@ class TestMessageService:
assert hasattr(result, "has_more")
@patch.object(MessageService, "pagination_by_first_id")
def test_pagination_raises_conversation_not_exists_error(self, mock_pagination, orm_session: Session):
def test_pagination_raises_conversation_not_exists_error(self, mock_pagination):
"""Test pagination raises ConversationNotExistsError."""
import services.errors.conversation
@@ -299,11 +288,11 @@ class TestMessageService:
conversation_id="invalid_id",
first_id=None,
limit=20,
session=orm_session,
session=Mock(),
)
@patch.object(MessageService, "pagination_by_first_id")
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination, orm_session: Session):
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination):
"""Test pagination raises FirstMessageNotExistsError."""
mock_pagination.side_effect = FirstMessageNotExistsError()
@@ -314,11 +303,11 @@ class TestMessageService:
conversation_id=str(uuid.uuid4()),
first_id="invalid_first_id",
limit=20,
session=orm_session,
session=Mock(),
)
@patch.object(MessageService, "create_feedback")
def test_create_feedback_with_rating_and_content(self, mock_create_feedback, orm_session: Session):
def test_create_feedback_with_rating_and_content(self, mock_create_feedback):
"""Test create_feedback with rating and content."""
mock_create_feedback.return_value = None
@@ -328,13 +317,13 @@ class TestMessageService:
user=Mock(spec=EndUser),
rating=FeedbackRating.LIKE,
content="Great response!",
session=orm_session,
session=Mock(),
)
mock_create_feedback.assert_called_once()
@patch.object(MessageService, "create_feedback")
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback, orm_session: Session):
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback):
"""Test create_feedback raises MessageNotExistsError."""
mock_create_feedback.side_effect = MessageNotExistsError()
@@ -345,11 +334,11 @@ class TestMessageService:
user=Mock(spec=EndUser),
rating=FeedbackRating.LIKE,
content=None,
session=orm_session,
session=Mock(),
)
@patch.object(MessageService, "get_all_messages_feedbacks")
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks, orm_session: Session):
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks):
"""Test get_all_messages_feedbacks returns list of feedbacks."""
mock_feedbacks = [
{"message_id": str(uuid.uuid4()), "rating": "like"},
@@ -357,15 +346,13 @@ class TestMessageService:
]
mock_get_feedbacks.return_value = mock_feedbacks
result = MessageService.get_all_messages_feedbacks(
app_model=Mock(spec=App), page=1, limit=20, session=orm_session
)
result = MessageService.get_all_messages_feedbacks(app_model=Mock(spec=App), page=1, limit=20, session=Mock())
assert len(result) == 2
assert result[0]["rating"] == "like"
@patch.object(MessageService, "get_suggested_questions_after_answer")
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions, orm_session: Session):
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions):
"""Test get_suggested_questions_after_answer returns list of questions."""
mock_questions = ["What about this aspect?", "Can you elaborate on that?", "How does this relate to...?"]
mock_get_questions.return_value = mock_questions
@@ -375,14 +362,14 @@ class TestMessageService:
user=Mock(spec=EndUser),
message_id=str(uuid.uuid4()),
invoke_from=Mock(),
session=orm_session,
session=Mock(),
)
assert len(result) == 3
assert isinstance(result[0], str)
@patch.object(MessageService, "get_suggested_questions_after_answer")
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions, orm_session: Session):
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions):
"""Test get_suggested_questions_after_answer raises SuggestedQuestionsAfterAnswerDisabledError."""
mock_get_questions.side_effect = SuggestedQuestionsAfterAnswerDisabledError()
@@ -392,11 +379,11 @@ class TestMessageService:
user=Mock(spec=EndUser),
message_id=str(uuid.uuid4()),
invoke_from=Mock(),
session=orm_session,
session=Mock(),
)
@patch.object(MessageService, "get_suggested_questions_after_answer")
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions, orm_session: Session):
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions):
"""Test get_suggested_questions_after_answer raises MessageNotExistsError."""
mock_get_questions.side_effect = MessageNotExistsError()
@@ -406,7 +393,7 @@ class TestMessageService:
user=Mock(spec=EndUser),
message_id="invalid_message_id",
invoke_from=Mock(),
session=orm_session,
session=Mock(),
)
@@ -24,7 +24,6 @@ from unittest.mock import Mock, patch
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden, NotFound
@@ -39,7 +38,6 @@ from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import (
)
from core.app.entities.app_invoke_entities import InvokeFrom
from models.account import Account
from models.dataset import Dataset
from services.errors.file import FileTooLargeError, UnsupportedFileTypeError
from services.rag_pipeline.entity.pipeline_service_api_entities import (
DatasourceNodeRunApiEntity,
@@ -48,20 +46,6 @@ from services.rag_pipeline.entity.pipeline_service_api_entities import (
from services.rag_pipeline.rag_pipeline import RagPipelineService
def _persist_dataset(session: Session, *, tenant_id: str, dataset_id: str) -> Dataset:
dataset = Dataset(
id=dataset_id,
tenant_id=tenant_id,
name="Pipeline dataset",
created_by="account-1",
data_source_type=None,
indexing_technique=None,
)
session.add(dataset)
session.commit()
return dataset
class TestDatasourceNodeRunPayload:
"""Test suite for DatasourceNodeRunPayload Pydantic model."""
@@ -566,15 +550,13 @@ class TestPipelineRunApiPost:
)
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService")
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns")
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
def test_post_success_streaming(
self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app, sqlite_session: Session
):
def test_post_success_streaming(self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app):
"""Test successful pipeline run with streaming response."""
tenant_id = str(uuid.uuid4())
dataset_id = str(uuid.uuid4())
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
session = Mock()
session.scalar.return_value = Mock()
mock_ns.payload = {
"inputs": {"key": "val"},
@@ -595,33 +577,33 @@ class TestPipelineRunApiPost:
with app.test_request_context("/datasets/test/pipeline/run", method="POST"):
api = PipelineRunApi()
response = api.post.__wrapped__(api, sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
response = api.post.__wrapped__(api, session, tenant_id=tenant_id, dataset_id=dataset_id)
assert response == {"result": "ok"}
mock_svc_cls.assert_called_once_with(sqlite_session)
mock_svc_cls.assert_called_once_with(session)
mock_gen_svc.generate.assert_called_once()
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
def test_post_not_found(self, app: Flask, sqlite_session: Session):
def test_post_not_found(self, app: Flask):
"""Test NotFound when dataset check fails."""
session = Mock()
session.scalar.return_value = None
with app.test_request_context("/datasets/test/pipeline/run", method="POST"):
api = PipelineRunApi()
with pytest.raises(NotFound):
api.post.__wrapped__(
api,
sqlite_session,
session,
tenant_id=str(uuid.uuid4()),
dataset_id=str(uuid.uuid4()),
)
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user", new="not_account")
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns")
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask, sqlite_session: Session):
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask):
"""Test Forbidden when current_user is not an Account."""
tenant_id = str(uuid.uuid4())
dataset_id = str(uuid.uuid4())
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
session = Mock()
session.scalar.return_value = Mock()
mock_ns.payload = {
"inputs": {},
"datasource_type": "online_document",
@@ -636,9 +618,9 @@ class TestPipelineRunApiPost:
with pytest.raises(Forbidden):
api.post.__wrapped__(
api,
sqlite_session,
tenant_id=tenant_id,
dataset_id=dataset_id,
session,
tenant_id=str(uuid.uuid4()),
dataset_id=str(uuid.uuid4()),
)
@@ -3,13 +3,10 @@ Unit tests for Service API wraps (authentication decorators)
"""
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import pytest
from flask import Flask
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
from controllers.service_api.wraps import (
@@ -24,11 +21,12 @@ from controllers.service_api.wraps import (
validate_dataset_token,
)
from enums.cloud_plan import CloudPlan
from models import Account, Tenant, TenantAccountJoin
from models.account import TenantAccountRole
from models.dataset import Dataset, RateLimitLog
from models.enums import ApiTokenType
from models.model import ApiToken, App, AppMode, IconType
from models.account import TenantStatus
from models.model import ApiToken
from tests.unit_tests.conftest import (
setup_mock_dataset_owner_execute_result,
setup_mock_tenant_owner_execute_result,
)
def _configure_current_app_mock(mock_current_app):
@@ -36,51 +34,6 @@ def _configure_current_app_mock(mock_current_app):
mock_current_app._get_current_object = Mock(return_value=Mock())
def _session_proxy(session: Session) -> MagicMock:
"""Emulate Flask-SQLAlchemy's callable scoped-session proxy around a test session."""
proxy = MagicMock(wraps=session)
proxy.return_value = session
return proxy
def _api_token(*, tenant_id: str, app_id: str | None = None, token_type: ApiTokenType) -> ApiToken:
return ApiToken(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
app_id=app_id,
type=token_type,
token="test_token",
)
def _persist_workspace(session: Session) -> tuple[Tenant, Account, TenantAccountJoin]:
tenant = Tenant(name="Workspace")
account = Account(name="Owner", email=f"owner-{uuid.uuid4()}@example.com")
membership = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
)
session.add_all([tenant, account, membership])
session.commit()
return tenant, account, membership
def _app_model(*, tenant_id: str, enable_api: bool = True) -> App:
return App(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
name="Service API App",
mode=AppMode.CHAT,
icon_type=IconType.EMOJI,
icon="chat",
icon_background="#FFFFFF",
enable_site=False,
enable_api=enable_api,
)
class TestValidateAndGetApiToken:
"""Test suite for validate_and_get_api_token function"""
@@ -117,24 +70,21 @@ class TestValidateAndGetApiToken:
def test_valid_token_returns_api_token(self, mock_fetch_token, mock_cache_cls, mock_record_usage, app: Flask):
"""Test that valid token returns the ApiToken object."""
# Arrange
api_token = _api_token(
tenant_id=str(uuid.uuid4()),
app_id=str(uuid.uuid4()),
token_type=ApiTokenType.APP,
)
api_token.token = "valid_token_123"
mock_api_token = Mock(spec=ApiToken)
mock_api_token.token = "valid_token_123"
mock_api_token.type = "app"
mock_cache_instance = Mock()
mock_cache_instance.get.return_value = None # Cache miss
mock_cache_cls.get = mock_cache_instance.get
mock_fetch_token.return_value = api_token
mock_fetch_token.return_value = mock_api_token
# Act
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer valid_token_123"}):
result = validate_and_get_api_token("app")
# Assert
assert result == api_token
assert result == mock_api_token
@patch("controllers.service_api.wraps.record_token_usage")
@patch("controllers.service_api.wraps.ApiTokenCache")
@@ -167,124 +117,116 @@ class TestValidateAppToken:
return app
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@patch("controllers.service_api.wraps.current_app")
@pytest.mark.parametrize(
"sqlite_session",
[(App, ApiToken, Tenant, Account, TenantAccountJoin)],
indirect=True,
)
def test_valid_app_token_allows_access(
self,
mock_current_app,
mock_validate_token,
mock_user_logged_in,
app: Flask,
sqlite_session: Session,
self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app
):
"""Test that valid app token allows access to decorated view."""
# Arrange
_configure_current_app_mock(mock_current_app)
tenant, account, _ = _persist_workspace(sqlite_session)
app_model = _app_model(tenant_id=tenant.id)
api_token = _api_token(tenant_id=tenant.id, app_id=app_model.id, token_type=ApiTokenType.APP)
sqlite_session.add_all([app_model, api_token])
sqlite_session.commit()
mock_validate_token.return_value = api_token
mock_api_token = Mock()
mock_api_token.app_id = str(uuid.uuid4())
mock_api_token.tenant_id = str(uuid.uuid4())
mock_validate_token.return_value = mock_api_token
mock_app = Mock()
mock_app.id = mock_api_token.app_id
mock_app.status = "normal"
mock_app.enable_api = True
mock_app.tenant_id = mock_api_token.tenant_id
mock_tenant = Mock()
mock_tenant.status = TenantStatus.NORMAL
mock_tenant.id = mock_api_token.tenant_id
mock_account = Mock()
mock_account.id = str(uuid.uuid4())
# Use side_effect to return app first, then tenant via session.get()
mock_db.session.get.side_effect = [mock_app, mock_tenant]
# Mock the tenant owner execute result (execute(select(...)).one_or_none())
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account)
@validate_app_token
def protected_view(app_model):
return {"success": True, "app_id": app_model.id}
# Act
with (
app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}),
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
):
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
result = protected_view()
# Assert
assert result["success"] is True
assert result["app_id"] == app_model.id
assert account.current_tenant_id == tenant.id
assert result["app_id"] == mock_app.id
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_app_not_found_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
def test_app_not_found_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
"""Test that Forbidden is raised when app no longer exists."""
# Arrange
api_token = _api_token(
tenant_id=str(uuid.uuid4()),
app_id=str(uuid.uuid4()),
token_type=ApiTokenType.APP,
)
mock_validate_token.return_value = api_token
mock_api_token = Mock()
mock_api_token.app_id = str(uuid.uuid4())
mock_validate_token.return_value = mock_api_token
mock_db.session.get.return_value = None
@validate_app_token
def protected_view(**kwargs):
return {"success": True}
# Act & Assert
with (
app.test_request_context("/", method="GET"),
patch("controllers.service_api.wraps.db.session", sqlite_session),
):
with app.test_request_context("/", method="GET"):
with pytest.raises(Forbidden) as exc_info:
protected_view()
assert "no longer exists" in str(exc_info.value)
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
"""Test that Forbidden is raised when app status is abnormal."""
# Arrange
app_model = _app_model(tenant_id=str(uuid.uuid4()))
sqlite_session.add(app_model)
sqlite_session.commit()
app_model.status = "abnormal"
mock_validate_token.return_value = _api_token(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
token_type=ApiTokenType.APP,
)
mock_api_token = Mock()
mock_api_token.app_id = str(uuid.uuid4())
mock_validate_token.return_value = mock_api_token
mock_app = Mock()
mock_app.status = "abnormal"
mock_db.session.get.return_value = mock_app
@validate_app_token
def protected_view(**kwargs):
return {"success": True}
# Act & Assert
with (
app.test_request_context("/", method="GET"),
patch("controllers.service_api.wraps.db.session", sqlite_session),
):
with app.test_request_context("/", method="GET"):
with pytest.raises(Forbidden) as exc_info:
protected_view()
assert "status is abnormal" in str(exc_info.value)
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
"""Test that Forbidden is raised when app API is disabled."""
# Arrange
app_model = _app_model(tenant_id=str(uuid.uuid4()), enable_api=False)
sqlite_session.add(app_model)
sqlite_session.commit()
mock_validate_token.return_value = _api_token(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
token_type=ApiTokenType.APP,
)
mock_api_token = Mock()
mock_api_token.app_id = str(uuid.uuid4())
mock_validate_token.return_value = mock_api_token
mock_app = Mock()
mock_app.status = "normal"
mock_app.enable_api = False
mock_db.session.get.return_value = mock_app
@validate_app_token
def protected_view(**kwargs):
return {"success": True}
# Act & Assert
with (
app.test_request_context("/", method="GET"),
patch("controllers.service_api.wraps.db.session", sqlite_session),
):
with app.test_request_context("/", method="GET"):
with pytest.raises(Forbidden) as exc_info:
protected_view()
assert "API service has been disabled" in str(exc_info.value)
@@ -526,35 +468,26 @@ class TestCloudEditionBillingRateLimitCheck:
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit")
@pytest.mark.parametrize("sqlite_session", [(RateLimitLog,)], indirect=True)
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.wraps.sessionmaker")
def test_rejects_over_rate_limit(
self,
mock_get_rate_limit,
mock_validate_token,
app: Flask,
sqlite_session: Session,
self, mock_sessionmaker, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask
):
"""Test that Forbidden is raised when over rate limit."""
# Arrange
tenant_id = str(uuid.uuid4())
mock_validate_token.return_value = _api_token(
tenant_id=tenant_id,
token_type=ApiTokenType.DATASET,
)
mock_validate_token.return_value = Mock(tenant_id="tenant123")
mock_rate_limit = Mock()
mock_rate_limit.enabled = True
mock_rate_limit.limit = 10
mock_rate_limit.subscription_plan = "pro"
mock_get_rate_limit.return_value = mock_rate_limit
rate_limit_log_session = MagicMock()
session_factory = MagicMock()
session_factory.begin.return_value.__enter__.return_value = rate_limit_log_session
mock_sessionmaker.return_value = session_factory
with (
patch("controllers.service_api.wraps.redis_client") as mock_redis,
patch(
"controllers.service_api.wraps.db",
SimpleNamespace(engine=sqlite_session.get_bind()),
),
):
with patch("controllers.service_api.wraps.redis_client") as mock_redis:
mock_redis.zcard.return_value = 15 # Over limit
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
@@ -566,12 +499,9 @@ class TestCloudEditionBillingRateLimitCheck:
with pytest.raises(Forbidden) as exc_info:
knowledge_request()
assert "rate limit" in str(exc_info.value)
persisted_logs = sqlite_session.scalars(select(RateLimitLog)).all()
assert len(persisted_logs) == 1
assert persisted_logs[0].tenant_id == tenant_id
assert persisted_logs[0].subscription_plan == "pro"
assert persisted_logs[0].operation == "knowledge"
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
rate_limit_log_session.add.assert_called_once()
mock_db.session.commit.assert_not_called()
class TestValidateDatasetToken:
@@ -585,62 +515,65 @@ class TestValidateDatasetToken:
return app
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@patch("controllers.service_api.wraps.current_app")
@pytest.mark.parametrize(
"sqlite_session",
[(Tenant, Account, TenantAccountJoin)],
indirect=True,
)
def test_valid_dataset_token(
self,
mock_current_app,
mock_validate_token,
mock_user_logged_in,
app: Flask,
sqlite_session: Session,
):
def test_valid_dataset_token(self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app: Flask):
"""Test that valid dataset token allows access."""
# Arrange
_configure_current_app_mock(mock_current_app)
tenant, account, _ = _persist_workspace(sqlite_session)
api_token = _api_token(tenant_id=tenant.id, token_type=ApiTokenType.DATASET)
mock_validate_token.return_value = api_token
tenant_id = str(uuid.uuid4())
mock_api_token = Mock()
mock_api_token.tenant_id = tenant_id
mock_validate_token.return_value = mock_api_token
mock_tenant = Mock()
mock_tenant.id = tenant_id
mock_tenant.status = TenantStatus.NORMAL
mock_ta = Mock()
mock_ta.account_id = str(uuid.uuid4())
mock_account = Mock()
mock_account.id = mock_ta.account_id
mock_account.current_tenant = mock_tenant
# Mock the tenant account join query (execute(select(...)).one_or_none())
setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta)
# Mock the account lookup via session.get()
mock_db.session.get.return_value = mock_account
@validate_dataset_token
def protected_view(tenant_id):
return {"success": True, "tenant_id": tenant_id}
# Act
with (
app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}),
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
):
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
result = protected_view()
# Assert
assert result["success"] is True
assert result["tenant_id"] == tenant.id
assert account.current_tenant_id == tenant.id
assert result["tenant_id"] == tenant_id
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
def test_dataset_not_found_raises_not_found(self, mock_validate_token, app: Flask, sqlite_session: Session):
def test_dataset_not_found_raises_not_found(self, mock_validate_token, mock_db, app: Flask):
"""Test that NotFound is raised when dataset doesn't exist."""
# Arrange
api_token = _api_token(tenant_id=str(uuid.uuid4()), token_type=ApiTokenType.DATASET)
mock_validate_token.return_value = api_token
mock_api_token = Mock()
mock_api_token.tenant_id = str(uuid.uuid4())
mock_validate_token.return_value = mock_api_token
mock_db.session.scalar.return_value = None
@validate_dataset_token
def protected_view(dataset_id=None, **kwargs):
return {"success": True}
# Act & Assert
with (
app.test_request_context("/", method="GET"),
patch("controllers.service_api.wraps.db.session", sqlite_session),
):
with app.test_request_context("/", method="GET"):
with pytest.raises(NotFound) as exc_info:
protected_view(dataset_id=str(uuid.uuid4()))
assert "Dataset not found" in str(exc_info.value)
@@ -634,6 +634,11 @@ def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path)
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
operation = payload["paths"]["/workspaces/current/plugin/{category}/list"]["get"]
parameters = {parameter["name"]: parameter for parameter in operation["parameters"]}
assert parameters["query"]["in"] == "query"
assert parameters["tags"]["in"] == "query"
assert parameters["tags"]["schema"]["type"] == "array"
assert parameters["language"]["in"] == "query"
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
"#/components/schemas/"
)
@@ -661,3 +666,112 @@ def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path)
builtin_tool_schema = schemas["PluginCategoryBuiltinToolProviderResponse"]
for field in ("plugin_unique_identifier", "team_credentials", "type", "tools"):
assert field in builtin_tool_schema["properties"]
def test_console_installed_plugin_ids_exported_schema_is_lightweight(tmp_path):
from dev.generate_swagger_specs import generate_specs
written_paths = generate_specs(tmp_path)
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
operation = payload["paths"]["/workspaces/current/plugin/installed-ids"]["get"]
parameters = {parameter["name"]: parameter for parameter in operation["parameters"]}
assert parameters["category"]["in"] == "query"
assert parameters["category"]["required"] is True
assert parameters["category"]["schema"]["enum"] == [
"agent-strategy",
"datasource",
"extension",
"model",
"tool",
"trigger",
]
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
"#/components/schemas/"
)
response_schema = payload["components"]["schemas"][response_ref]
assert response_schema["required"] == ["plugin_ids"]
assert response_schema["properties"] == {
"plugin_ids": {
"items": {"type": "string"},
"title": "Plugin Ids",
"type": "array",
}
}
def test_console_model_provider_summary_exported_schema_is_lightweight(tmp_path):
from dev.generate_swagger_specs import generate_specs
written_paths = generate_specs(tmp_path)
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
operation = payload["paths"]["/workspaces/current/model-providers/summary"]["get"]
assert operation.get("parameters", []) == []
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
"#/components/schemas/"
)
response_schema = payload["components"]["schemas"][response_ref]
assert response_schema["required"] == ["data", "plugins"]
assert response_schema["properties"]["data"]["items"]["$ref"] == (
"#/components/schemas/ModelProviderSummaryResponse"
)
assert response_schema["properties"]["plugins"]["additionalProperties"]["$ref"] == (
"#/components/schemas/ModelProviderPluginSummaryResponse"
)
provider_properties = payload["components"]["schemas"]["ModelProviderSummaryResponse"]["properties"]
assert set(provider_properties) == {
"configurate_methods",
"custom_configuration",
"description",
"icon_small",
"icon_small_dark",
"is_configured",
"label",
"plugin_id",
"preferred_provider_type",
"provider",
"supported_model_types",
"system_configuration",
}
assert "provider_credential_schema" not in provider_properties
assert "model_credential_schema" not in provider_properties
custom_configuration_schema = payload["components"]["schemas"]["ModelProviderCustomConfigurationSummaryResponse"]
custom_configuration_properties = custom_configuration_schema["properties"]
assert set(custom_configuration_schema["required"]) == {
"available_credentials",
"current_credential_usable",
"status",
}
assert set(custom_configuration_properties) == {
"available_credentials",
"current_credential_id",
"current_credential_name",
"current_credential_usable",
"status",
}
assert custom_configuration_properties["available_credentials"]["items"]["$ref"] == (
"#/components/schemas/CredentialConfiguration"
)
assert "has_credentials" not in custom_configuration_properties
credential_properties = payload["components"]["schemas"]["CredentialConfiguration"]["properties"]
assert set(credential_properties) == {
"credential_id",
"credential_name",
}
assert "encrypted_config" not in credential_properties
plugin_properties = payload["components"]["schemas"]["ModelProviderPluginSummaryResponse"]["properties"]
assert set(plugin_properties) == {
"installation_id",
"plugin_id",
"plugin_unique_identifier",
"runtime_type",
"source",
"version",
}
@@ -7,7 +7,6 @@ from types import SimpleNamespace
from typing import Any
import pytest
from dify_agent.layers.config import DifyConfigSkillConfig
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
@@ -30,14 +29,6 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from models.agent_config_entities import AgentSoulConfig
@pytest.fixture(autouse=True)
def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs",
lambda *, tenant_id, agent_id: [],
)
def _exec_ctx() -> DifyExecutionContextLayerConfig:
return DifyExecutionContextLayerConfig(
tenant_id="tenant-1",
@@ -523,33 +514,6 @@ class TestAgentAppConfigLayer:
"mentioned_file_names": [],
}
def test_config_layer_includes_bound_workspace_skills(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs",
lambda *, tenant_id, agent_id: [
DifyConfigSkillConfig(
name="workspace-skill",
description="Bound workspace skill.",
size=123,
mime_type="application/zip",
)
],
)
soul = _soul_with_model()
soul.prompt.system_prompt = "Use [§skill:workspace-skill:Workspace Skill§]."
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
assert [skill.name for skill in config.config.skills] == ["workspace-skill"]
assert config.config.mentioned_skill_names == ["workspace-skill"]
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use workspace-skill."
@pytest.mark.parametrize(
("system_prompt", "expected_prefix"),
[
@@ -1,11 +1,11 @@
from __future__ import annotations
import logging
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline
@@ -54,7 +54,6 @@ from graphon.runtime import GraphRuntimeState, VariablePool
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole
from models.model import AppMode, EndUser
from models.workflow import WorkflowAppLog
from tests.workflow_test_utils import build_test_variable_pool
@@ -194,7 +193,7 @@ class TestWorkflowGenerateTaskPipeline:
assert isinstance(responses[0], ValueError)
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine):
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch):
pipeline = _make_pipeline()
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")),
@@ -202,10 +201,11 @@ class TestWorkflowGenerateTaskPipeline:
)
pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started"
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.db",
SimpleNamespace(engine=sqlite_engine),
)
@contextmanager
def _fake_session():
yield SimpleNamespace()
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None)
responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))
@@ -339,18 +339,19 @@ class TestWorkflowGenerateTaskPipeline:
assert responses == ["finish"]
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
def test_save_workflow_app_log_created_from(self, sqlite_session: Session):
def test_save_workflow_app_log_created_from(self):
pipeline = _make_pipeline()
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
pipeline._user_id = "user"
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
sqlite_session.flush()
added: list[object] = []
saved_log = sqlite_session.scalar(select(WorkflowAppLog))
assert saved_log is not None
assert saved_log.workflow_run_id == "run-id"
assert saved_log.created_from == "service-api"
class _Session:
def add(self, item):
added.append(item)
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert added
def test_iteration_loop_and_human_input_handlers(self):
pipeline = _make_pipeline()
@@ -673,29 +674,35 @@ class TestWorkflowGenerateTaskPipeline:
assert "Fails to get audio trunk, task_id: task" in caplog.messages
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
def test_database_session_rolls_back_on_error(
self, monkeypatch: pytest.MonkeyPatch, sqlite_engine, sqlite_session: Session
):
def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch):
pipeline = _make_pipeline()
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
pipeline._user_id = "user"
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.db",
SimpleNamespace(engine=sqlite_engine),
)
calls = {"enter": 0, "exit_exc": None}
def persist_then_fail() -> None:
with pipeline._database_session() as session:
pipeline._save_workflow_app_log(session=session, workflow_run_id="run-id")
session.flush()
raise RuntimeError("db error")
class _BeginContext:
def __enter__(self):
calls["enter"] += 1
return MagicMock()
def __exit__(self, exc_type, exc, tb):
calls["exit_exc"] = exc_type
return False
class _Sessionmaker:
def __init__(self, *args, **kwargs):
pass
def begin(self):
return _BeginContext()
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.sessionmaker", _Sessionmaker)
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object()))
with pytest.raises(RuntimeError, match="db error"):
persist_then_fail()
with pipeline._database_session():
raise RuntimeError("db error")
sqlite_session.expire_all()
assert sqlite_session.scalar(select(WorkflowAppLog)) is None
assert calls["enter"] == 1
assert calls["exit_exc"] is RuntimeError
def test_node_retry_and_started_handlers_cover_none_and_value(self):
pipeline = _make_pipeline()
@@ -855,30 +862,31 @@ class TestWorkflowGenerateTaskPipeline:
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["stopped"])
assert list(pipeline._process_stream_response()) == ["stopped"]
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
def test_save_workflow_app_log_covers_invoke_from_variants(self, sqlite_session: Session):
def test_save_workflow_app_log_covers_invoke_from_variants(self):
pipeline = _make_pipeline()
pipeline._user_id = "user-id"
added: list[object] = []
class _Session:
def add(self, item):
added.append(item)
pipeline._application_generate_entity.invoke_from = InvokeFrom.EXPLORE
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert added[-1].created_from == "installed-app"
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-2")
sqlite_session.flush()
saved_logs = sqlite_session.scalars(select(WorkflowAppLog).order_by(WorkflowAppLog.workflow_run_id)).all()
assert [log.created_from for log in saved_logs] == ["installed-app", "web-app"]
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert added[-1].created_from == "web-app"
count_before = len(saved_logs)
count_before = len(added)
pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-3")
sqlite_session.flush()
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert len(added) == count_before
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id=None)
sqlite_session.flush()
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id=None)
assert len(added) == count_before
def test_save_output_for_event_writes_draft_variables(self):
pipeline = _make_pipeline()
@@ -1,13 +1,9 @@
import logging
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from sqlalchemy import Engine, event
from sqlalchemy.orm import Session, sessionmaker
from core.app.layers.trigger_post_layer import TriggerPostLayer
from core.workflow.system_variables import build_system_variables
@@ -17,63 +13,19 @@ from graphon.graph_events import (
GraphRunSucceededEvent,
)
from graphon.runtime import VariablePool
from models.enums import AppTriggerType, CreatorUserRole, WorkflowTriggerStatus
from models.trigger import WorkflowTriggerLog
@dataclass(frozen=True)
class TriggerDatabase:
session: Session
statements: list[str]
@pytest.fixture(autouse=True)
def trigger_database(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[TriggerDatabase]:
"""Create the trigger-log table and bind layer-owned sessions to SQLite."""
WorkflowTriggerLog.metadata.create_all(sqlite_engine, tables=[WorkflowTriggerLog.__table__])
sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker)
statements: list[str] = []
def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
statements.append(statement)
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
with sqlite_session_maker() as session:
try:
yield TriggerDatabase(session=session, statements=statements)
finally:
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
def _persist_trigger_log(database: TriggerDatabase, *, trigger_log_id: str = "log-1") -> WorkflowTriggerLog:
trigger_log = WorkflowTriggerLog(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_run_id=None,
root_node_id=None,
trigger_metadata="{}",
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
trigger_data="{}",
inputs="{}",
outputs=None,
status=WorkflowTriggerStatus.RUNNING,
error=None,
queue_name="workflow",
celery_task_id=None,
created_by_role=CreatorUserRole.ACCOUNT,
created_by="account-1",
)
trigger_log.id = trigger_log_id
database.session.add(trigger_log)
database.session.commit()
return trigger_log
from models.enums import WorkflowTriggerStatus
class TestTriggerPostLayer:
def test_on_event_updates_trigger_log(self, trigger_database: TriggerDatabase):
trigger_log = _persist_trigger_log(trigger_database)
def test_on_event_updates_trigger_log(self):
trigger_log = SimpleNamespace(
status=None,
workflow_run_id=None,
outputs=None,
elapsed_time=None,
total_tokens=None,
finished_at=None,
)
runtime_state = SimpleNamespace(
outputs={"answer": "ok"},
variable_pool=VariablePool.from_bootstrap(
@@ -83,10 +35,19 @@ class TestTriggerPostLayer:
)
with (
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
):
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = session
repo = Mock()
repo.get_by_id.return_value = trigger_log
mock_repo_cls.return_value = repo
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
@@ -96,18 +57,25 @@ class TestTriggerPostLayer:
layer.on_event(GraphRunSucceededEvent())
trigger_database.session.expire_all()
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
assert persisted_log is not None
assert persisted_log.status == WorkflowTriggerStatus.SUCCEEDED
assert persisted_log.workflow_run_id == "run-1"
assert persisted_log.outputs == '{"answer":"ok"}'
assert persisted_log.elapsed_time == 10
assert persisted_log.total_tokens == 12
assert persisted_log.finished_at is not None
assert trigger_log.status == WorkflowTriggerStatus.SUCCEEDED
assert trigger_log.workflow_run_id == "run-1"
assert trigger_log.outputs is not None
assert trigger_log.elapsed_time is not None
assert trigger_log.total_tokens == 12
assert trigger_log.finished_at is not None
repo.update.assert_called_once_with(trigger_log)
session.commit.assert_called_once()
def test_on_event_updates_trigger_log_for_aborted_event(self, trigger_database: TriggerDatabase):
trigger_log = _persist_trigger_log(trigger_database)
def test_on_event_updates_trigger_log_for_aborted_event(self):
trigger_log = SimpleNamespace(
status=None,
workflow_run_id=None,
outputs=None,
error=None,
elapsed_time=None,
total_tokens=None,
finished_at=None,
)
runtime_state = SimpleNamespace(
outputs={"partial": "ok"},
variable_pool=VariablePool.from_bootstrap(
@@ -117,10 +85,19 @@ class TestTriggerPostLayer:
)
with (
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
):
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = session
repo = Mock()
repo.get_by_id.return_value = trigger_log
mock_repo_cls.return_value = repo
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
@@ -130,22 +107,17 @@ class TestTriggerPostLayer:
layer.on_event(GraphRunAbortedEvent(reason="timeout"))
trigger_database.session.expire_all()
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
assert persisted_log is not None
assert persisted_log.status == WorkflowTriggerStatus.FAILED
assert persisted_log.workflow_run_id == "run-1"
assert persisted_log.outputs == '{"partial":"ok"}'
assert persisted_log.error == "timeout"
assert persisted_log.elapsed_time == 10
assert persisted_log.total_tokens == 7
assert persisted_log.finished_at is not None
assert trigger_log.status == WorkflowTriggerStatus.FAILED
assert trigger_log.workflow_run_id == "run-1"
assert trigger_log.outputs is not None
assert trigger_log.error == "timeout"
assert trigger_log.elapsed_time is not None
assert trigger_log.total_tokens == 7
assert trigger_log.finished_at is not None
repo.update.assert_called_once_with(trigger_log)
session.commit.assert_called_once()
def test_on_event_handles_missing_trigger_log(
self,
caplog: pytest.LogCaptureFixture,
trigger_database: TriggerDatabase,
):
def test_on_event_handles_missing_trigger_log(self, caplog: pytest.LogCaptureFixture):
runtime_state = SimpleNamespace(
outputs={},
variable_pool=VariablePool.from_bootstrap(
@@ -154,20 +126,31 @@ class TestTriggerPostLayer:
total_tokens=0,
)
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC),
trigger_log_id="missing",
)
layer.initialize(runtime_state, Mock())
with (
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
):
session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = session
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
layer.on_event(GraphRunFailedEvent(error="boom"))
repo = Mock()
repo.get_by_id.return_value = None
mock_repo_cls.return_value = repo
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC),
trigger_log_id="missing",
)
layer.initialize(runtime_state, Mock())
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
layer.on_event(GraphRunFailedEvent(error="boom"))
assert any(record.levelno == logging.ERROR for record in caplog.records)
assert trigger_database.session.get(WorkflowTriggerLog, "missing") is None
session.commit.assert_not_called()
def test_on_event_ignores_non_status_events(self, trigger_database: TriggerDatabase):
def test_on_event_ignores_non_status_events(self):
runtime_state = SimpleNamespace(
outputs={},
variable_pool=VariablePool.from_bootstrap(
@@ -176,14 +159,14 @@ class TestTriggerPostLayer:
total_tokens=0,
)
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC),
trigger_log_id="log-1",
)
layer.initialize(runtime_state, Mock())
with patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory:
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC),
trigger_log_id="log-1",
)
layer.initialize(runtime_state, Mock())
trigger_database.statements.clear()
layer.on_event(Mock())
layer.on_event(Mock())
assert trigger_database.statements == []
mock_session_factory.create_session.assert_not_called()
@@ -1,95 +1,24 @@
"""Unit tests for the message cycle manager optimization."""
import logging
from collections.abc import Iterator
from dataclasses import dataclass
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from flask import Flask, current_app
from sqlalchemy import Engine, event, select
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.queue_entities import QueueAnnotationReplyEvent, QueueRetrieverResourcesEvent
from core.app.entities.task_entities import MessageStreamResponse, StreamEvent, TaskStateMetadata
from core.app.task_pipeline import message_cycle_manager as message_cycle_manager_module
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
from core.rag.entities import RetrievalSourceMetadata
from graphon.file import FileTransferMethod, FileType
from models import model as model_module
from models.base import TypeBase
from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo
from models.model import App, AppMode, Conversation, MessageFile
from models.model import App, AppMode
@dataclass(frozen=True)
class _SQLiteDb:
engine: Engine
session: Session
@pytest.fixture
def cycle_db(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
"""Bind request-owned and cycle-manager-owned sessions to isolated SQLite."""
TypeBase.metadata.create_all(
sqlite_engine,
tables=[App.__table__, Conversation.__table__, MessageFile.__table__],
)
owned_session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
with owned_session_factory() as request_session:
sqlite_db = _SQLiteDb(engine=sqlite_engine, session=request_session)
monkeypatch.setattr(message_cycle_manager_module, "db", sqlite_db)
monkeypatch.setattr(model_module, "db", sqlite_db)
monkeypatch.setattr(message_cycle_manager_module.session_factory, "create_session", owned_session_factory)
yield request_session
def _app(*, app_id: str = "app-id", tenant_id: str = "tenant-1") -> App:
return App(
id=app_id,
tenant_id=tenant_id,
name="Test App",
description="",
mode=AppMode.CHAT,
enable_site=True,
enable_api=True,
max_active_requests=0,
)
def _conversation(*, conversation_id: str = "conv-1", app_id: str = "app-id") -> Conversation:
conversation = Conversation(
app_id=app_id,
mode=AppMode.CHAT,
name="",
status="normal",
from_source=ConversationFromSource.API,
inputs={},
)
conversation.id = conversation_id
return conversation
def _message_file(
*,
file_id: str = "file-1",
message_id: str = "test-message-id",
belongs_to: MessageFileBelongsTo | None = MessageFileBelongsTo.ASSISTANT,
url: str | None = "http://example.com/image.png",
file_type: FileType = FileType.IMAGE,
) -> MessageFile:
message_file = MessageFile(
message_id=message_id,
type=file_type,
transfer_method=FileTransferMethod.TOOL_FILE,
created_by_role=CreatorUserRole.ACCOUNT,
created_by="account-id",
belongs_to=belongs_to,
url=url,
)
message_file.id = file_id
return message_file
def _patch_create_session(mock_session):
session_cm = Mock()
session_cm.__enter__ = Mock(return_value=mock_session)
session_cm.__exit__ = Mock(return_value=False)
return patch("core.app.task_pipeline.message_cycle_manager.session_factory.create_session", return_value=session_cm)
class TestMessageCycleManagerOptimization:
@@ -108,22 +37,30 @@ class TestMessageCycleManagerOptimization:
task_state = Mock()
return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state)
def test_get_message_event_type_with_assistant_file(self, message_cycle_manager, cycle_db: Session):
def test_get_message_event_type_with_assistant_file(self, message_cycle_manager):
"""Test get_message_event_type returns MESSAGE_FILE when message has assistant-generated files.
This ensures that AI-generated images (belongs_to='assistant') trigger the MESSAGE_FILE event,
allowing the frontend to properly display generated image files with url field.
"""
cycle_db.add(_message_file())
cycle_db.commit()
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
# Setup mock session and message file
mock_session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
with current_app.app_context():
result = message_cycle_manager.get_message_event_type("test-message-id")
mock_message_file = Mock()
mock_message_file.belongs_to = "assistant"
mock_session.scalar.return_value = mock_message_file
assert result == StreamEvent.MESSAGE_FILE
assert "test-message-id" in message_cycle_manager._message_has_file
# Execute
with current_app.app_context():
result = message_cycle_manager.get_message_event_type("test-message-id")
def test_get_message_event_type_with_user_file(self, message_cycle_manager, cycle_db: Session):
# Assert
assert result == StreamEvent.MESSAGE_FILE
mock_session.scalar.assert_called_once()
def test_get_message_event_type_with_user_file(self, message_cycle_manager):
"""Test get_message_event_type returns MESSAGE when message only has user-uploaded files.
This is a regression test for the issue where user-uploaded images (belongs_to='user')
@@ -131,81 +68,90 @@ class TestMessageCycleManagerOptimization:
resulting in broken images in the chat UI. The query filters for belongs_to='assistant',
so when only user files exist, the database query returns None, resulting in MESSAGE event type.
"""
cycle_db.add(_message_file(belongs_to=MessageFileBelongsTo.USER))
cycle_db.commit()
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
# Setup mock session and message file
mock_session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
with current_app.app_context():
result = message_cycle_manager.get_message_event_type("test-message-id")
# When querying for assistant files with only user files present, return None
# (simulates database query with belongs_to='assistant' filter returning no results)
mock_session.scalar.return_value = None
assert result == StreamEvent.MESSAGE
assert "test-message-id" not in message_cycle_manager._message_has_file
# Execute
with current_app.app_context():
result = message_cycle_manager.get_message_event_type("test-message-id")
def test_get_message_event_type_without_message_file(self, message_cycle_manager, cycle_db: Session):
# Assert
assert result == StreamEvent.MESSAGE
mock_session.scalar.assert_called_once()
def test_get_message_event_type_without_message_file(self, message_cycle_manager):
"""Test get_message_event_type returns MESSAGE when message has no files."""
assert list(cycle_db.scalars(select(MessageFile)).all()) == []
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
# Setup mock session and no message file
mock_session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
# Current implementation uses session.scalar(select(...))
mock_session.scalar.return_value = None
with current_app.app_context():
result = message_cycle_manager.get_message_event_type("test-message-id")
# Execute
with current_app.app_context():
result = message_cycle_manager.get_message_event_type("test-message-id")
assert result == StreamEvent.MESSAGE
# Assert
assert result == StreamEvent.MESSAGE
mock_session.scalar.assert_called_once()
def test_get_message_event_type_uses_cache_without_query(
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
):
def test_get_message_event_type_uses_cache_without_query(self, message_cycle_manager):
"""Return MESSAGE_FILE directly from in-memory cache without opening a DB session."""
message_cycle_manager._message_has_file.add("cached-message")
statements: list[str] = []
def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
statements.append(statement)
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
try:
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
result = message_cycle_manager.get_message_event_type("cached-message")
finally:
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
assert result == StreamEvent.MESSAGE_FILE
assert statements == []
mock_session_factory.create_session.assert_not_called()
def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager, cycle_db: Session):
def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager):
"""MessageCycleManager.message_to_stream_response expects a valid event_type; callers should precompute it."""
cycle_db.add(_message_file())
cycle_db.commit()
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
# Setup mock session and message file
mock_session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
with current_app.app_context():
event_type = message_cycle_manager.get_message_event_type("test-message-id")
result = message_cycle_manager.message_to_stream_response(
answer="Hello world", message_id="test-message-id", event_type=event_type
)
mock_message_file = Mock()
mock_message_file.belongs_to = "assistant"
mock_session.scalar.return_value = mock_message_file
assert isinstance(result, MessageStreamResponse)
assert result.answer == "Hello world"
assert result.id == "test-message-id"
assert result.event == StreamEvent.MESSAGE_FILE
# Execute: compute event type once, then pass to message_to_stream_response
with current_app.app_context():
event_type = message_cycle_manager.get_message_event_type("test-message-id")
result = message_cycle_manager.message_to_stream_response(
answer="Hello world", message_id="test-message-id", event_type=event_type
)
def test_message_to_stream_response_with_event_type_skips_query(
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
):
# Assert
assert isinstance(result, MessageStreamResponse)
assert result.answer == "Hello world"
assert result.id == "test-message-id"
assert result.event == StreamEvent.MESSAGE_FILE
mock_session.scalar.assert_called_once()
def test_message_to_stream_response_with_event_type_skips_query(self, message_cycle_manager):
"""Test that message_to_stream_response skips database query when event_type is provided."""
statements: list[str] = []
def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
statements.append(statement)
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
try:
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
# Execute with event_type provided
result = message_cycle_manager.message_to_stream_response(
answer="Hello world", message_id="test-message-id", event_type=StreamEvent.MESSAGE
)
finally:
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
assert isinstance(result, MessageStreamResponse)
assert result.answer == "Hello world"
assert result.id == "test-message-id"
assert result.event == StreamEvent.MESSAGE
assert statements == []
# Assert
assert isinstance(result, MessageStreamResponse)
assert result.answer == "Hello world"
assert result.id == "test-message-id"
assert result.event == StreamEvent.MESSAGE
# Should not open a session when event_type is provided
mock_session_factory.create_session.assert_not_called()
def test_message_to_stream_response_with_from_variable_selector(self, message_cycle_manager):
"""Test message_to_stream_response with from_variable_selector parameter."""
@@ -222,32 +168,40 @@ class TestMessageCycleManagerOptimization:
assert result.from_variable_selector == ["var1", "var2"]
assert result.event == StreamEvent.MESSAGE
def test_optimization_usage_example(self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine):
def test_optimization_usage_example(self, message_cycle_manager):
"""Test the optimization pattern that should be used by callers."""
statements: list[str] = []
def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
statements.append(statement)
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
try:
# Step 1: Get event type once (this queries database)
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
mock_session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
# Current implementation uses session.scalar(select(...))
mock_session.scalar.return_value = None # No files
with current_app.app_context():
event_type = message_cycle_manager.get_message_event_type("test-message-id")
# Should open session once
mock_session_factory.create_session.assert_called_once()
assert event_type == StreamEvent.MESSAGE
# Step 2: Use event_type for multiple calls (no additional queries)
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
mock_session_factory.create_session.return_value.__enter__.return_value = Mock()
chunk1_response = message_cycle_manager.message_to_stream_response(
answer="Chunk 1", message_id="test-message-id", event_type=event_type
)
chunk2_response = message_cycle_manager.message_to_stream_response(
answer="Chunk 2", message_id="test-message-id", event_type=event_type
)
finally:
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
assert event_type == StreamEvent.MESSAGE
assert len([statement for statement in statements if statement.lstrip().upper().startswith("SELECT")]) == 1
assert chunk1_response.event == StreamEvent.MESSAGE
assert chunk2_response.event == StreamEvent.MESSAGE
assert chunk1_response.answer == "Chunk 1"
assert chunk2_response.answer == "Chunk 2"
# Should not open session again when event_type provided
mock_session_factory.create_session.assert_not_called()
assert chunk1_response.event == StreamEvent.MESSAGE
assert chunk2_response.event == StreamEvent.MESSAGE
assert chunk1_response.answer == "Chunk 1"
assert chunk2_response.answer == "Chunk 2"
def test_generate_conversation_name_returns_none_for_completion(self, message_cycle_manager):
"""Return None when completion entities are used for conversation naming.
@@ -315,38 +269,51 @@ class TestMessageCycleManagerOptimization:
assert message_cycle_manager._application_generate_entity.is_new_conversation is False
mock_timer.assert_not_called()
def test_generate_conversation_name_worker_returns_when_conversation_missing(
self, message_cycle_manager, cycle_db: Session
):
def test_generate_conversation_name_worker_returns_when_conversation_missing(self, message_cycle_manager):
"""Return early when the conversation cannot be found."""
flask_app = Flask(__name__)
assert list(cycle_db.scalars(select(Conversation)).all()) == []
db_session = Mock()
db_session.scalar.return_value = None
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-missing", "hello")
with _patch_create_session(db_session):
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-missing", "hello")
assert list(cycle_db.scalars(select(Conversation)).all()) == []
db_session.commit.assert_not_called()
def test_generate_conversation_name_worker_returns_when_app_missing(self, message_cycle_manager, cycle_db: Session):
def test_generate_conversation_name_worker_returns_when_app_missing(self, message_cycle_manager):
"""Return early when non-completion conversation has no app relation."""
flask_app = Flask(__name__)
conversation = _conversation()
cycle_db.add(conversation)
cycle_db.commit()
conversation = SimpleNamespace(mode=AppMode.CHAT, app=None, app_id="app-id")
db_session = Mock()
db_session.scalar.return_value = conversation
db_session.get.return_value = None
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
with _patch_create_session(db_session):
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
assert cycle_db.get(Conversation, "conv-1").name == ""
assert cycle_db.get(App, "app-id") is None
db_session.commit.assert_not_called()
def test_generate_conversation_name_worker_uses_cached_name(
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
):
def test_generate_conversation_name_worker_uses_cached_name(self, message_cycle_manager):
"""Use cached conversation name when present and avoid LLM call."""
flask_app = Flask(__name__)
cycle_db.add_all([_app(), _conversation()])
cycle_db.commit()
class ConversationWithPoisonedApp:
mode = AppMode.CHAT
app_id = "app-id"
name = ""
@property
def app(self):
raise AssertionError("conversation.app must not open an implicit session")
conversation = ConversationWithPoisonedApp()
app_model = SimpleNamespace(tenant_id="tenant-1")
db_session = Mock()
db_session.scalar.return_value = conversation
db_session.get.return_value = app_model
with (
_patch_create_session(db_session) as create_session,
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
):
@@ -354,23 +321,27 @@ class TestMessageCycleManagerOptimization:
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
assert cycle_db.in_transaction() is False
with Session(sqlite_engine) as verification_session:
conversation = verification_session.get(Conversation, "conv-1")
assert conversation is not None
assert conversation.name == "cached-title"
create_session.assert_called_once_with()
db_session.get.assert_called_once_with(App, "app-id")
db_session.commit.assert_called_once()
mock_llm_generator.generate_conversation_name.assert_not_called()
mock_redis.setex.assert_not_called()
def test_generate_conversation_name_worker_generates_and_caches_name(
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
):
def test_generate_conversation_name_worker_generates_and_caches_name(self, message_cycle_manager):
"""Generate conversation name and write it to redis cache on cache miss."""
flask_app = Flask(__name__)
cycle_db.add_all([_app(), _conversation()])
cycle_db.commit()
conversation = SimpleNamespace(
mode=AppMode.CHAT,
app=SimpleNamespace(tenant_id="tenant-1"),
app_id="app-id",
name="",
)
db_session = Mock()
db_session.scalar.return_value = conversation
with (
_patch_create_session(db_session),
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
):
@@ -379,27 +350,27 @@ class TestMessageCycleManagerOptimization:
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
assert cycle_db.in_transaction() is False
with Session(sqlite_engine) as verification_session:
conversation = verification_session.get(Conversation, "conv-1")
assert conversation is not None
assert conversation.name == "generated-title"
db_session.commit.assert_called_once()
mock_redis.setex.assert_called_once()
def test_generate_conversation_name_worker_falls_back_when_generation_fails(
self,
message_cycle_manager,
cycle_db: Session,
sqlite_engine: Engine,
caplog: pytest.LogCaptureFixture,
self, message_cycle_manager, caplog: pytest.LogCaptureFixture
):
"""Fallback to truncated query when LLM generation fails."""
flask_app = Flask(__name__)
cycle_db.add_all([_app(), _conversation()])
cycle_db.commit()
conversation = SimpleNamespace(
mode=AppMode.CHAT,
app=SimpleNamespace(tenant_id="tenant-1"),
app_id="app-id",
name="",
)
db_session = Mock()
db_session.scalar.return_value = conversation
long_query = "q" * 60
with (
_patch_create_session(db_session),
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
patch("core.app.task_pipeline.message_cycle_manager.dify_config") as mock_dify_config,
@@ -411,11 +382,8 @@ class TestMessageCycleManagerOptimization:
with caplog.at_level(logging.ERROR, logger="core.app.task_pipeline.message_cycle_manager"):
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query)
assert cycle_db.in_transaction() is False
with Session(sqlite_engine) as verification_session:
conversation = verification_session.get(Conversation, "conv-1")
assert conversation is not None
assert conversation.name == (long_query[:47] + "...")
db_session.commit.assert_called_once()
assert any(record.levelno == logging.ERROR for record in caplog.records)
def test_handle_annotation_reply_sets_metadata(self, message_cycle_manager):
@@ -486,25 +454,33 @@ class TestMessageCycleManagerOptimization:
assert message_cycle_manager._task_state.metadata.retriever_resources[0].position == 1
assert message_cycle_manager._task_state.metadata.retriever_resources[1].position == 2
def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_manager, cycle_db: Session):
def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_manager):
"""Build a stream response with a signed tool file URL.
Args: message_cycle_manager with a persisted MessageFile and mocked sign_tool_file.
Args: message_cycle_manager with mocked Session/db and sign_tool_file.
Returns: MessageStreamResponse with signed url and belongs_to normalized to user.
Side effects: Calls sign_tool_file for tool file ids.
"""
message_cycle_manager._application_generate_entity.task_id = "task-1"
cycle_db.add(
_message_file(
file_id="file-1",
message_id="msg-1",
belongs_to=None,
url="tool://file.verylongextension",
)
)
cycle_db.commit()
with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign:
message_file = SimpleNamespace(
id="file-1",
type="image",
belongs_to=None,
url="tool://file.verylongextension",
message_id="msg-1",
)
session = Mock()
session.scalar.return_value = message_file
with (
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign,
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
):
mock_db.engine = Mock()
mock_session_cls.return_value.__enter__.return_value = session
mock_sign.return_value = "signed-url"
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="file-1"))
@@ -538,42 +514,56 @@ class TestMessageCycleManagerOptimization:
assert len(message_cycle_manager._task_state.metadata.retriever_resources) == 1
assert message_cycle_manager._task_state.metadata.retriever_resources[0].position == 1
def test_message_file_to_stream_response_uses_http_url_directly(self, message_cycle_manager, cycle_db: Session):
def test_message_file_to_stream_response_uses_http_url_directly(self, message_cycle_manager):
"""Use original URL when message file URL is already HTTP."""
message_cycle_manager._application_generate_entity.task_id = "task-http"
cycle_db.add(
_message_file(
file_id="file-http",
message_id="msg-http",
belongs_to=MessageFileBelongsTo.ASSISTANT,
url="http://example.com/pic.png",
)
message_file = SimpleNamespace(
id="file-http",
type="image",
belongs_to="assistant",
url="http://example.com/pic.png",
message_id="msg-http",
)
cycle_db.commit()
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="file-http"))
session = Mock()
session.scalar.return_value = message_file
with (
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
):
mock_db.engine = Mock()
mock_session_cls.return_value.__enter__.return_value = session
response = message_cycle_manager.message_file_to_stream_response(
SimpleNamespace(message_file_id="file-http")
)
assert response is not None
assert response.url == "http://example.com/pic.png"
assert "msg-http" in message_cycle_manager._message_has_file
def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot(
self, message_cycle_manager, cycle_db: Session
):
def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot(self, message_cycle_manager):
"""Default tool file extension to .bin when URL has no extension part."""
message_cycle_manager._application_generate_entity.task_id = "task-bin"
cycle_db.add(
_message_file(
file_id="file-bin",
message_id="msg-bin",
belongs_to=MessageFileBelongsTo.ASSISTANT,
url="tool-file-id",
file_type=FileType.CUSTOM,
)
message_file = SimpleNamespace(
id="file-bin",
type="file",
belongs_to="assistant",
url="tool-file-id",
message_id="msg-bin",
)
cycle_db.commit()
with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign:
session = Mock()
session.scalar.return_value = message_file
with (
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign,
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
):
mock_db.engine = Mock()
mock_session_cls.return_value.__enter__.return_value = session
mock_sign.return_value = "signed-bin-url"
response = message_cycle_manager.message_file_to_stream_response(
@@ -584,13 +574,19 @@ class TestMessageCycleManagerOptimization:
assert response.url == "signed-bin-url"
mock_sign.assert_called_once_with(tool_file_id="tool-file-id", extension=".bin")
def test_message_file_to_stream_response_returns_none_when_file_missing(
self, message_cycle_manager, cycle_db: Session
):
def test_message_file_to_stream_response_returns_none_when_file_missing(self, message_cycle_manager):
"""Return None when message file lookup does not find a record."""
assert list(cycle_db.scalars(select(MessageFile)).all()) == []
session = Mock()
session.scalar.return_value = None
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="missing"))
with (
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
):
mock_db.engine = Mock()
mock_session_cls.return_value.__enter__.return_value = session
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="missing"))
assert response is None
@@ -1,13 +1,10 @@
import types
from collections.abc import Generator, Iterator
from collections.abc import Generator
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from contexts.wrapper import RecyclableContextVar
from core.datasource import datasource_manager as datasource_manager_module
from core.datasource.datasource_manager import DatasourceManager
from core.datasource.entities.datasource_entities import DatasourceMessage, DatasourceProviderType
from core.datasource.errors import DatasourceProviderNotFoundError
@@ -15,34 +12,6 @@ from core.workflow.file_reference import parse_file_reference
from graphon.enums import WorkflowNodeExecutionStatus
from graphon.file import File, FileTransferMethod, FileType
from graphon.node_events import StreamChunkEvent, StreamCompletedEvent
from models.base import TypeBase
from models.tools import ToolFile
@pytest.fixture
def tool_file_session(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
"""Bind datasource-owned lookups to a SQLite ToolFile table."""
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[ToolFile.__tablename__]])
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(datasource_manager_module.session_factory, "create_session", session_maker)
with session_maker() as session:
yield session
def _persist_tool_file(session: Session, *, file_id: str, tenant_id: str) -> ToolFile:
tool_file = ToolFile(
user_id="user-1",
tenant_id=tenant_id,
conversation_id=None,
file_key="files/image.png",
mimetype="image/png",
name="image.png",
size=10,
)
tool_file.id = file_id
session.add(tool_file)
session.commit()
return tool_file
def _gen_messages_text_only(text: str) -> Generator[DatasourceMessage, None, None]:
@@ -404,8 +373,7 @@ def test_stream_node_events_emits_events_online_document(mocker: MockerFixture):
assert events[-1].node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture, tool_file_session: Session):
_persist_tool_file(tool_file_session, file_id="tool_file_1", tenant_id="t1")
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture):
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
def _transformed(**_kwargs):
@@ -450,6 +418,19 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
side_effect=_transformed,
)
fake_tool_file = types.SimpleNamespace(mimetype="image/png")
class _Session:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def scalar(self, _stmt):
return fake_tool_file
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
mocker.patch("core.datasource.datasource_manager.get_file_type_by_mime_type", return_value=FileType.IMAGE)
built = File(
file_type=FileType.IMAGE,
@@ -500,8 +481,7 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
assert events[-1].node_run_result.outputs["x"] == 1
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture, tool_file_session: Session):
_persist_tool_file(tool_file_session, file_id="missing", tenant_id="other-tenant")
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture):
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
def _transformed(**_kwargs):
@@ -516,6 +496,18 @@ def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture,
side_effect=_transformed,
)
class _Session:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def scalar(self, _stmt):
return None
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
with pytest.raises(ValueError, match="ToolFile not found for file_id=missing, tenant_id=t1"):
list(
DatasourceManager.stream_node_events(
@@ -14,64 +14,18 @@ Tests follow the Arrange-Act-Assert pattern for clarity.
"""
import json
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
from unittest.mock import Mock, patch
from uuid import uuid4
import httpx
import pytest
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from core.datasource.entities.datasource_entities import DatasourceProviderType
from core.datasource.online_document.online_document_provider import (
OnlineDocumentDatasourcePluginProviderController,
)
from core.rag.extractor import notion_extractor as notion_extractor_module
from core.rag.extractor.notion_extractor import NotionExtractor
from core.rag.models.document import Document
from models.base import TypeBase
from models.dataset import Document as DocumentModel
from models.enums import DataSourceType, DocumentCreatedFrom
@dataclass(frozen=True)
class _Database:
"""Expose the real SQLite session used by the extractor update."""
session: Session
@pytest.fixture
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Database]:
"""Bind a real session for Notion document metadata persistence."""
TypeBase.metadata.create_all(sqlite_engine, tables=[DocumentModel.__table__])
with Session(sqlite_engine, expire_on_commit=False) as session:
database = _Database(session)
monkeypatch.setattr(notion_extractor_module, "db", database)
yield database
@pytest.fixture
def persisted_document(database: _Database) -> DocumentModel:
document = DocumentModel(
id=str(uuid4()),
tenant_id=str(uuid4()),
dataset_id=str(uuid4()),
position=1,
data_source_type=DataSourceType.NOTION_IMPORT,
data_source_info=json.dumps({"last_edited_time": "2024-01-01T00:00:00.000Z"}),
batch="batch",
name="Notion page",
created_from=DocumentCreatedFrom.WEB,
created_by=str(uuid4()),
)
database.session.add(document)
database.session.commit()
return document
class TestNotionExtractorAuthentication:
@@ -809,14 +763,9 @@ class TestNotionExtractorLastEditedTime:
call_args = mock_request.call_args
assert "databases/database-789" in call_args[0][1]
@patch("core.rag.extractor.notion_extractor.db")
@patch("httpx.request")
def test_update_last_edited_time(
self,
mock_request: Mock,
extractor_page: NotionExtractor,
database: _Database,
persisted_document: DocumentModel,
):
def test_update_last_edited_time(self, mock_request, mock_db, extractor_page, mock_document_model):
"""Test updating document model with last edited time."""
# Arrange
mock_response = Mock()
@@ -828,11 +777,11 @@ class TestNotionExtractorLastEditedTime:
mock_request.return_value = mock_response
# Act
extractor_page.update_last_edited_time(persisted_document)
extractor_page.update_last_edited_time(mock_document_model)
# Assert
database.session.expire(persisted_document)
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
assert mock_document_model.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
mock_db.session.commit.assert_called_once()
def test_update_last_edited_time_no_document(self, extractor_page):
"""Test update_last_edited_time with None document model."""
@@ -858,10 +807,9 @@ class TestNotionExtractorIntegration:
mock_doc.data_source_info_dict = {"last_edited_time": "2024-01-01T00:00:00.000Z"}
return mock_doc
@patch("core.rag.extractor.notion_extractor.db")
@patch("httpx.request")
def test_extract_page_complete_workflow(
self, mock_request: Mock, database: _Database, persisted_document: DocumentModel
):
def test_extract_page_complete_workflow(self, mock_request, mock_db, mock_document_model):
"""Test complete page extraction workflow."""
# Arrange
extractor = NotionExtractor(
@@ -870,7 +818,7 @@ class TestNotionExtractorIntegration:
notion_page_type="page",
tenant_id="tenant-789",
notion_access_token="test-token",
document_model=persisted_document,
document_model=mock_document_model,
)
# Mock last edited time request
@@ -921,18 +869,11 @@ class TestNotionExtractorIntegration:
assert isinstance(documents[0], Document)
assert "# Test Page" in documents[0].page_content
assert "Test content" in documents[0].page_content
database.session.expire(persisted_document)
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
@patch("core.rag.extractor.notion_extractor.db")
@patch("httpx.post")
@patch("httpx.request")
def test_extract_database_complete_workflow(
self,
mock_request: Mock,
mock_post: Mock,
database: _Database,
persisted_document: DocumentModel,
):
def test_extract_database_complete_workflow(self, mock_request, mock_post, mock_db, mock_document_model):
"""Test complete database extraction workflow."""
# Arrange
extractor = NotionExtractor(
@@ -941,7 +882,7 @@ class TestNotionExtractorIntegration:
notion_page_type="database",
tenant_id="tenant-789",
notion_access_token="test-token",
document_model=persisted_document,
document_model=mock_document_model,
)
# Mock last edited time request
@@ -980,8 +921,6 @@ class TestNotionExtractorIntegration:
assert isinstance(documents[0], Document)
assert "Name:Item 1" in documents[0].page_content
assert "Status:Active" in documents[0].page_content
database.session.expire(persisted_document)
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
def test_extract_invalid_page_type(self):
"""Test extract with invalid page type."""
@@ -1,57 +1,7 @@
from datetime import datetime, timedelta
import sys
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import event
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
import core.llm_generator.llm_generator as generator_module
from core.llm_generator.llm_generator import LLMGenerator, _parse_string_list
from core.model_manager import ModelInstance, ModelManager
from core.workflow.generator import tool_catalogue as tool_catalogue_module
from core.workflow.generator.tool_catalogue import ToolCatalogueEntry
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
from models.dataset import Dataset
from services.workflow_service import WorkflowService
@pytest.fixture
def dataset_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
"""Bind the real SQLite session to the production database extension."""
monkeypatch.setattr(generator_module.db, "session", sqlite_session)
return sqlite_session
def _llm_result(content: str) -> LLMResult:
"""Build a real non-streaming LLM response around deterministic test content."""
return LLMResult(
model="test-model",
message=AssistantPromptMessage(content=content),
usage=LLMUsage.empty_usage(),
)
def _model_manager() -> tuple[MagicMock, MagicMock]:
"""Build spec-constrained mocks for the model-manager boundary and its default model."""
model_manager = MagicMock(spec=ModelManager)
model_instance = MagicMock(spec=ModelInstance)
model_manager.get_default_model_instance.return_value = model_instance
return model_manager, model_instance
def _dataset(*, dataset_id: str, tenant_id: str, name: str, created_at: datetime) -> Dataset:
return Dataset(
id=dataset_id,
tenant_id=tenant_id,
name=name,
created_by="account-id",
created_at=created_at,
)
class TestParseStringList:
@@ -84,115 +34,95 @@ class TestParseStringList:
class TestGenerateWorkflowInstructionSuggestions:
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
def test_no_default_model(self, mock_for_tenant):
model_manager, _ = _model_manager()
model_manager.get_default_model_instance.side_effect = RuntimeError("no default model")
mock_for_tenant.return_value = model_manager
mock_for_tenant.return_value.get_default_model_instance.side_effect = Exception("No model")
assert LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") == []
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
@patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context")
def test_llm_success(self, mock_build_context, mock_for_tenant):
mock_build_context.return_value = "context"
model_manager, model_instance = _model_manager()
model_instance.invoke_llm.return_value = _llm_result('["idea 1", "idea 2"]')
mock_for_tenant.return_value = model_manager
mock_model = MagicMock()
mock_model.invoke_llm.return_value = MagicMock()
mock_model.invoke_llm.return_value.message.get_text_content.return_value = '["idea 1", "idea 2"]'
mock_for_tenant.return_value.get_default_model_instance.return_value = mock_model
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow")
assert result == ["idea 1", "idea 2"]
model_instance.invoke_llm.assert_called_once()
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
@patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context")
def test_llm_error(self, mock_build_context, mock_for_tenant):
mock_build_context.return_value = "context"
model_manager, model_instance = _model_manager()
model_instance.invoke_llm.side_effect = RuntimeError("API error")
mock_for_tenant.return_value = model_manager
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow")
assert result == []
model_instance.invoke_llm.assert_called_once()
mock_model = MagicMock()
mock_model.invoke_llm.side_effect = Exception("API error")
mock_for_tenant.return_value.get_default_model_instance.return_value = mock_model
assert LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") == []
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
@patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context")
def test_llm_bad_output(self, mock_build_context, mock_for_tenant):
mock_build_context.return_value = "context"
model_manager, model_instance = _model_manager()
model_instance.invoke_llm.return_value = _llm_result("Not a list")
mock_for_tenant.return_value = model_manager
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow")
assert result == []
model_instance.invoke_llm.assert_called_once()
mock_model = MagicMock()
mock_model.invoke_llm.return_value = MagicMock()
mock_model.invoke_llm.return_value.message.get_text_content.return_value = "Not a list"
mock_for_tenant.return_value.get_default_model_instance.return_value = mock_model
assert LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") == []
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
class TestBuildSuggestionContext:
def test_both_success(self, dataset_session: Session, monkeypatch: pytest.MonkeyPatch):
now = datetime.now()
dataset_session.add_all(
(
_dataset(dataset_id="kb-1", tenant_id="tenant", name="kb1", created_at=now),
_dataset(
dataset_id="kb-2",
tenant_id="tenant",
name="kb2",
created_at=now - timedelta(seconds=1),
),
_dataset(dataset_id="other-kb", tenant_id="other", name="private", created_at=now),
)
)
dataset_session.commit()
@patch("core.llm_generator.llm_generator.db.session.scalars")
def test_both_success(self, mock_scalars, monkeypatch):
mock_scalars.return_value.all.return_value = ["kb1", "kb2"]
def build_tool_catalogue(_tenant_id: str) -> list[ToolCatalogueEntry]:
return [
ToolCatalogueEntry(
provider_name="provider",
provider_type="builtin",
plugin_id="",
tool_name="tool1",
tool_label="tool1",
description="First tool",
),
ToolCatalogueEntry(
provider_name="provider",
provider_type="builtin",
plugin_id="",
tool_name="tool2",
tool_label="tool2",
description="Second tool",
),
]
# Keep the real module and formatter; only isolate provider/plugin discovery.
monkeypatch.setattr(tool_catalogue_module, "build_tool_catalogue", build_tool_catalogue)
# ``_build_suggestion_context`` imports the tool catalogue lazily, so we
# stub the module in ``sys.modules``. Use ``monkeypatch.setitem`` so the
# ORIGINAL module is RESTORED on teardown — a bare ``del`` would evict it
# from sys.modules entirely, after which a sibling test that imported
# ``build_tool_catalogue`` at collection time (e.g. test_tool_catalogue)
# diverges from a freshly re-imported module and its @patch targets stop
# applying, silently breaking it under xdist.
mock_tool_catalogue = MagicMock()
mock_tool_catalogue.build_tool_catalogue.return_value = "catalog"
mock_tool_catalogue.format_tool_catalogue.return_value = "tool1\ntool2"
monkeypatch.setitem(sys.modules, "core.workflow.generator.tool_catalogue", mock_tool_catalogue)
result = LLMGenerator._build_suggestion_context("tenant")
assert "Knowledge bases:\n- kb1\n- kb2" in result
assert "Installed tools:\n- provider/tool1 — First tool\n- provider/tool2 — Second tool" in result
assert "Installed tools:\ntool1\ntool2" in result
def test_both_fail(self, dataset_session: Session, monkeypatch: pytest.MonkeyPatch):
def fail_query(_orm_execute_state: object) -> None:
raise SQLAlchemyError("DB error")
@patch("core.llm_generator.llm_generator.db.session.scalars")
def test_both_fail(self, mock_scalars, monkeypatch):
mock_scalars.side_effect = Exception("DB error")
def fail_tool_catalogue(_tenant_id: str) -> list[ToolCatalogueEntry]:
raise RuntimeError("Tool error")
# See ``test_both_success``: restore the original module via monkeypatch
# rather than ``del``-ing it, so we don't evict it for sibling tests.
mock_tool_catalogue = MagicMock()
mock_tool_catalogue.build_tool_catalogue.side_effect = Exception("Tool error")
monkeypatch.setitem(sys.modules, "core.workflow.generator.tool_catalogue", mock_tool_catalogue)
event.listen(dataset_session, "do_orm_execute", fail_query)
monkeypatch.setattr(tool_catalogue_module, "build_tool_catalogue", fail_tool_catalogue)
try:
assert LLMGenerator._build_suggestion_context("tenant") == ""
finally:
event.remove(dataset_session, "do_orm_execute", fail_query)
assert LLMGenerator._build_suggestion_context("tenant") == ""
class TestWorkflowServiceInterface:
def test_real_workflow_service_exposes_protocol_methods(self):
def test_protocol_methods(self):
# Just to cover the 'pass' statements in the Protocol definition
from core.llm_generator.llm_generator import WorkflowServiceInterface
service: WorkflowServiceInterface = WorkflowService(sessionmaker())
class MockService(WorkflowServiceInterface):
def get_draft_workflow(self, app_model, workflow_id=None, *, session):
return super().get_draft_workflow(app_model, workflow_id, session=session)
assert callable(service.get_draft_workflow)
assert callable(service.get_node_last_run)
def get_node_last_run(self, app_model, workflow, node_id):
return super().get_node_last_run(app_model, workflow, node_id)
service = MockService()
service.get_draft_workflow(None, session=None)
service.get_node_last_run(None, None, "node")
@@ -1,19 +1,11 @@
"""Comprehensive SQLite-backed tests for token-buffer memory."""
"""Comprehensive unit tests for core/memory/token_buffer_memory.py"""
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from sqlalchemy import Engine, event
from sqlalchemy.orm import Session
from core.memory import token_buffer_memory as memory_module
from core.memory.token_buffer_memory import TokenBufferMemory
from graphon.file import FileTransferMethod, FileType
from graphon.model_runtime.entities import (
AssistantPromptMessage,
ImagePromptMessageContent,
@@ -21,44 +13,13 @@ from graphon.model_runtime.entities import (
TextPromptMessageContent,
UserPromptMessage,
)
from models.base import TypeBase
from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo
from models.model import AppMode, Message, MessageFile
from models.workflow import Workflow, WorkflowType
from models.model import AppMode
# ---------------------------------------------------------------------------
# Helpers / shared fixtures
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Database:
"""Typed SQLite binding plus executed SQL for query-count assertions."""
engine: Engine
session: Session
statements: list[tuple[str, object]]
@pytest.fixture
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
TypeBase.metadata.create_all(
sqlite_engine,
tables=[Message.__table__, MessageFile.__table__, Workflow.__table__],
)
statements: list[tuple[str, object]] = []
def record_statement(_connection, _cursor, statement, parameters, _context, _executemany) -> None:
statements.append((statement, parameters))
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
with Session(sqlite_engine, expire_on_commit=False) as session:
database = Database(engine=sqlite_engine, session=session, statements=statements)
monkeypatch.setattr(memory_module, "db", database)
yield database
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
def _make_conversation(mode: AppMode = AppMode.CHAT) -> MagicMock:
"""Return a minimal Conversation mock."""
conv = MagicMock()
@@ -86,73 +47,6 @@ def _make_message(answer: str = "hello", answer_tokens: int = 5) -> MagicMock:
return msg
def _persist_message(
database: Database,
conversation_id: str,
*,
query: str = "user query",
answer: str = "hello",
answer_tokens: int = 5,
created_at: datetime | None = None,
workflow_run_id: str | None = None,
) -> Message:
message = Message(
id=str(uuid4()),
app_id="app-1",
conversation_id=conversation_id,
_inputs={},
query=query,
message={},
message_unit_price=Decimal(0),
answer=answer,
answer_tokens=answer_tokens,
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.API,
workflow_run_id=workflow_run_id,
created_at=created_at or datetime.now(UTC).replace(tzinfo=None),
)
database.session.add(message)
database.session.commit()
return message
def _persist_message_file(
database: Database,
message: Message,
*,
belongs_to: MessageFileBelongsTo | None,
) -> MessageFile:
message_file = MessageFile(
message_id=message.id,
type=FileType.IMAGE,
transfer_method=FileTransferMethod.REMOTE_URL,
created_by_role=CreatorUserRole.ACCOUNT,
created_by="account-1",
belongs_to=belongs_to,
url="https://example.com/image.png",
)
database.session.add(message_file)
database.session.commit()
return message_file
def _persist_workflow(database: Database, *, workflow_id: str) -> Workflow:
workflow = Workflow(
id=workflow_id,
tenant_id="tenant-1",
app_id="app-1",
type=WorkflowType.CHAT,
version="1",
graph="{}",
features="{}",
created_by="account-1",
)
database.session.add(workflow)
database.session.commit()
return workflow
# ===========================================================================
# Tests for __init__ and workflow_run_repo property
# ===========================================================================
@@ -167,25 +61,25 @@ class TestInit:
assert mem.model_instance is mi
assert mem._workflow_run_repo is None
def test_workflow_run_repo_is_created_lazily(self, database: Database):
def test_workflow_run_repo_is_created_lazily(self):
conv = _make_conversation()
mi = _make_model_instance()
mem = TokenBufferMemory(conversation=conv, model_instance=mi)
mock_repo = MagicMock()
with patch(
"core.memory.token_buffer_memory.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
return_value=mock_repo,
) as repository_factory:
with (
patch("core.memory.token_buffer_memory.sessionmaker") as mock_sm,
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
return_value=mock_repo,
),
):
mock_db.engine = MagicMock()
repo = mem.workflow_run_repo
assert repo is mock_repo
assert mem._workflow_run_repo is mock_repo
session_factory = repository_factory.call_args.args[0]
with session_factory() as session:
assert isinstance(session, Session)
assert session.get_bind() is database.engine
def test_workflow_run_repo_cached_after_first_access(self):
conv = _make_conversation()
mi = _make_model_instance()
@@ -516,7 +410,7 @@ class TestBuildPromptMessageWithFiles:
)
@pytest.mark.parametrize("mode", [AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def test_workflow_mode_workflow_not_found_raises(self, mode, database: Database):
def test_workflow_mode_workflow_not_found_raises(self, mode):
"""Raises ValueError when Workflow lookup returns None."""
conv = _make_conversation(mode)
conv.app = MagicMock()
@@ -528,17 +422,22 @@ class TestBuildPromptMessageWithFiles:
mem._workflow_run_repo = MagicMock()
mem._workflow_run_repo.get_workflow_run_by_id.return_value = mock_workflow_run
with pytest.raises(ValueError, match="Workflow not found"):
mem._build_prompt_message_with_files(
message_files=[],
text_content="text",
message=_make_message(),
app_record=MagicMock(),
is_user_message=True,
)
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
):
mock_db.session.scalar.return_value = None # workflow not found
with pytest.raises(ValueError, match="Workflow not found"):
mem._build_prompt_message_with_files(
message_files=[],
text_content="text",
message=_make_message(),
app_record=MagicMock(),
is_user_message=True,
)
@pytest.mark.parametrize("mode", [AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def test_workflow_mode_success_no_files_user(self, mode, database: Database):
def test_workflow_mode_success_no_files_user(self, mode):
"""Happy path: workflow mode, no message files → plain UserPromptMessage."""
conv = _make_conversation(mode)
conv.app = MagicMock()
@@ -546,16 +445,22 @@ class TestBuildPromptMessageWithFiles:
mock_workflow_run = MagicMock()
mock_workflow_run.workflow_id = str(uuid4())
workflow = _persist_workflow(database, workflow_id=mock_workflow_run.workflow_id)
mock_workflow = MagicMock()
mock_workflow.features_dict = {}
mem = TokenBufferMemory(conversation=conv, model_instance=_make_model_instance())
mem._workflow_run_repo = MagicMock()
mem._workflow_run_repo.get_workflow_run_by_id.return_value = mock_workflow_run
with patch(
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
return_value=None,
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
return_value=None,
),
):
mock_db.session.scalar.return_value = mock_workflow
result = mem._build_prompt_message_with_files(
message_files=[],
text_content="wf text",
@@ -566,7 +471,6 @@ class TestBuildPromptMessageWithFiles:
assert isinstance(result, UserPromptMessage)
assert result.content == "wf text"
assert database.session.get(Workflow, workflow.id) is workflow
# ------------------------------------------------------------------
# Invalid mode
@@ -594,140 +498,417 @@ class TestBuildPromptMessageWithFiles:
class TestGetHistoryPromptMessages:
"""Tests for persisted history retrieval, file batching, and pruning."""
"""Tests for get_history_prompt_messages."""
def _make_memory(self, mode: AppMode = AppMode.CHAT) -> TokenBufferMemory:
conv = _make_conversation(mode)
conv.app = MagicMock()
return TokenBufferMemory(conversation=conv, model_instance=_make_model_instance())
def test_returns_empty_when_no_messages(self, database: Database) -> None:
assert self._make_memory().get_history_prompt_messages() == []
def test_skips_newest_message_without_answer(self, database: Database) -> None:
def test_returns_empty_when_no_messages(self):
mem = self._make_memory()
message = _persist_message(database, mem.conversation.id, answer="", answer_tokens=0)
with patch("core.memory.token_buffer_memory.db") as mock_db:
mock_db.session.scalars.return_value.all.return_value = []
result = mem.get_history_prompt_messages()
assert result == []
assert mem.get_history_prompt_messages() == []
assert database.session.get(Message, message.id) is message
def test_message_with_answer_returns_user_and_assistant_prompts(self, database: Database) -> None:
def test_skips_first_message_without_answer(self):
"""The newest message (index 0 after extraction) without answer and tokens==0 is skipped."""
mem = self._make_memory()
_persist_message(database, mem.conversation.id, query="My query", answer="My answer", answer_tokens=10)
result = mem.get_history_prompt_messages()
msg_no_answer = _make_message(answer="", answer_tokens=0)
msg_no_answer.parent_message_id = None # ensures extract_thread_messages returns it
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.extract_thread_messages",
return_value=[msg_no_answer],
),
):
mock_db.session.scalars.return_value.all.side_effect = [
[msg_no_answer], # first call: messages query
[], # second call: user files query (never hit, but safe)
]
result = mem.get_history_prompt_messages()
assert result == []
def test_message_with_answer_not_skipped(self):
"""A message with a non-empty answer is NOT popped."""
mem = self._make_memory()
msg = _make_message(answer="some answer", answer_tokens=10)
msg.parent_message_id = None
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.extract_thread_messages",
return_value=[msg],
),
patch(
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
return_value=None,
),
):
# user files query → empty; assistant files query → empty
mock_db.session.scalars.return_value.all.return_value = []
result = mem.get_history_prompt_messages()
assert len(result) == 2 # one user + one assistant
def test_message_limit_default_is_500(self):
"""When message_limit is None the stmt is limited to 500."""
mem = self._make_memory()
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch("core.memory.token_buffer_memory.select") as mock_select,
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
):
mock_stmt = MagicMock()
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
mock_stmt.limit.return_value = mock_stmt
mock_db.session.scalars.return_value.all.return_value = []
mem.get_history_prompt_messages(message_limit=None)
mock_stmt.limit.assert_called_with(500)
def test_message_limit_clipped_to_500(self):
"""A message_limit > 500 is clamped to 500."""
mem = self._make_memory()
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch("core.memory.token_buffer_memory.select") as mock_select,
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
):
mock_stmt = MagicMock()
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
mock_stmt.limit.return_value = mock_stmt
mock_db.session.scalars.return_value.all.return_value = []
mem.get_history_prompt_messages(message_limit=9999)
mock_stmt.limit.assert_called_with(500)
def test_message_limit_positive_used(self):
"""A positive message_limit < 500 is used as-is."""
mem = self._make_memory()
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch("core.memory.token_buffer_memory.select") as mock_select,
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
):
mock_stmt = MagicMock()
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
mock_stmt.limit.return_value = mock_stmt
mock_db.session.scalars.return_value.all.return_value = []
mem.get_history_prompt_messages(message_limit=10)
mock_stmt.limit.assert_called_with(10)
def test_message_limit_zero_uses_default(self):
"""message_limit=0 triggers the else branch → default 500."""
mem = self._make_memory()
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch("core.memory.token_buffer_memory.select") as mock_select,
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
):
mock_stmt = MagicMock()
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
mock_stmt.limit.return_value = mock_stmt
mock_db.session.scalars.return_value.all.return_value = []
mem.get_history_prompt_messages(message_limit=0)
mock_stmt.limit.assert_called_with(500)
def test_user_files_cause_build_with_files_call(self):
"""When user_files is non-empty _build_prompt_message_with_files is invoked."""
mem = self._make_memory()
msg = _make_message()
msg.parent_message_id = None
mock_user_file = MagicMock()
mock_user_file.message_id = msg.id # must match so batched grouping keys it to this message
mock_user_prompt = UserPromptMessage(content="from build")
mock_assistant_prompt = AssistantPromptMessage(content="answer")
call_count = {"n": 0}
def scalars_side_effect(stmt):
r = MagicMock()
if call_count["n"] == 0:
# messages query
r.all.return_value = [msg]
elif call_count["n"] == 1:
# user files
r.all.return_value = [mock_user_file]
else:
# assistant files
r.all.return_value = []
call_count["n"] += 1
return r
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.extract_thread_messages",
return_value=[msg],
),
patch.object(
mem,
"_build_prompt_message_with_files",
side_effect=[mock_user_prompt, mock_assistant_prompt],
) as mock_build,
patch(
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
return_value=None,
),
):
mock_db.session.scalars.side_effect = scalars_side_effect
result = mem.get_history_prompt_messages()
assert mock_build.call_count >= 1
# First call should be user message
first_call_kwargs = mock_build.call_args_list[0][1]
assert first_call_kwargs["is_user_message"] is True
def test_assistant_files_cause_build_with_files_call(self):
"""When assistant_files is non-empty, build is called with is_user_message=False."""
mem = self._make_memory()
msg = _make_message()
msg.parent_message_id = None
mock_assistant_file = MagicMock()
mock_assistant_file.message_id = msg.id # must match so batched grouping keys it to this message
mock_user_prompt = UserPromptMessage(content="query")
mock_assistant_prompt = AssistantPromptMessage(content="built")
call_count = {"n": 0}
def scalars_side_effect(stmt):
r = MagicMock()
if call_count["n"] == 0:
r.all.return_value = [msg]
elif call_count["n"] == 1:
r.all.return_value = [] # no user files
else:
r.all.return_value = [mock_assistant_file]
call_count["n"] += 1
return r
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.extract_thread_messages",
return_value=[msg],
),
patch.object(
mem,
"_build_prompt_message_with_files",
return_value=mock_assistant_prompt,
) as mock_build,
):
mock_db.session.scalars.side_effect = scalars_side_effect
result = mem.get_history_prompt_messages()
mock_build.assert_called_once()
call_kwargs = mock_build.call_args[1]
assert call_kwargs["is_user_message"] is False
def test_message_files_loaded_with_constant_query_count(self):
"""Regression guard against N+1: message files must be batch-loaded.
Regardless of the number of messages in the thread, file loading must use a
constant number of queries (1 messages query + 2 batched file queries),
never 2 queries per message.
"""
mem = self._make_memory()
messages = [_make_message() for _ in range(5)]
for m in messages:
m.parent_message_id = None
scalars_calls = {"n": 0}
def scalars_side_effect(stmt):
r = MagicMock()
# First call returns the thread messages; the batched file queries return none.
r.all.return_value = messages if scalars_calls["n"] == 0 else []
scalars_calls["n"] += 1
return r
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=messages),
patch("core.memory.token_buffer_memory.FileUploadConfigManager.convert", return_value=None),
):
mock_db.session.scalars.side_effect = scalars_side_effect
mem.get_history_prompt_messages()
# 1 (messages) + 2 (batched user/assistant files) = 3, independent of message count.
# Before this fix it would have been 1 + 2 * 5 = 11 (an N+1 pattern).
assert scalars_calls["n"] == 3
def test_token_pruning_removes_oldest_messages(self):
"""If tokens exceed limit, oldest messages are removed until within limit."""
conv = _make_conversation()
conv.app = MagicMock()
# Model returns tokens that decrease only after removing pairs
token_values = [3000, 1500] # first call over limit, second within
mi = MagicMock()
mi.get_llm_num_tokens.side_effect = token_values
mem = TokenBufferMemory(conversation=conv, model_instance=mi)
msg = _make_message()
msg.parent_message_id = None
call_count = {"n": 0}
def scalars_side_effect(stmt):
r = MagicMock()
if call_count["n"] == 0:
r.all.return_value = [msg]
else:
r.all.return_value = []
call_count["n"] += 1
return r
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.extract_thread_messages",
return_value=[msg],
),
patch(
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
return_value=None,
),
):
mock_db.session.scalars.side_effect = scalars_side_effect
result = mem.get_history_prompt_messages(max_token_limit=2000)
# After pruning, we should have fewer than the 2 initial messages
assert len(result) <= 1
def test_token_pruning_stops_at_single_message(self):
"""Pruning stops when only 1 message remains (to prevent empty list)."""
conv = _make_conversation()
conv.app = MagicMock()
# Always over limit
mi = MagicMock()
mi.get_llm_num_tokens.return_value = 99999
mem = TokenBufferMemory(conversation=conv, model_instance=mi)
msg = _make_message()
msg.parent_message_id = None
call_count = {"n": 0}
def scalars_side_effect(stmt):
r = MagicMock()
if call_count["n"] == 0:
r.all.return_value = [msg]
else:
r.all.return_value = []
call_count["n"] += 1
return r
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.extract_thread_messages",
return_value=[msg],
),
patch(
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
return_value=None,
),
):
mock_db.session.scalars.side_effect = scalars_side_effect
result = mem.get_history_prompt_messages(max_token_limit=1)
# At least 1 message should remain
assert len(result) >= 1
def test_no_pruning_when_within_limit(self):
"""When tokens ≤ limit, no pruning occurs."""
mem = self._make_memory()
mem.model_instance.get_llm_num_tokens.return_value = 50 # well under default 2000
msg = _make_message()
msg.parent_message_id = None
call_count = {"n": 0}
def scalars_side_effect(stmt):
r = MagicMock()
if call_count["n"] == 0:
r.all.return_value = [msg]
else:
r.all.return_value = []
call_count["n"] += 1
return r
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.extract_thread_messages",
return_value=[msg],
),
patch(
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
return_value=None,
),
):
mock_db.session.scalars.side_effect = scalars_side_effect
result = mem.get_history_prompt_messages(max_token_limit=2000)
assert len(result) == 2 # user + assistant
def test_plain_user_and_assistant_messages_returned(self):
"""Without files, plain UserPromptMessage and AssistantPromptMessage appear."""
mem = self._make_memory()
msg = _make_message(answer="My answer")
msg.query = "My query"
msg.parent_message_id = None
call_count = {"n": 0}
def scalars_side_effect(stmt):
r = MagicMock()
if call_count["n"] == 0:
r.all.return_value = [msg]
else:
r.all.return_value = []
call_count["n"] += 1
return r
with (
patch("core.memory.token_buffer_memory.db") as mock_db,
patch(
"core.memory.token_buffer_memory.extract_thread_messages",
return_value=[msg],
),
patch(
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
return_value=None,
),
):
mock_db.session.scalars.side_effect = scalars_side_effect
result = mem.get_history_prompt_messages()
assert len(result) == 2
assert isinstance(result[0], UserPromptMessage)
assert result[0].content == "My query"
assert isinstance(result[1], AssistantPromptMessage)
assert result[1].content == "My answer"
def test_history_is_conversation_scoped(self, database: Database) -> None:
mem = self._make_memory()
_persist_message(database, mem.conversation.id, answer="visible")
_persist_message(database, "other-conversation", answer="hidden")
result = mem.get_history_prompt_messages()
assert [prompt.content for prompt in result] == ["user query", "visible"]
@pytest.mark.parametrize(
("message_limit", "expected_limit"),
[(None, 500), (9999, 500), (10, 10), (0, 500)],
)
def test_message_limit_is_applied_to_executable_query(
self,
database: Database,
message_limit: int | None,
expected_limit: int,
) -> None:
mem = self._make_memory()
before = len(database.statements)
mem.get_history_prompt_messages(message_limit=message_limit)
statements = database.statements[before:]
assert len(statements) == 1
sql, parameters = statements[0]
assert "LIMIT" in sql
assert expected_limit in parameters
@pytest.mark.parametrize(
("belongs_to", "is_user_message"),
[
(MessageFileBelongsTo.USER, True),
(None, True),
(MessageFileBelongsTo.ASSISTANT, False),
],
)
def test_message_files_use_persisted_ownership(
self,
database: Database,
belongs_to: MessageFileBelongsTo | None,
is_user_message: bool,
) -> None:
mem = self._make_memory()
message = _persist_message(database, mem.conversation.id)
message_file = _persist_message_file(database, message, belongs_to=belongs_to)
built_prompt = (
UserPromptMessage(content="built user")
if is_user_message
else AssistantPromptMessage(content="built assistant")
)
with patch.object(mem, "_build_prompt_message_with_files", return_value=built_prompt) as build_prompt:
result = mem.get_history_prompt_messages()
build_prompt.assert_called_once()
assert build_prompt.call_args.kwargs["message_files"] == [message_file]
assert build_prompt.call_args.kwargs["is_user_message"] is is_user_message
assert built_prompt in result
def test_message_files_are_batch_loaded_with_constant_query_count(self, database: Database) -> None:
mem = self._make_memory()
base_time = datetime.now(UTC).replace(tzinfo=None)
messages = [
_persist_message(
database,
mem.conversation.id,
query=f"query-{index}",
answer=f"answer-{index}",
created_at=base_time + timedelta(seconds=index),
)
for index in range(5)
]
before = len(database.statements)
with patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=messages):
result = mem.get_history_prompt_messages()
selects = [sql for sql, _ in database.statements[before:] if sql.lstrip().upper().startswith("SELECT")]
assert len(selects) == 3
assert len(result) == 10
@pytest.mark.parametrize(
("token_values", "max_token_limit", "expected_length"),
[
([3000, 1500], 2000, 1),
([99999, 99999], 1, 1),
([50], 2000, 2),
],
)
def test_token_pruning_uses_persisted_history(
self,
database: Database,
token_values: list[int],
max_token_limit: int,
expected_length: int,
) -> None:
mem = self._make_memory()
mem.model_instance.get_llm_num_tokens.side_effect = token_values
_persist_message(database, mem.conversation.id)
result = mem.get_history_prompt_messages(max_token_limit=max_token_limit)
assert len(result) == expected_length
user_msg, ai_msg = result
assert isinstance(user_msg, UserPromptMessage)
assert user_msg.content == "My query"
assert isinstance(ai_msg, AssistantPromptMessage)
assert ai_msg.content == "My answer"
# ===========================================================================
@@ -2,24 +2,13 @@ from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from sqlalchemy.orm import Session
import core.moderation.api.api as moderation_module
from core.extension.api_based_extension_requestor import APIBasedExtensionPoint
from core.moderation.api.api import ApiModeration, ModerationInputParams, ModerationOutputParams
from core.moderation.base import ModerationAction, ModerationInputsResult, ModerationOutputsResult
from models.api_based_extension import APIBasedExtension
class _DatabaseBinding:
"""Expose the real SQLite session used by extension lookup."""
session: Session
def __init__(self, session: Session) -> None:
self.session = session
class TestApiModeration:
@pytest.fixture
def api_config(self):
@@ -176,27 +165,17 @@ class TestApiModeration:
with pytest.raises(ValueError, match="API-based Extension not found"):
api_moderation._get_config_by_requestor(APIBasedExtensionPoint.APP_MODERATION_INPUT, {})
@pytest.mark.parametrize("sqlite_session", [(APIBasedExtension,)], indirect=True)
def test_get_api_based_extension(self, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> None:
target = APIBasedExtension(
tenant_id="tenant-1",
name="Target extension",
api_endpoint="https://example.com/moderate",
api_key="encrypted-key",
)
target.id = "ext-1"
other_tenant = APIBasedExtension(
tenant_id="tenant-2",
name="Other extension",
api_endpoint="https://example.com/other",
api_key="other-key",
)
other_tenant.id = "ext-2"
sqlite_session.add_all((target, other_tenant))
sqlite_session.commit()
monkeypatch.setattr(moderation_module, "db", _DatabaseBinding(sqlite_session))
@patch("core.moderation.api.api.db.session.scalar")
def test_get_api_based_extension(self, mock_scalar):
mock_ext = MagicMock(spec=APIBasedExtension)
mock_scalar.return_value = mock_ext
result = ApiModeration._get_api_based_extension("tenant-1", "ext-1")
assert result is target
assert ApiModeration._get_api_based_extension("tenant-1", "ext-2") is None
assert result == mock_ext
mock_scalar.assert_called_once()
# Verify the call has the correct filters
args, kwargs = mock_scalar.call_args
stmt = args[0]
# We can't easily inspect the statement without complex sqlalchemy tricks,
# but calling it is usually enough for unit tests if we mock the result.
@@ -7,212 +7,36 @@ Covers:
- TraceTask._get_user_id_from_metadata
"""
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from unittest.mock import PropertyMock, patch
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import Engine, event
from sqlalchemy.orm import Session
from core.tools.entities.tool_entities import ApiProviderSchemaType
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from models.account import Tenant
from models.base import TypeBase
from models.model import App, AppMode, IconType
from models.provider import Provider, ProviderCredential, ProviderModel, ProviderModelCredential, ProviderType
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def orm_session(sqlite_engine: Engine) -> Iterator[Session]:
models = (
App,
Tenant,
Provider,
ProviderCredential,
ProviderModel,
ProviderModelCredential,
BuiltinToolProvider,
ApiToolProvider,
WorkflowToolProvider,
MCPToolProvider,
)
tables = [model.metadata.tables[model.__tablename__] for model in models]
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
def _make_db_and_session_patches(scalar_side_effect=None, scalar_return_value=None):
"""Return (mock_db, cm, session) ready to patch 'core.ops.ops_trace_manager.db'
and 'core.ops.ops_trace_manager.Session'.
with patch.object(type(db), "engine", new_callable=PropertyMock, return_value=sqlite_engine):
with Session(sqlite_engine, expire_on_commit=False) as session:
yield session
Provide either scalar_side_effect (list, for multiple calls) or
scalar_return_value (single value).
"""
mock_db = MagicMock()
mock_db.engine = MagicMock()
def _persist_app(session: Session, *, tenant_id: str, name: str = "MyApp") -> App:
app = App(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
name=name,
mode=AppMode.WORKFLOW,
icon_type=IconType.EMOJI,
icon="workflow",
icon_background="#FFFFFF",
enable_site=True,
enable_api=False,
)
session.add(app)
session.commit()
return app
def _persist_tenant(session: Session, *, name: str = "MyWorkspace") -> Tenant:
tenant = Tenant(name=name)
session.add(tenant)
session.commit()
return tenant
def _persist_tool_provider(
session: Session, provider_type: str
) -> BuiltinToolProvider | ApiToolProvider | WorkflowToolProvider | MCPToolProvider:
tenant_id = str(uuid.uuid4())
user_id = str(uuid.uuid4())
if provider_type in {"builtin", "plugin"}:
provider = BuiltinToolProvider(
name="CredentialA",
tenant_id=tenant_id,
user_id=user_id,
provider="test/provider",
)
elif provider_type == "api":
provider = ApiToolProvider(
name="CredentialA",
icon="icon.svg",
schema="{}",
schema_type_str=ApiProviderSchemaType.OPENAPI,
user_id=user_id,
tenant_id=tenant_id,
description="API provider",
tools_str="[]",
credentials_str="{}",
)
elif provider_type == "workflow":
provider = WorkflowToolProvider(
name="CredentialA",
label="CredentialA",
icon="icon.svg",
app_id=str(uuid.uuid4()),
version="1",
user_id=user_id,
tenant_id=tenant_id,
description="Workflow provider",
)
elif provider_type == "mcp":
provider = MCPToolProvider(
name="CredentialA",
server_identifier="credential-a",
server_url="https://example.com/mcp",
server_url_hash="credential-a-hash",
icon="icon.svg",
tenant_id=tenant_id,
user_id=user_id,
)
session = MagicMock()
if scalar_side_effect is not None:
session.scalar.side_effect = scalar_side_effect
else:
raise ValueError(f"unsupported provider type: {provider_type}")
session.scalar.return_value = scalar_return_value
session.add(provider)
session.commit()
return provider
cm = MagicMock()
cm.__enter__ = MagicMock(return_value=session)
cm.__exit__ = MagicMock(return_value=False)
def _persist_provider_credential(
session: Session,
*,
tenant_id: str,
credential_name: str = "ProvCredName",
) -> ProviderCredential:
credential = ProviderCredential(
tenant_id=tenant_id,
provider_name="openai",
credential_name=credential_name,
encrypted_config="{}",
)
session.add(credential)
session.commit()
return credential
def _persist_model_credential(
session: Session,
*,
tenant_id: str,
credential_name: str = "ModelCredName",
) -> ProviderModelCredential:
credential = ProviderModelCredential(
tenant_id=tenant_id,
provider_name="openai",
model_name="gpt-4",
model_type=ModelType.LLM,
credential_name=credential_name,
encrypted_config="{}",
)
session.add(credential)
session.commit()
return credential
def _persist_provider(
session: Session,
*,
tenant_id: str,
credential_id: str | None,
) -> Provider:
provider = Provider(
tenant_id=tenant_id,
provider_name="openai",
provider_type=ProviderType.CUSTOM,
credential_id=credential_id,
)
session.add(provider)
session.commit()
return provider
def _persist_provider_model(
session: Session,
*,
tenant_id: str,
credential_id: str | None,
) -> ProviderModel:
model = ProviderModel(
tenant_id=tenant_id,
provider_name="openai",
model_name="gpt-4",
model_type=ModelType.LLM,
credential_id=credential_id,
)
session.add(model)
session.commit()
return model
@contextmanager
def _raise_on_table(engine: Engine, table_name: str) -> Iterator[None]:
"""Raise only when SQL targets the named table, leaving other real lookups intact."""
def fail_target_query(_conn, _cursor, statement, _parameters, _context, _executemany):
if f"FROM {table_name}" in statement:
raise RuntimeError(f"forced failure for {table_name}")
event.listen(engine, "before_cursor_execute", fail_target_query)
try:
yield
finally:
event.remove(engine, "before_cursor_execute", fail_target_query)
return mock_db, cm, session
# ---------------------------------------------------------------------------
@@ -223,42 +47,62 @@ def _raise_on_table(engine: Engine, table_name: str) -> Iterator[None]:
class TestLookupAppAndWorkspaceNames:
"""Tests for _lookup_app_and_workspace_names(app_id, tenant_id)."""
def test_both_found(self, orm_session: Session):
def test_both_found(self):
"""Returns (app_name, workspace_name) when both records exist."""
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
tenant = _persist_tenant(orm_session)
app = _persist_app(orm_session, tenant_id=tenant.id)
app_name, workspace_name = _lookup_app_and_workspace_names(app.id, tenant.id)
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=["MyApp", "MyWorkspace"])
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
assert app_name == "MyApp"
assert workspace_name == "MyWorkspace"
def test_app_only_found(self, orm_session: Session):
def test_app_only_found(self):
"""Returns (app_name, '') when tenant record is absent."""
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
app = _persist_app(orm_session, tenant_id=str(uuid.uuid4()))
app_name, workspace_name = _lookup_app_and_workspace_names(app.id, str(uuid.uuid4()))
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=["MyApp", None])
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
assert app_name == "MyApp"
assert workspace_name == ""
def test_tenant_only_found(self, orm_session: Session):
def test_tenant_only_found(self):
"""Returns ('', workspace_name) when app record is absent."""
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
tenant = _persist_tenant(orm_session)
app_name, workspace_name = _lookup_app_and_workspace_names(str(uuid.uuid4()), tenant.id)
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[None, "MyWorkspace"])
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
assert app_name == ""
assert workspace_name == "MyWorkspace"
def test_neither_found(self, orm_session: Session):
def test_neither_found(self):
"""Returns ('', '') when both DB lookups return None."""
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
app_name, workspace_name = _lookup_app_and_workspace_names(str(uuid.uuid4()), str(uuid.uuid4()))
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[None, None])
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
assert app_name == ""
assert workspace_name == ""
@@ -267,30 +111,50 @@ class TestLookupAppAndWorkspaceNames:
"""Returns ('', '') immediately when both IDs are None — no DB access."""
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
app_name, workspace_name = _lookup_app_and_workspace_names(None, None)
mock_db = MagicMock()
mock_session_cls = MagicMock()
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
):
app_name, workspace_name = _lookup_app_and_workspace_names(None, None)
mock_session_cls.assert_not_called()
assert app_name == ""
assert workspace_name == ""
def test_app_id_none_only_queries_tenant(self, orm_session: Session):
def test_app_id_none_only_queries_tenant(self):
"""When app_id is None, only the tenant query is issued."""
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
tenant = _persist_tenant(orm_session, name="OnlyWorkspace")
app_name, workspace_name = _lookup_app_and_workspace_names(None, tenant.id)
mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="OnlyWorkspace")
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
app_name, workspace_name = _lookup_app_and_workspace_names(None, "tenant-456")
assert app_name == ""
assert workspace_name == "OnlyWorkspace"
assert session.scalar.call_count == 1
def test_tenant_id_none_only_queries_app(self, orm_session: Session):
def test_tenant_id_none_only_queries_app(self):
"""When tenant_id is None, only the app query is issued."""
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
app = _persist_app(orm_session, tenant_id=str(uuid.uuid4()), name="OnlyApp")
app_name, workspace_name = _lookup_app_and_workspace_names(app.id, None)
mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="OnlyApp")
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", None)
assert app_name == "OnlyApp"
assert workspace_name == ""
assert session.scalar.call_count == 1
# ---------------------------------------------------------------------------
@@ -302,20 +166,32 @@ class TestLookupCredentialName:
"""Tests for _lookup_credential_name(credential_id, provider_type)."""
@pytest.mark.parametrize("provider_type", ["builtin", "plugin", "api", "workflow", "mcp"])
def test_known_provider_types_return_name(self, provider_type: str, orm_session: Session):
def test_known_provider_types_return_name(self, provider_type):
"""Each valid provider_type results in a DB query and returns the credential name."""
from core.ops.ops_trace_manager import _lookup_credential_name
provider = _persist_tool_provider(orm_session, provider_type)
result = _lookup_credential_name(provider.id, provider_type)
mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="CredentialA")
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
result = _lookup_credential_name("cred-123", provider_type)
assert result == "CredentialA"
session.scalar.assert_called_once()
def test_credential_not_found_returns_empty_string(self, orm_session: Session):
def test_credential_not_found_returns_empty_string(self):
"""Returns '' when DB yields None for the given credential_id."""
from core.ops.ops_trace_manager import _lookup_credential_name
result = _lookup_credential_name(str(uuid.uuid4()), "api")
mock_db, cm, _session = _make_db_and_session_patches(scalar_return_value=None)
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
result = _lookup_credential_name("cred-999", "api")
assert result == ""
@@ -323,24 +199,48 @@ class TestLookupCredentialName:
"""Returns '' immediately for an unrecognised provider_type — no DB access."""
from core.ops.ops_trace_manager import _lookup_credential_name
result = _lookup_credential_name(str(uuid.uuid4()), "unknown_type")
mock_db = MagicMock()
mock_session_cls = MagicMock()
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
):
result = _lookup_credential_name("cred-123", "unknown_type")
mock_session_cls.assert_not_called()
assert result == ""
def test_none_credential_id_returns_empty_string_without_db(self):
"""Returns '' immediately when credential_id is None — no DB access."""
from core.ops.ops_trace_manager import _lookup_credential_name
result = _lookup_credential_name(None, "api")
mock_db = MagicMock()
mock_session_cls = MagicMock()
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
):
result = _lookup_credential_name(None, "api")
mock_session_cls.assert_not_called()
assert result == ""
def test_none_provider_type_returns_empty_string_without_db(self):
"""Returns '' immediately when provider_type is None — no DB access."""
from core.ops.ops_trace_manager import _lookup_credential_name
result = _lookup_credential_name(str(uuid.uuid4()), None)
mock_db = MagicMock()
mock_session_cls = MagicMock()
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
):
result = _lookup_credential_name("cred-123", None)
mock_session_cls.assert_not_called()
assert result == ""
def test_builtin_and_plugin_map_to_same_model(self):
@@ -381,78 +281,106 @@ class TestLookupCredentialName:
class TestLookupLlmCredentialInfo:
"""Tests for _lookup_llm_credential_info(tenant_id, provider, model, model_type)."""
def test_model_level_credential_found(self, orm_session: Session):
def _provider_record(self, credential_id: str | None = None) -> MagicMock:
record = MagicMock()
record.credential_id = credential_id
return record
def _model_record(self, credential_id: str | None = None) -> MagicMock:
record = MagicMock()
record.credential_id = credential_id
return record
def test_model_level_credential_found(self):
"""Returns model-level credential_id and name when ProviderModel has a credential."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
tenant_id = str(uuid.uuid4())
model_credential = _persist_model_credential(orm_session, tenant_id=tenant_id)
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=None)
_persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=model_credential.id)
provider_record = self._provider_record(credential_id=None)
model_record = self._model_record(credential_id="model-cred-id")
decoy_tenant_id = str(uuid.uuid4())
decoy_credential = _persist_model_credential(
orm_session,
tenant_id=decoy_tenant_id,
credential_name="WrongTenantCredential",
# scalar calls: (1) Provider, (2) ProviderModel, (3) ProviderModelCredential.credential_name
mock_db, cm, _session = _make_db_and_session_patches(
scalar_side_effect=[provider_record, model_record, "ModelCredName"]
)
_persist_provider(orm_session, tenant_id=decoy_tenant_id, credential_id=None)
_persist_provider_model(orm_session, tenant_id=decoy_tenant_id, credential_id=decoy_credential.id)
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
assert cred_id == model_credential.id
assert cred_id == "model-cred-id"
assert cred_name == "ModelCredName"
def test_provider_level_fallback_when_no_model_credential(self, orm_session: Session):
def test_provider_level_fallback_when_no_model_credential(self):
"""Falls back to provider-level credential when ProviderModel has no credential_id."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
tenant_id = str(uuid.uuid4())
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
_persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=None)
provider_record = self._provider_record(credential_id="prov-cred-id")
model_record = self._model_record(credential_id=None)
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
# scalar calls: (1) Provider, (2) ProviderModel (no cred), (3) ProviderCredential.credential_name
mock_db, cm, _session = _make_db_and_session_patches(
scalar_side_effect=[provider_record, model_record, "ProvCredName"]
)
assert cred_id == provider_credential.id
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
assert cred_id == "prov-cred-id"
assert cred_name == "ProvCredName"
def test_provider_level_fallback_when_no_model_record(self, orm_session: Session):
def test_provider_level_fallback_when_no_model_record(self):
"""Falls back to provider-level credential when no ProviderModel row exists."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
tenant_id = str(uuid.uuid4())
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
provider_record = self._provider_record(credential_id="prov-cred-id")
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
# scalar calls: (1) Provider, (2) ProviderModel → None, (3) ProviderCredential.credential_name
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[provider_record, None, "ProvCredName"])
assert cred_id == provider_credential.id
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
assert cred_id == "prov-cred-id"
assert cred_name == "ProvCredName"
def test_no_model_arg_uses_provider_level_only(self, orm_session: Session):
def test_no_model_arg_uses_provider_level_only(self):
"""When model is None, skips ProviderModel query and uses provider credential."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
tenant_id = str(uuid.uuid4())
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
provider_record = self._provider_record(credential_id="prov-cred-id")
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", None)
# scalar calls: (1) Provider, (2) ProviderCredential.credential_name — no ProviderModel
mock_db, cm, session = _make_db_and_session_patches(scalar_side_effect=[provider_record, "ProvCredName"])
assert cred_id == provider_credential.id
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", None)
assert cred_id == "prov-cred-id"
assert cred_name == "ProvCredName"
assert session.scalar.call_count == 2
def test_provider_not_found_returns_none_and_empty(self, orm_session: Session):
def test_provider_not_found_returns_none_and_empty(self):
"""Returns (None, '') when Provider record does not exist."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
other_tenant_id = str(uuid.uuid4())
_persist_provider(orm_session, tenant_id=other_tenant_id, credential_id=None)
tenant_id = str(uuid.uuid4())
mock_db, cm, _session = _make_db_and_session_patches(scalar_return_value=None)
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
assert cred_id is None
assert cred_name == ""
@@ -461,8 +389,16 @@ class TestLookupLlmCredentialInfo:
"""Returns (None, '') immediately when tenant_id is None — no DB access."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
cred_id, cred_name = _lookup_llm_credential_info(None, "openai", "gpt-4")
mock_db = MagicMock()
mock_session_cls = MagicMock()
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
):
cred_id, cred_name = _lookup_llm_credential_info(None, "openai", "gpt-4")
mock_session_cls.assert_not_called()
assert cred_id is None
assert cred_name == ""
@@ -470,46 +406,69 @@ class TestLookupLlmCredentialInfo:
"""Returns (None, '') immediately when provider is None — no DB access."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
cred_id, cred_name = _lookup_llm_credential_info(str(uuid.uuid4()), None, "gpt-4")
mock_db = MagicMock()
mock_session_cls = MagicMock()
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
):
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", None, "gpt-4")
mock_session_cls.assert_not_called()
assert cred_id is None
assert cred_name == ""
def test_db_error_on_outer_query_returns_none_and_empty(self, orm_session: Session, sqlite_engine: Engine):
def test_db_error_on_outer_query_returns_none_and_empty(self):
"""Returns (None, '') and logs a warning when the outer DB query raises."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
with _raise_on_table(sqlite_engine, "providers"):
cred_id, cred_name = _lookup_llm_credential_info(str(uuid.uuid4()), "openai", "gpt-4")
mock_db, cm, session = _make_db_and_session_patches()
session.scalar.side_effect = Exception("DB connection failed")
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
assert cred_id is None
assert cred_name == ""
def test_credential_name_lookup_failure_returns_id_with_empty_name(
self, orm_session: Session, sqlite_engine: Engine
):
def test_credential_name_lookup_failure_returns_id_with_empty_name(self):
"""When credential name sub-query fails, returns cred_id but '' for name."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
tenant_id = str(uuid.uuid4())
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
provider_record = self._provider_record(credential_id="prov-cred-id")
with _raise_on_table(sqlite_engine, "provider_credentials"):
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
# Provider found, no model record, then name lookup raises
mock_db, cm, _session = _make_db_and_session_patches(
scalar_side_effect=[provider_record, None, Exception("deleted")]
)
assert cred_id == provider_credential.id
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
assert cred_id == "prov-cred-id"
assert cred_name == ""
def test_no_credential_on_provider_or_model_returns_none_id(self, orm_session: Session):
def test_no_credential_on_provider_or_model_returns_none_id(self):
"""Returns (None, '') when neither provider nor model has a credential_id."""
from core.ops.ops_trace_manager import _lookup_llm_credential_info
tenant_id = str(uuid.uuid4())
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=None)
_persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=None)
provider_record = self._provider_record(credential_id=None)
model_record = self._model_record(credential_id=None)
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[provider_record, model_record])
with (
patch("core.ops.ops_trace_manager.db", mock_db),
patch("core.ops.ops_trace_manager.Session", return_value=cm),
):
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
assert cred_id is None
assert cred_name == ""
+11 -47
View File
@@ -1,11 +1,9 @@
import re
from datetime import datetime
from decimal import Decimal
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.orm import Session
import core.ops.utils as utils_module
from core.ops.utils import (
filter_none_values,
generate_dotted_order,
@@ -17,42 +15,6 @@ from core.ops.utils import (
validate_url,
validate_url_with_path,
)
from models.enums import ConversationFromSource
from models.model import Message
class _DatabaseBinding:
"""Expose the real SQLite session used by the message lookup helper."""
session: Session
def __init__(self, session: Session) -> None:
self.session = session
@pytest.fixture
def message_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
"""Bind the message lookup helper to the shared SQLite test session."""
monkeypatch.setattr(utils_module, "db", _DatabaseBinding(sqlite_session))
return sqlite_session
def _message(message_id: str) -> Message:
message = Message(
id=message_id,
app_id="app-id",
conversation_id="conversation-id",
query="question",
message={"role": "user", "content": "question"},
answer="answer",
message_unit_price=Decimal("0.0001"),
answer_unit_price=Decimal("0.0001"),
currency="USD",
from_source=ConversationFromSource.API,
)
message._inputs = {}
return message
class TestValidateUrl:
@@ -258,20 +220,22 @@ class TestFilterNoneValues:
assert filter_none_values({}) == {}
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
class TestGetMessageData:
"""Test cases for get_message_data function"""
def test_get_message_data(self, message_session: Session):
target = _message("message-id")
unrelated = _message("other-message-id")
message_session.add_all((target, unrelated))
message_session.commit()
@patch("core.ops.utils.db")
@patch("core.ops.utils.Message")
@patch("core.ops.utils.select")
def test_get_message_data(self, mock_select, mock_message, mock_db):
mock_scalar = mock_db.session.scalar
mock_msg_instance = MagicMock()
mock_scalar.return_value = mock_msg_instance
result = get_message_data("message-id")
assert result is target
assert result.id == "message-id"
assert result == mock_msg_instance
mock_select.assert_called_once()
mock_scalar.assert_called_once()
class TestMeasureTime:
@@ -28,6 +28,19 @@ class TestPluginModelClient:
)
assert request_mock.call_args.kwargs["params"] == {"page": 1, "page_size": 256}
def test_fetch_model_provider_bindings(self, mocker: MockerFixture):
client = PluginModelClient()
request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=["binding-a"])
result = client.fetch_model_provider_bindings("tenant-1")
assert result == ["binding-a"]
assert request_mock.call_args.args[:2] == (
"GET",
"plugin/tenant-1/management/models/bindings",
)
assert "params" not in request_mock.call_args.kwargs
def test_get_model_schema(self, mocker: MockerFixture):
client = PluginModelClient()
schema = SimpleNamespace(name="schema")
@@ -29,6 +29,7 @@ from core.plugin.entities.plugin import (
)
from core.plugin.entities.plugin_daemon import (
PluginDecodeResponse,
PluginInstalledIdsDaemonResponse,
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginInstallTaskStatus,
@@ -132,7 +133,13 @@ class TestPluginDiscovery:
plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response
) as mock_request:
result = plugin_installer.list_plugins_by_category(
"test-tenant", category=PluginCategory.Tool, page=2, page_size=10
"test-tenant",
category=PluginCategory.Tool,
page=2,
page_size=10,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
mock_request.assert_called_once()
@@ -141,6 +148,9 @@ class TestPluginDiscovery:
assert call_args.args[2] is PluginListWithoutTotalResponse
assert call_args.kwargs["params"]["page"] == 2
assert call_args.kwargs["params"]["page_size"] == 10
assert call_args.kwargs["params"]["query"] == "weather"
assert call_args.kwargs["params"]["tags"] == ["search", "rag"]
assert call_args.kwargs["params"]["language"] == "zh_Hans"
assert result.list == [mock_plugin_entity]
assert result.has_more is True
@@ -156,6 +166,23 @@ class TestPluginDiscovery:
# Assert: Verify empty list is returned
assert len(result) == 0
def test_list_installed_plugin_ids(self, plugin_installer):
"""The lightweight ID endpoint is unpaginated and does not request plugin details."""
mock_response = PluginInstalledIdsDaemonResponse(plugin_ids=["langgenius/openai", "langgenius/anthropic"])
with patch.object(
plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response
) as mock_request:
result = plugin_installer.list_installed_plugin_ids("test-tenant", PluginCategory.Tool)
mock_request.assert_called_once_with(
"GET",
"plugin/test-tenant/management/installation/ids",
PluginInstalledIdsDaemonResponse,
params={"category": "tool"},
)
assert result == ["langgenius/openai", "langgenius/anthropic"]
def test_fetch_plugin_by_identifier_found(self, plugin_installer):
"""Test fetching a plugin by its unique identifier when it exists."""
# Arrange: Mock successful fetch
@@ -1,42 +1,9 @@
from datetime import datetime, timedelta
from decimal import Decimal
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from constants import UUID_NIL
from core.prompt.utils.extract_thread_messages import extract_thread_messages
from core.prompt.utils.get_thread_messages_length import get_thread_messages_length
from models.enums import ConversationFromSource
from models.model import Message
def _persisted_message(
*,
message_id: str,
conversation_id: str,
parent_message_id: str,
answer: str,
created_at: datetime,
) -> Message:
message = Message(
id=message_id,
app_id="app-id",
conversation_id=conversation_id,
query="question",
message={"role": "user", "content": "question"},
answer=answer,
message_unit_price=Decimal("0.0001"),
answer_unit_price=Decimal("0.0001"),
currency="USD",
from_source=ConversationFromSource.API,
parent_message_id=parent_message_id,
created_at=created_at,
updated_at=created_at,
)
message._inputs = {}
return message
class MockMessage:
@@ -137,64 +104,33 @@ def test_extract_thread_messages_breaks_when_parent_is_none():
assert result[0].id == id2
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
def test_get_thread_messages_length_excludes_newly_created_empty_answer(sqlite_session: Session):
def test_get_thread_messages_length_excludes_newly_created_empty_answer():
id1, id2 = str(uuid4()), str(uuid4())
now = datetime.now()
messages = [
_persisted_message(
message_id=id2,
conversation_id="conversation-1",
parent_message_id=id1,
answer="",
created_at=now,
),
_persisted_message(
message_id=id1,
conversation_id="conversation-1",
parent_message_id=UUID_NIL,
answer="ok",
created_at=now - timedelta(seconds=1),
),
_persisted_message(
message_id=str(uuid4()),
conversation_id="other-conversation",
parent_message_id=UUID_NIL,
answer="unrelated",
created_at=now + timedelta(seconds=1),
),
MockMessage(id2, id1, answer=""), # newest generated message should be excluded
MockMessage(id1, UUID_NIL, answer="ok"),
]
sqlite_session.add_all(messages)
sqlite_session.commit()
length = get_thread_messages_length("conversation-1", session=sqlite_session)
session = MagicMock()
session.scalars.return_value.all.return_value = messages
length = get_thread_messages_length("conversation-1", session=session)
assert length == 1
session.scalars.assert_called_once()
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
def test_get_thread_messages_length_keeps_non_empty_latest_answer(sqlite_session: Session):
def test_get_thread_messages_length_keeps_non_empty_latest_answer():
id1, id2 = str(uuid4()), str(uuid4())
now = datetime.now()
messages = [
_persisted_message(
message_id=id2,
conversation_id="conversation-2",
parent_message_id=id1,
answer="latest-answer",
created_at=now,
),
_persisted_message(
message_id=id1,
conversation_id="conversation-2",
parent_message_id=UUID_NIL,
answer="older-answer",
created_at=now - timedelta(seconds=1),
),
MockMessage(id2, id1, answer="latest-answer"),
MockMessage(id1, UUID_NIL, answer="older-answer"),
]
sqlite_session.add_all(messages)
sqlite_session.commit()
length = get_thread_messages_length("conversation-2", session=sqlite_session)
session = MagicMock()
session.scalars.return_value.all.return_value = messages
length = get_thread_messages_length("conversation-2", session=session)
assert length == 2
session.scalars.assert_called_once()
@@ -2,21 +2,9 @@ from types import SimpleNamespace
import pandas as pd
import pytest
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
import core.rag.extractor.excel_extractor as excel_module
from core.rag.extractor.excel_extractor import ExcelExtractor
from models.base import TypeBase
from models.model import UploadFile
@pytest.fixture
def database_session_maker(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> sessionmaker[Session]:
TypeBase.metadata.create_all(sqlite_engine, tables=[UploadFile.__table__])
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(excel_module.session_factory, "create_session", session_maker)
return session_maker
class _FakeCell:
@@ -70,22 +58,82 @@ class _FakeImage:
return self._raw_data
class _FieldExpression:
def __eq__(self, other):
return ("eq", other)
def in_(self, values):
return ("in", tuple(values))
class _SelectStub:
def where(self, *args, **kwargs):
return self
class _FakeUploadFile:
tenant_id = _FieldExpression()
key = _FieldExpression()
_i = 0
def __init__(self, **kwargs):
type(self)._i += 1
self.id = f"u{self._i}"
self.key = kwargs["key"]
class _PersistentSession:
def __init__(self, persisted):
self._persisted = persisted
self.added = []
self.commit_count = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def scalars(self, _stmt):
return SimpleNamespace(all=lambda: list(self._persisted.values()))
def add_all(self, objects) -> None:
self.added.extend(objects)
def commit(self) -> None:
self.commit_count += 1
for upload_file in self.added:
self._persisted[upload_file.key] = upload_file
self.added.clear()
class _PersistentSessionFactory:
def __init__(self):
self.persisted = {}
self.sessions = []
def create_session(self):
session = _PersistentSession(self.persisted)
self.sessions.append(session)
return session
def _patch_image_persistence(monkeypatch: pytest.MonkeyPatch):
saves: list[tuple[str, bytes]] = []
session_factory = _PersistentSessionFactory()
def save(key: str, data: bytes) -> None:
saves.append((key, data))
monkeypatch.setattr(excel_module.storage, "save", save)
_FakeUploadFile._i = 0
monkeypatch.setattr(excel_module, "storage", SimpleNamespace(save=save))
monkeypatch.setattr(excel_module, "session_factory", session_factory)
monkeypatch.setattr(excel_module, "select", lambda *args, **kwargs: _SelectStub())
monkeypatch.setattr(excel_module, "UploadFile", _FakeUploadFile)
monkeypatch.setattr(excel_module.dify_config, "FILES_URL", "http://files.local", raising=False)
monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False)
return saves
def _get_upload_files(session_maker: sessionmaker[Session]) -> list[UploadFile]:
with session_maker() as session:
return list(session.scalars(select(UploadFile)).all())
return saves, session_factory
class TestExcelExtractor:
@@ -112,11 +160,7 @@ class TestExcelExtractor:
assert docs[1].page_content == '"Name":"";"Link":"123"'
assert all(doc.metadata["source"] == "/tmp/sample.xlsx" for doc in docs)
def test_extract_xlsx_turns_embedded_images_into_markdown_links(
self,
monkeypatch: pytest.MonkeyPatch,
database_session_maker: sessionmaker[Session],
):
def test_extract_xlsx_turns_embedded_images_into_markdown_links(self, monkeypatch: pytest.MonkeyPatch):
image_bytes = b"\x89PNG\r\n\x1a\nexcel-image"
sheet = _FakeSheet(
header_rows=[("Question", "Answer", "Image")],
@@ -131,7 +175,7 @@ class TestExcelExtractor:
)
workbook = _FakeWorkbook({"Data": sheet})
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
saves = _patch_image_persistence(monkeypatch)
saves, session_factory = _patch_image_persistence(monkeypatch)
extractor = ExcelExtractor(
"/tmp/sample.xlsx",
@@ -140,30 +184,23 @@ class TestExcelExtractor:
source_file_id="source-file-1",
)
docs = extractor.extract()
upload_files = _get_upload_files(database_session_maker)
assert workbook.closed is True
assert len(docs) == 2
assert len(upload_files) == 1
assert docs[0].page_content == (
'"Question":"Q1";"Answer":"A1";'
f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview) '
f'![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
'"Image":"![image](http://files.local/files/u1/file-preview) '
'![image](http://files.local/files/u1/file-preview)"'
)
assert docs[1].page_content == '"Question":"Q2";"Answer":"A2";"Image":""'
assert len(saves) == 1
assert saves[0][0].startswith("image_files/tenant-1/source-file-1/")
assert saves[0][0].endswith(".png")
assert saves[0][1] == image_bytes
assert upload_files[0].tenant_id == "tenant-1"
assert upload_files[0].key == saves[0][0]
assert upload_files[0].used is True
assert len(session_factory.persisted) == 1
assert [session.commit_count for session in session_factory.sessions] == [1]
def test_extract_xlsx_keeps_rows_with_only_embedded_images(
self,
monkeypatch: pytest.MonkeyPatch,
database_session_maker: sessionmaker[Session],
):
def test_extract_xlsx_keeps_rows_with_only_embedded_images(self, monkeypatch: pytest.MonkeyPatch):
image_bytes = b"\x89PNG\r\n\x1a\nimage-only-row"
sheet = _FakeSheet(
header_rows=[("Question", "Answer", "Image")],
@@ -175,7 +212,7 @@ class TestExcelExtractor:
)
workbook = _FakeWorkbook({"Data": sheet})
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
saves = _patch_image_persistence(monkeypatch)
saves, session_factory = _patch_image_persistence(monkeypatch)
extractor = ExcelExtractor(
"/tmp/sample.xlsx",
@@ -184,21 +221,17 @@ class TestExcelExtractor:
source_file_id="source-file-1",
)
docs = extractor.extract()
upload_files = _get_upload_files(database_session_maker)
assert workbook.closed is True
assert len(docs) == 1
assert len(upload_files) == 1
assert docs[0].page_content == (
f'"Question":"";"Answer":"";"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
'"Question":"";"Answer":"";"Image":"![image](http://files.local/files/u1/file-preview)"'
)
assert len(saves) == 1
assert len(session_factory.persisted) == 1
assert [session.commit_count for session in session_factory.sessions] == [1]
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(
self,
monkeypatch: pytest.MonkeyPatch,
database_session_maker: sessionmaker[Session],
):
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(self, monkeypatch: pytest.MonkeyPatch):
image_bytes = b"\x89PNG\r\n\x1a\nretry-safe-image"
workbooks = [
_FakeWorkbook(
@@ -221,7 +254,7 @@ class TestExcelExtractor:
),
]
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbooks.pop(0))
saves = _patch_image_persistence(monkeypatch)
saves, session_factory = _patch_image_persistence(monkeypatch)
extractor = ExcelExtractor(
"/tmp/sample.xlsx",
@@ -231,17 +264,16 @@ class TestExcelExtractor:
)
first_docs = extractor.extract()
second_docs = extractor.extract()
upload_files = _get_upload_files(database_session_maker)
assert len(upload_files) == 1
expected_page_content = (
'"Question":"Q1";"Answer":"A1";'
f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
'"Question":"Q1";"Answer":"A1";"Image":"![image](http://files.local/files/u1/file-preview)"'
)
assert first_docs[0].page_content == expected_page_content
assert second_docs[0].page_content == expected_page_content
assert len(saves) == 1
assert len(session_factory.persisted) == 1
assert [session.commit_count for session in session_factory.sessions] == [1, 0]
def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
class FakeExcelFile:
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,16 @@
"""Unit tests for workflow-as-tool behavior with real SQLite ORM boundaries."""
"""Unit tests for workflow-as-tool behavior.
StubSession/StubScalars emulate SQLAlchemy session/scalars with minimal methods
(`scalar`, `scalars`, `expunge`, `commit`, `refresh`, context manager) to keep
database access mocked and predictable in tests.
"""
import json
import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, Mock, patch
import pytest
from sqlalchemy import Engine, inspect
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.__base.tool_runtime import ToolRuntime
@@ -23,142 +23,74 @@ from core.tools.entities.tool_entities import (
ToolProviderType,
)
from core.tools.errors import ToolInvokeError
from core.tools.workflow_as_tool import tool as workflow_tool_module
from core.tools.workflow_as_tool.tool import WorkflowTool
from graphon.file import FILE_MODEL_IDENTITY, FileTransferMethod, FileType
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.base import TypeBase
from models.enums import EndUserType
from models.model import App, AppMode, EndUser
from models.workflow import Workflow, WorkflowType
TENANT_ID = "00000000-0000-0000-0000-000000000001"
OTHER_TENANT_ID = "00000000-0000-0000-0000-000000000002"
APP_ID = "00000000-0000-0000-0000-000000000003"
ACCOUNT_ID = "00000000-0000-0000-0000-000000000004"
END_USER_ID = "00000000-0000-0000-0000-000000000005"
CREATOR_ID = "00000000-0000-0000-0000-000000000006"
@dataclass(frozen=True)
class SqliteToolDb:
engine: Engine
session_maker: sessionmaker[Session]
caller_session: Session
class StubScalars:
"""Minimal stub for SQLAlchemy scalar results."""
_value: Any
def __init__(self, value: Any) -> None:
self._value = value
def first(self) -> Any:
return self._value
@pytest.fixture
def sqlite_tool_db(
monkeypatch: pytest.MonkeyPatch,
sqlite_engine: Engine,
) -> Iterator[SqliteToolDb]:
"""Bind service-owned sessions and Account tenant reloads to SQLite."""
models = (App, Workflow, EndUser, Account, Tenant, TenantAccountJoin)
TypeBase.metadata.create_all(sqlite_engine, tables=[model.__table__ for model in models])
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(workflow_tool_module.session_factory, "create_session", session_maker)
class StubSession:
"""Minimal stub for session_factory-created sessions."""
from models import account as account_module
scalar_results: list[Any]
scalars_results: list[Any]
expunge_calls: list[object]
monkeypatch.setattr(account_module, "db", SimpleNamespace(engine=sqlite_engine))
with session_maker() as caller_session:
yield SqliteToolDb(engine=sqlite_engine, session_maker=session_maker, caller_session=caller_session)
def __init__(self, *, scalar_results: list[Any] | None = None, scalars_results: list[Any] | None = None) -> None:
self.scalar_results = list(scalar_results or [])
self.scalars_results = list(scalars_results or [])
self.expunge_calls: list[object] = []
def scalar(self, _stmt: Any) -> Any:
return self.scalar_results.pop(0)
def scalars(self, _stmt: Any) -> StubScalars:
return StubScalars(self.scalars_results.pop(0))
def expunge(self, value: Any) -> None:
self.expunge_calls.append(value)
def begin(self) -> "StubSession":
return self
def commit(self) -> None:
pass
def refresh(self, _value: Any) -> None:
pass
def close(self) -> None:
pass
def __enter__(self) -> "StubSession":
return self
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
return False
def _persist_tenant(db: SqliteToolDb, *, tenant_id: str = TENANT_ID) -> Tenant:
tenant = Tenant(name="Tenant")
tenant.id = tenant_id
db.caller_session.add(tenant)
db.caller_session.commit()
return tenant
def _persist_account(db: SqliteToolDb, *, tenant_id: str = TENANT_ID) -> Account:
account = Account(name="Account", email="account@example.com")
account.id = ACCOUNT_ID
join = TenantAccountJoin(
tenant_id=tenant_id,
account_id=account.id,
current=True,
role=TenantAccountRole.NORMAL,
)
db.caller_session.add_all([account, join])
db.caller_session.commit()
return account
def _persist_end_user(
db: SqliteToolDb,
*,
end_user_id: str = END_USER_ID,
tenant_id: str = TENANT_ID,
) -> EndUser:
end_user = EndUser(
id=end_user_id,
tenant_id=tenant_id,
app_id=APP_ID,
type=EndUserType.SERVICE_API,
name="End user",
session_id="end-user-session",
)
db.caller_session.add(end_user)
db.caller_session.commit()
return end_user
def _persist_app(db: SqliteToolDb) -> App:
app = App(
id=APP_ID,
tenant_id=TENANT_ID,
name="Workflow app",
description="",
mode=AppMode.WORKFLOW,
icon_type=None,
icon="",
icon_background=None,
app_model_config_id=None,
workflow_id=None,
enable_site=False,
enable_api=True,
max_active_requests=None,
created_by=CREATOR_ID,
)
db.caller_session.add(app)
db.caller_session.commit()
return app
def _persist_workflow(db: SqliteToolDb, *, version: str, workflow_id: str | None = None) -> Workflow:
workflow = Workflow.new(
tenant_id=TENANT_ID,
app_id=APP_ID,
type=WorkflowType.WORKFLOW.value,
version=version,
graph=json.dumps({"nodes": [], "edges": []}),
features="{}",
created_by=CREATOR_ID,
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
workflow.id = workflow_id or str(uuid.uuid4())
db.caller_session.add(workflow)
db.caller_session.commit()
return workflow
def _build_tool(*, tenant_id: str = "test_tool", workflow_app_id: str = "app-1", version: str = "1") -> WorkflowTool:
def _build_tool() -> WorkflowTool:
entity = ToolEntity(
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
parameters=[],
description=None,
has_runtime_parameters=False,
)
runtime = ToolRuntime(tenant_id=tenant_id, invoke_from=InvokeFrom.EXPLORE)
runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE)
return WorkflowTool(
workflow_app_id=workflow_app_id,
workflow_app_id="app-1",
workflow_as_tool_id="wf-tool-1",
version=version,
version="1",
workflow_entities={},
workflow_call_depth=1,
entity=entity,
@@ -166,10 +98,7 @@ def _build_tool(*, tenant_id: str = "test_tool", workflow_app_id: str = "app-1",
)
def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field(monkeypatch: pytest.MonkeyPatch):
"""Ensure that WorkflowTool will throw a `ToolInvokeError` exception when
`WorkflowAppGenerator.generate` returns a result with `error` key inside
the `data` element.
@@ -193,14 +122,11 @@ def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_fiel
with pytest.raises(ToolInvokeError) as exc_info:
# WorkflowTool always returns a generator, so we need to iterate to
# actually `run` the tool.
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
list(tool.invoke(MagicMock(), "test_user", {}))
assert exc_info.value.args == ("oops",)
def test_workflow_tool_does_not_use_pause_state_config(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.MonkeyPatch):
"""Ensure pause_state_config is passed as None."""
tool = _build_tool()
@@ -214,17 +140,14 @@ def test_workflow_tool_does_not_use_pause_state_config(
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
list(tool.invoke(MagicMock(), "test_user", {}))
call_kwargs = generate_mock.call_args.kwargs
assert "pause_state_config" in call_kwargs
assert call_kwargs["pause_state_config"] is None
def test_workflow_tool_passes_parent_trace_context_from_runtime(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pytest.MonkeyPatch):
"""Ensure nested workflow runtime metadata is forwarded as parent trace context."""
tool = _build_tool()
tool.set_parent_trace_context(
@@ -242,7 +165,7 @@ def test_workflow_tool_passes_parent_trace_context_from_runtime(
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
list(tool.invoke(MagicMock(), "test_user", {}))
call_kwargs = generate_mock.call_args.kwargs
assert call_kwargs["args"]["parent_trace_context"].model_dump() == {
@@ -251,10 +174,7 @@ def test_workflow_tool_passes_parent_trace_context_from_runtime(
}
def test_workflow_tool_passes_parent_trace_session_id(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_passes_parent_trace_session_id(monkeypatch: pytest.MonkeyPatch):
"""Ensure nested workflows inherit the parent observability session ID."""
tool = _build_tool()
tool.entity.parameters = [
@@ -277,17 +197,14 @@ def test_workflow_tool_passes_parent_trace_session_id(
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {"trace_session_id": "user-input-session"}))
list(tool.invoke(MagicMock(), "test_user", {"trace_session_id": "user-input-session"}))
call_kwargs = generate_mock.call_args.kwargs
assert call_kwargs["args"]["inputs"]["trace_session_id"] == "user-input-session"
assert call_kwargs["args"]["trace_session_id"] == "session-1"
def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypatch: pytest.MonkeyPatch):
"""Ensure private trace context does not overwrite same-named workflow inputs."""
tool = _build_tool()
tool.entity.parameters = [
@@ -321,7 +238,7 @@ def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(
list(
tool.invoke(
sqlite_tool_db.caller_session,
MagicMock(),
"test_user",
{
"outer_workflow_run_id": "user-workflow-input",
@@ -339,10 +256,7 @@ def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(
}
def test_workflow_tool_can_clear_parent_trace_context(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.MonkeyPatch):
"""Ensure reused WorkflowTool instances do not keep stale parent trace context."""
tool = _build_tool()
tool.set_parent_trace_context(
@@ -361,16 +275,13 @@ def test_workflow_tool_can_clear_parent_trace_context(
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
list(tool.invoke(MagicMock(), "test_user", {}))
call_kwargs = generate_mock.call_args.kwargs
assert "parent_trace_context" not in call_kwargs["args"]
def test_workflow_tool_can_clear_trace_session_id(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatch):
"""Ensure reused WorkflowTool instances do not keep stale trace session IDs."""
tool = _build_tool()
tool.set_trace_session_id("session-1")
@@ -386,7 +297,7 @@ def test_workflow_tool_can_clear_trace_session_id(
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
list(tool.invoke(MagicMock(), "test_user", {}))
call_kwargs = generate_mock.call_args.kwargs
assert "trace_session_id" not in call_kwargs["args"]
@@ -404,7 +315,6 @@ def test_workflow_tool_can_clear_trace_session_id(
def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete(
monkeypatch: pytest.MonkeyPatch,
runtime_parameters: dict[str, Any],
sqlite_tool_db: SqliteToolDb,
):
"""Ensure incomplete runtime metadata does not leak parent trace context into generator args."""
tool = _build_tool()
@@ -420,16 +330,13 @@ def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete(
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
list(tool.invoke(MagicMock(), "test_user", {}))
call_kwargs = generate_mock.call_args.kwargs
assert "parent_trace_context" not in call_kwargs["args"]
def test_workflow_tool_should_generate_variable_messages_for_outputs(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch: pytest.MonkeyPatch):
"""Test that WorkflowTool should generate variable messages when there are outputs"""
tool = _build_tool()
@@ -452,7 +359,7 @@ def test_workflow_tool_should_generate_variable_messages_for_outputs(
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
# Execute tool invocation
messages = list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
messages = list(tool.invoke(MagicMock(), "test_user", {}))
# Verify variable messages
variable_messages = [msg for msg in messages if msg.type == ToolInvokeMessage.MessageType.VARIABLE]
@@ -475,10 +382,7 @@ def test_workflow_tool_should_generate_variable_messages_for_outputs(
assert json_messages[0].message.json_object == mock_outputs
def test_workflow_tool_should_handle_empty_outputs(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_should_handle_empty_outputs(monkeypatch: pytest.MonkeyPatch):
"""Test that WorkflowTool should handle empty outputs correctly"""
tool = _build_tool()
@@ -498,7 +402,7 @@ def test_workflow_tool_should_handle_empty_outputs(
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
# Execute tool invocation
messages = list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
messages = list(tool.invoke(MagicMock(), "test_user", {}))
# Verify generated messages
# Should contain: 0 variable messages + 1 text message + 1 JSON message = 2 messages
@@ -554,32 +458,41 @@ def test_create_file_message_should_include_file_marker():
assert message.meta == {"file": file_obj}
def test_resolve_user_from_database_falls_back_to_end_user(sqlite_tool_db: SqliteToolDb):
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
"""Ensure worker context can resolve EndUser when Account is missing."""
_persist_tenant(sqlite_tool_db)
end_user = _persist_end_user(sqlite_tool_db)
other_tenant_end_user = _persist_end_user(
sqlite_tool_db,
end_user_id="00000000-0000-0000-0000-000000000007",
tenant_id=OTHER_TENANT_ID,
tenant = SimpleNamespace(id="tenant_id")
end_user = SimpleNamespace(id="end_user_id", tenant_id="tenant_id")
# Monkeypatch session factory to return our stub session
stub_session = StubSession(scalar_results=[tenant, None, end_user])
monkeypatch.setattr(
"core.tools.workflow_as_tool.tool.session_factory.create_session",
lambda: stub_session,
)
tool = _build_tool(tenant_id=TENANT_ID)
tool = _build_tool()
tool.runtime.invoke_from = InvokeFrom.SERVICE_API
tool.runtime.tenant_id = "tenant_id"
resolved_user = tool._resolve_user_from_database(user_id=end_user.id)
assert isinstance(resolved_user, EndUser)
assert resolved_user.id == end_user.id
assert resolved_user.tenant_id == TENANT_ID
assert inspect(resolved_user).detached is True
assert tool._resolve_user_from_database(user_id=other_tenant_end_user.id) is None
assert resolved_user is end_user
assert stub_session.expunge_calls == [end_user]
def test_resolve_user_from_database_returns_none_when_no_tenant(sqlite_tool_db: SqliteToolDb):
def test_resolve_user_from_database_returns_none_when_no_tenant(monkeypatch: pytest.MonkeyPatch):
"""Return None if tenant cannot be found in worker context."""
tool = _build_tool(tenant_id=OTHER_TENANT_ID)
# Monkeypatch session factory to return our stub session with no tenant
monkeypatch.setattr(
"core.tools.workflow_as_tool.tool.session_factory.create_session",
lambda: StubSession(scalar_results=[None]),
)
tool = _build_tool()
tool.runtime.invoke_from = InvokeFrom.SERVICE_API
tool.runtime.tenant_id = "missing_tenant"
resolved_user = tool._resolve_user_from_database(user_id="any")
@@ -631,10 +544,7 @@ def test_extract_usage_from_nested():
assert nested == {"total_tokens": 3}
def test_invoke_raises_when_user_not_found(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_invoke_raises_when_user_not_found(monkeypatch: pytest.MonkeyPatch):
"""Raise ToolInvokeError when user resolution fails."""
tool = _build_tool()
monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None)
@@ -642,45 +552,58 @@ def test_invoke_raises_when_user_not_found(
monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: None)
with pytest.raises(ToolInvokeError, match="User not found"):
list(tool.invoke(sqlite_tool_db.caller_session, "missing", {}))
list(tool.invoke(MagicMock(), "missing", {}))
def test_resolve_user_from_database_returns_account(sqlite_tool_db: SqliteToolDb):
def test_resolve_user_from_database_returns_account(monkeypatch: pytest.MonkeyPatch):
"""Resolve Account and set tenant in worker context."""
tenant = _persist_tenant(sqlite_tool_db)
account = _persist_account(sqlite_tool_db)
tool = _build_tool(tenant_id=TENANT_ID)
tenant = SimpleNamespace(id="tenant_id")
account = SimpleNamespace(id="account_id", current_tenant=None)
set_current_tenant = Mock(side_effect=lambda tenant, *, session: setattr(account, "current_tenant", tenant))
account.set_current_tenant_with_session = set_current_tenant
session = StubSession(scalar_results=[tenant, account])
resolved = tool._resolve_user_from_database(user_id=account.id)
assert isinstance(resolved, Account)
assert resolved.id == account.id
assert resolved.current_tenant_id == tenant.id
assert inspect(resolved).detached is True
monkeypatch.setattr("core.tools.workflow_as_tool.tool.session_factory.create_session", lambda: session)
tool = _build_tool()
tool.runtime.tenant_id = "tenant_id"
resolved = tool._resolve_user_from_database(user_id="account_id")
assert resolved is account
assert account.current_tenant is tenant
set_current_tenant.assert_called_once_with(tenant, session=session)
assert session.expunge_calls == [account]
def test_get_workflow_and_get_app_db_branches(sqlite_tool_db: SqliteToolDb):
def test_get_workflow_and_get_app_db_branches(monkeypatch: pytest.MonkeyPatch):
"""Cover workflow/app retrieval branches and error cases."""
app = _persist_app(sqlite_tool_db)
specific_workflow = _persist_workflow(sqlite_tool_db, version="1")
latest_workflow = _persist_workflow(sqlite_tool_db, version="2")
_persist_workflow(sqlite_tool_db, version=Workflow.VERSION_DRAFT)
tool = _build_tool(tenant_id=TENANT_ID, workflow_app_id=APP_ID)
tool = _build_tool()
latest_workflow = SimpleNamespace(id="wf-latest")
specific_workflow = SimpleNamespace(id="wf-v1")
app = SimpleNamespace(id="app-1")
sessions = iter(
[
StubSession(scalar_results=[], scalars_results=[latest_workflow]),
StubSession(scalar_results=[specific_workflow], scalars_results=[]),
StubSession(scalar_results=[app], scalars_results=[]),
]
)
monkeypatch.setattr(
"core.tools.workflow_as_tool.tool.session_factory.create_session",
lambda: next(sessions),
)
latest = tool._get_workflow(APP_ID, "")
specific = tool._get_workflow(APP_ID, "1")
resolved_app = tool._get_app(APP_ID)
assert latest.id == latest_workflow.id
assert specific.id == specific_workflow.id
assert resolved_app.id == app.id
assert inspect(latest).detached is True
assert inspect(specific).detached is True
assert inspect(resolved_app).detached is True
assert tool._get_workflow("app-1", "") is latest_workflow
assert tool._get_workflow("app-1", "1") is specific_workflow
assert tool._get_app("app-1") is app
monkeypatch.setattr(
"core.tools.workflow_as_tool.tool.session_factory.create_session",
lambda: StubSession(scalar_results=[None, None], scalars_results=[None]),
)
with pytest.raises(ValueError, match="workflow not found"):
tool._get_workflow(APP_ID, "missing")
tool._get_workflow("app-1", "1")
with pytest.raises(ValueError, match="app not found"):
tool._get_app("00000000-0000-0000-0000-000000000099")
tool._get_app("app-1")
def _setup_transform_args_tool(monkeypatch: pytest.MonkeyPatch) -> WorkflowTool:
@@ -799,10 +722,7 @@ def test_transform_args_normalizes_optional_files_parameter(
assert files == []
def test_workflow_tool_invocation_normalizes_optional_files_parameter(
monkeypatch: pytest.MonkeyPatch,
sqlite_tool_db: SqliteToolDb,
):
def test_workflow_tool_invocation_normalizes_optional_files_parameter(monkeypatch: pytest.MonkeyPatch):
"""Ensure casted empty FILES values do not reach workflow input validation as [None]."""
tool = _build_tool()
images_param = ToolParameter.get_simple_instance(
@@ -821,7 +741,7 @@ def test_workflow_tool_invocation_normalizes_optional_files_parameter(
generate_mock = MagicMock(return_value={"data": {}})
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {"images": None}))
list(tool.invoke(MagicMock(), "test_user", {"images": None}))
call_kwargs = generate_mock.call_args.kwargs
assert call_kwargs["args"]["inputs"]["images"] == []
@@ -5,7 +5,6 @@ from typing import cast
import pytest
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.config import DifyConfigSkillConfig
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID
@@ -41,14 +40,6 @@ from models.agent_config_entities import (
)
@pytest.fixture(autouse=True)
def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs",
lambda *, tenant_id, agent_id: [],
)
class FakeCredentialsProvider:
def fetch(self, provider_name: str, model_name: str) -> dict[str, object]:
assert provider_name == "openai"
@@ -1429,30 +1420,6 @@ def test_build_config_layer_config_includes_soul_context_and_mentions():
assert warnings == []
def test_build_config_layer_config_includes_runtime_agent_skills():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
soul = AgentSoulConfig(
prompt={"system_prompt": "Use [§skill:workspace-skill:Workspace Skill§]."},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
)
config, warnings = build_config_layer_config(
soul,
runtime_config_skills=[
DifyConfigSkillConfig(
name="workspace-skill",
description="Bound workspace skill.",
size=123,
mime_type="application/zip",
)
],
)
assert [skill.name for skill in config.skills] == ["workspace-skill"]
assert config.mentioned_skill_names == ["workspace-skill"]
assert warnings == []
def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
@@ -1538,33 +1505,6 @@ def test_workflow_run_request_contains_config_layer():
assert any(spec.name == DIFY_CONFIG_LAYER_ID and spec.type == "dify.config" for spec in specs)
def test_workflow_run_request_includes_bound_workspace_skills(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs",
lambda *, tenant_id, agent_id: [
DifyConfigSkillConfig(
name="workspace-skill",
description="Bound workspace skill.",
size=123,
mime_type="application/zip",
)
],
)
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={"system_prompt": "Use [§skill:workspace-skill:Workspace Skill§]."},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
assert [skill.name for skill in config.config.skills] == ["workspace-skill"]
assert config.config.mentioned_skill_names == ["workspace-skill"]
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert soul_prompt.config.prefix == "Use workspace-skill."
def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt():
context = _context()
context.snapshot.config_snapshot = _soul_with_config_assets()
@@ -18,6 +18,7 @@ from models.provider import ProviderType
@pytest.fixture
def credit_pool_session_factory(sqlite_engine: Engine) -> Iterator[sessionmaker[Session]]:
"""Bind message-created accounting to fixture-owned SQLite sessions."""
TenantCreditPool.__table__.create(sqlite_engine)
session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
with patch("events.event_handlers.update_provider_when_message_created.db.session", session_factory):
yield session_factory
+1 -45
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytest
from libs.helper import OptionalTimestampField, alphanumeric, email, escape_like_pattern, extract_tenant_id
from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id
from models.account import Account
from models.model import EndUser
@@ -153,47 +153,3 @@ class TestEmailValidator:
def test_invalid_email_rejected(self):
with pytest.raises(ValueError, match="not a valid email"):
email("not-an-email")
class TestAlphanumericValidator:
"""Tests for the alphanumeric() validator — regression for #39666."""
def test_valid_alphanumeric_accepted(self):
assert alphanumeric("tool_name") == "tool_name"
assert alphanumeric("Tool123") == "Tool123"
assert alphanumeric("_underscore_start") == "_underscore_start"
assert alphanumeric("a") == "a"
def test_trailing_newline_rejected(self):
# re.match with $ accepts a trailing \n in Python; re.fullmatch does not.
# This was the pre-fix behaviour: alphanumeric("tool\n") returned "tool\n".
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool_name\n")
def test_trailing_carriage_return_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool_name\r")
def test_trailing_crlf_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool_name\r\n")
def test_leading_newline_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("\ntool_name")
def test_embedded_whitespace_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool name")
def test_empty_string_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("")
def test_special_characters_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool-name")
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool.name")
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool/name")
@@ -13,7 +13,7 @@ from services.agent import skill_package_service as skill_package_service_module
from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService
_SKILL_MD = """---
name: pdf-toolkit
name: PDF Toolkit
description: Tools for working with PDF files.
---
@@ -43,7 +43,7 @@ def _archive_members(content: bytes) -> list[str]:
def test_valid_skill_normalizes_manifest():
manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}).manifest
assert manifest.name == "pdf-toolkit"
assert manifest.name == "PDF Toolkit"
assert manifest.description == "Tools for working with PDF files."
assert manifest.entry_path == "SKILL.md"
assert set(manifest.files) == {"SKILL.md", "scripts/run.py"}
@@ -51,10 +51,10 @@ def test_valid_skill_normalizes_manifest():
assert len(manifest.hash) == 64
def test_name_and_description_are_required_in_frontmatter():
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": b"# heading-name\n\nbody"})
assert exc_info.value.code == "missing_skill_name"
def test_name_falls_back_to_heading_without_frontmatter():
manifest = _normalize({"SKILL.md": b"# Heading Name\n\nbody"}).manifest
assert manifest.name == "Heading Name"
assert manifest.description == ""
def test_shallowest_skill_md_preferred_during_normalization():
@@ -155,18 +155,7 @@ def test_validate_and_normalize_strips_deeper_selected_skill_root():
({"README.md": b"x"}, "skill.zip", "missing_skill_md"),
({"SKILL.md": _SKILL_MD.encode()}, "skill.tar", "unsupported_extension"),
({"SKILL.md": b""}, "skill.zip", "empty_skill_md"),
({"SKILL.md": b"---\ndescription: valid\n---\n# no name here"}, "skill.zip", "missing_skill_name"),
({"SKILL.md": b"---\nname: pdf-toolkit\n---\n# no description"}, "skill.zip", "missing_skill_description"),
(
{"SKILL.md": b"---\nname: PDF Toolkit\ndescription: valid\n---\n# invalid name"},
"skill.zip",
"invalid_skill_name",
),
(
{"SKILL.md": f"---\nname: pdf-toolkit\ndescription: {'x' * 1025}\n---\n# long".encode()},
"skill.zip",
"invalid_skill_description",
),
({"SKILL.md": b"no name here"}, "skill.zip", "missing_skill_name"),
({"SKILL.md": b"\xff\xfenot utf8"}, "skill.zip", "skill_md_not_utf8"),
],
)
@@ -235,10 +224,10 @@ def test_bad_frontmatter_yaml_rejected():
assert exc_info.value.code == "invalid_frontmatter"
def test_unterminated_frontmatter_rejected():
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": b"---\n# heading-wins\nbody"})
assert exc_info.value.code == "missing_skill_name"
def test_unterminated_frontmatter_falls_back_to_heading():
# leading '---' with no closing fence -> no frontmatter, use the heading
manifest = _normalize({"SKILL.md": b"---\n# Heading Wins\nbody"}).manifest
assert manifest.name == "Heading Wins"
def test_validate_and_normalize_rejects_files_outside_selected_skill_root():
@@ -20,7 +20,7 @@ _AGENT_ID = "22222222-2222-2222-2222-222222222222"
_USER_ID = "33333333-3333-3333-3333-333333333333"
_SKILL_MD = b"""---
name: pdf-toolkit
name: PDF Toolkit
description: Work with PDFs.
---
@@ -121,7 +121,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(
assert skill_row.is_skill is True
assert skill_row.skill_metadata is not None
skill_metadata = DriveSkillMetadata.model_validate_json(skill_row.skill_metadata)
assert skill_metadata.name == "pdf-toolkit"
assert skill_metadata.name == "PDF Toolkit"
assert skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
assert archive_row.file_kind == AgentDriveFileKind.TOOL_FILE
assert archive_row.file_id == archive_tool_file.id
@@ -132,7 +132,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(
# The returned upload response carries only the drive-derived fields the UI needs.
skill = result["skill"]
assert skill["path"] == "pdf-toolkit"
assert skill["name"] == "pdf-toolkit"
assert skill["name"] == "PDF Toolkit"
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
assert result["manifest"]["entry_path"] == "SKILL.md"
@@ -1,105 +1,89 @@
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from models.account import (
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantPluginAutoUpgradeStrategySetting,
)
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
MODULE = "services.plugin.plugin_auto_upgrade_service"
PLUGIN_CATEGORY = TenantPluginAutoUpgradeCategory.TOOL
STRATEGY_MODELS = (TenantPluginAutoUpgradeStrategy,)
def _strategy(
tenant_id: str,
*,
category: TenantPluginAutoUpgradeCategory = PLUGIN_CATEGORY,
setting: TenantPluginAutoUpgradeStrategySetting = TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
mode: TenantPluginAutoUpgradeMode = TenantPluginAutoUpgradeMode.EXCLUDE,
exclude: list[str] | None = None,
include: list[str] | None = None,
upgrade_time: int = 0,
) -> TenantPluginAutoUpgradeStrategy:
return TenantPluginAutoUpgradeStrategy(
tenant_id=tenant_id,
category=category,
strategy_setting=setting,
upgrade_time_of_day=upgrade_time,
upgrade_mode=mode,
exclude_plugins=exclude or [],
include_plugins=include or [],
)
def _patched_session():
"""Return a mock SQLAlchemy session for service calls."""
session = MagicMock()
return session
class TestGetStrategy:
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_returns_strategy_when_found(self, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
strategy = _strategy(tenant_id)
sqlite_session.add(strategy)
sqlite_session.commit()
def test_returns_strategy_when_found(self):
session = _patched_session()
strategy = MagicMock()
session.scalar.return_value = strategy
result = PluginAutoUpgradeService.get_strategy(tenant_id, PLUGIN_CATEGORY, session=sqlite_session)
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session)
assert result is strategy
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_returns_none_when_not_found(self, sqlite_session: Session) -> None:
assert PluginAutoUpgradeService.get_strategy(str(uuid4()), PLUGIN_CATEGORY, session=sqlite_session) is None
def test_returns_none_when_not_found(self):
session = _patched_session()
session.scalar.return_value = None
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session)
assert result is None
class TestChangeStrategy:
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_creates_new_strategy(self, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
def test_creates_new_strategy(self):
session = _patched_session()
session.scalar.return_value = None
result = PluginAutoUpgradeService.change_strategy(
tenant_id,
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
3,
TenantPluginAutoUpgradeMode.ALL,
[],
[],
category=PLUGIN_CATEGORY,
session=sqlite_session,
)
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.return_value = MagicMock()
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.change_strategy(
"t1",
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
3,
TenantPluginAutoUpgradeMode.ALL,
[],
[],
category=PLUGIN_CATEGORY,
session=session,
)
strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy))
assert result is True
assert strategy is not None
assert strategy.tenant_id == tenant_id
assert strategy.upgrade_time_of_day == 3
assert strategy.upgrade_mode == TenantPluginAutoUpgradeMode.ALL
session.add.assert_called_once()
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_updates_existing_strategy(self, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
existing = _strategy(tenant_id)
sqlite_session.add(existing)
sqlite_session.commit()
def test_updates_existing_strategy(self):
session = _patched_session()
existing = MagicMock()
session.scalar.return_value = existing
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.change_strategy(
tenant_id,
"t1",
TenantPluginAutoUpgradeStrategySetting.LATEST,
5,
TenantPluginAutoUpgradeMode.PARTIAL,
["p1"],
["p2"],
category=PLUGIN_CATEGORY,
session=sqlite_session,
session=session,
)
sqlite_session.refresh(existing)
assert result is True
assert existing.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
assert existing.upgrade_time_of_day == 5
@@ -109,115 +93,157 @@ class TestChangeStrategy:
class TestExcludePlugin:
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_creates_default_strategy_when_none_exists(self, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
def test_creates_default_strategy_when_none_exists(self):
session = _patched_session()
session.scalar.return_value = None
result = PluginAutoUpgradeService.exclude_plugin(tenant_id, "plugin-1", PLUGIN_CATEGORY, session=sqlite_session)
with (
patch(f"{MODULE}.select"),
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy"),
):
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.exclude_plugin(
"t1",
"plugin-1",
PLUGIN_CATEGORY,
session=session,
)
strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy))
assert result is True
assert strategy is not None
assert strategy.exclude_plugins == ["plugin-1"]
session.add.assert_called_once()
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_appends_to_exclude_list_in_exclude_mode(self, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
existing = _strategy(tenant_id, exclude=["p-existing"])
sqlite_session.add(existing)
sqlite_session.commit()
def test_appends_to_exclude_list_in_exclude_mode(self):
session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
existing.exclude_plugins = ["p-existing"]
session.scalar.return_value = existing
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p-new", PLUGIN_CATEGORY, session=sqlite_session)
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
sqlite_session.refresh(existing)
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY, session=session)
assert result is True
assert existing.exclude_plugins == ["p-existing", "p-new"]
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_removes_from_include_list_in_partial_mode(self, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.PARTIAL, include=["p1", "p2"])
sqlite_session.add(existing)
sqlite_session.commit()
def test_removes_from_include_list_in_partial_mode(self):
session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = TenantPluginAutoUpgradeMode.PARTIAL
existing.include_plugins = ["p1", "p2"]
session.scalar.return_value = existing
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
sqlite_session.refresh(existing)
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
assert result is True
assert existing.include_plugins == ["p2"]
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_switches_to_exclude_mode_from_all(self, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.ALL)
sqlite_session.add(existing)
sqlite_session.commit()
def test_switches_to_exclude_mode_from_all(self):
session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = TenantPluginAutoUpgradeMode.ALL
session.scalar.return_value = existing
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
sqlite_session.refresh(existing)
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
assert result is True
assert existing.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE
assert existing.exclude_plugins == ["p1"]
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_no_duplicate_in_exclude_list(self, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
existing = _strategy(tenant_id, exclude=["p1"])
sqlite_session.add(existing)
sqlite_session.commit()
def test_no_duplicate_in_exclude_list(self):
session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
existing.exclude_plugins = ["p1"]
session.scalar.return_value = existing
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
sqlite_session.refresh(existing)
assert existing.exclude_plugins == ["p1"]
class TestBackfillStrategyCategories:
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_creates_default_missing_categories_without_fetching_daemon(self, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
tool_strategy = _strategy(tenant_id)
sqlite_session.add(tool_strategy)
sqlite_session.commit()
def test_creates_default_missing_categories_without_fetching_daemon(self):
session = _patched_session()
tool_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
session.scalars.return_value.all.return_value = [tool_strategy]
installer = MagicMock()
with patch(f"{MODULE}.PluginInstaller", return_value=installer):
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session)
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all())
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 1
assert result.normalized is False
installer.list_plugins.assert_not_called()
assert len(strategies) == len(TenantPluginAutoUpgradeCategory)
assert tool_strategy.upgrade_time_of_day == expected_time
created_strategies = [call.args[0] for call in session.add.call_args_list]
model_strategy = next(
strategy for strategy in strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
strategy for strategy in created_strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
)
assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
assert model_strategy.upgrade_time_of_day == expected_time
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self) -> None:
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day(str(uuid4()))
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self):
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
assert default_time % (15 * 60) == 0
assert 0 <= default_time < 24 * 60 * 60
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
def test_creates_missing_categories_and_splits_known_plugins(
self, sqlite_session: Session, caplog: pytest.LogCaptureFixture
) -> None:
tenant_id = str(uuid4())
tool_strategy = _strategy(
tenant_id,
exclude=["tool-plugin", "model-plugin", "unknown-plugin"],
include=["model-plugin", "tool-plugin"],
def test_creates_missing_categories_and_splits_known_plugins(self, caplog: pytest.LogCaptureFixture):
session = _patched_session()
tool_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
include_plugins=["model-plugin", "tool-plugin"],
)
model_strategy = _strategy(
tenant_id,
model_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeCategory.MODEL,
exclude=["tool-plugin", "model-plugin", "unknown-plugin"],
include=["model-plugin", "tool-plugin"],
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
include_plugins=["model-plugin", "tool-plugin"],
)
sqlite_session.add_all([tool_strategy, model_strategy])
sqlite_session.commit()
session.scalars.return_value.all.return_value = [tool_strategy, model_strategy]
installed_plugins = [
SimpleNamespace(
plugin_id="tool-plugin",
@@ -235,17 +261,18 @@ class TestBackfillStrategyCategories:
patch(f"{MODULE}.PluginInstaller", return_value=installer),
caplog.at_level(logging.WARNING, logger=MODULE),
):
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session)
strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all())
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 2
assert result.normalized is True
assert len(strategies) == len(TenantPluginAutoUpgradeCategory)
assert session.add.call_count == len(TenantPluginAutoUpgradeCategory) - 2
assert tool_strategy.exclude_plugins == ["tool-plugin"]
assert tool_strategy.include_plugins == ["tool-plugin"]
assert model_strategy.exclude_plugins == ["model-plugin"]
assert model_strategy.include_plugins == ["model-plugin"]
assert (
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
f"tenant_id={tenant_id}, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
"tenant_id=t1, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
)
@@ -7,19 +7,28 @@ import pytest
import zstandard
from pydantic import TypeAdapter
from redis import RedisError
from sqlalchemy.orm import Session
from core.helper.model_provider_cache import ProviderCredentialsCacheType
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
from models.provider import Provider, ProviderCredential, ProviderType, TenantPreferredModelProvider
MODULE = "core.plugin.plugin_service"
TENANT_ID = "11111111-1111-1111-1111-111111111111"
OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222"
USER_ID = "33333333-3333-3333-3333-333333333333"
class _FakeSession:
def __init__(self) -> None:
self.execute = Mock()
self.scalars = Mock(return_value=SimpleNamespace(all=Mock(return_value=[])))
def __enter__(self) -> "_FakeSession":
return self
def __exit__(self, exc_type, exc, traceback) -> None:
return None
def begin(self) -> "_FakeSession":
return self
def _build_provider_entity(provider: str = "openai") -> ProviderEntity:
@@ -237,26 +246,6 @@ class TestPluginModelProviderCache:
call([cache_key]),
]
def test_fetch_plugin_model_providers_bypasses_redis_when_cache_disabled(self) -> None:
"""With the cache disabled the daemon is the only source, and Redis is never touched."""
with patch(f"{MODULE}.redis_client") as redis_client, patch(f"{MODULE}.dify_config") as config:
config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED = False
client = Mock()
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
from core.plugin.plugin_service import PluginService
first = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
second = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
assert [provider.provider for provider in first] == ["langgenius/openai/openai"]
assert [provider.provider for provider in second] == ["langgenius/openai/openai"]
assert client.fetch_model_providers.call_count == 2
redis_client.get.assert_not_called()
redis_client.mget.assert_not_called()
redis_client.setex.assert_not_called()
redis_client.lock.assert_not_called()
def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None:
"""Redis read failures do not block provider discovery for the tenant."""
with patch(f"{MODULE}.redis_client") as redis_client:
@@ -817,7 +806,105 @@ class TestPluginListEndpointCounts:
assert tool_plugin.endpoints_active == 0
class TestPluginCategoryList:
def test_list_by_category_forwards_search_and_tag_filters(self) -> None:
plugins = SimpleNamespace(list=[], has_more=False)
with patch(f"{MODULE}.PluginInstaller") as installer_cls:
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category(
"tenant-1",
PluginCategory.Tool,
2,
25,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
assert result is plugins
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
"tenant-1",
PluginCategory.Tool,
2,
25,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
def test_filtered_model_category_does_not_reconcile_from_a_partial_result(self) -> None:
plugins = SimpleNamespace(list=[], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category(
"tenant-1",
PluginCategory.Model,
1,
100,
query="openai",
tags=[],
language="en_US",
)
assert result is plugins
invalidate_cache.assert_not_called()
store_marker.assert_not_called()
class TestInstalledPluginIds:
def test_list_installed_plugin_ids_uses_lightweight_daemon_endpoint(self) -> None:
with patch(f"{MODULE}.PluginInstaller") as installer_cls:
installer_cls.return_value.list_installed_plugin_ids.return_value = [
"langgenius/openai",
"langgenius/anthropic",
]
from core.plugin.plugin_service import PluginService
result = PluginService.list_installed_plugin_ids("tenant-1", PluginCategory.Tool)
assert result == ["langgenius/openai", "langgenius/anthropic"]
installer_cls.return_value.list_installed_plugin_ids.assert_called_once_with("tenant-1", PluginCategory.Tool)
class TestPluginModelProviderCacheInvalidation:
def test_list_model_provider_bindings_reconciles_remote_provider_cache(self) -> None:
"""The summary binding read owns the remote marker once the full category list leaves the first-load path."""
remote_binding = _build_remote_model_plugin()
client = MagicMock()
client.fetch_model_provider_bindings.return_value = [remote_binding]
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
with (
patch(
f"{MODULE}.PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins",
return_value=True,
) as should_invalidate,
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
from core.plugin.plugin_service import PluginService
result = PluginService.list_model_provider_bindings("tenant-1", client=client)
assert result == [remote_binding]
client.fetch_model_provider_bindings.assert_called_once_with("tenant-1")
should_invalidate.assert_called_once_with("tenant-1", [remote_binding])
invalidate_cache.assert_called_once_with("tenant-1")
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_get_debugging_key_does_not_invalidate_model_provider_cache(self) -> None:
"""Reading a debug key does not mean a debug runtime has registered a model provider."""
with (
@@ -861,7 +948,13 @@ class TestPluginModelProviderCacheInvalidation:
assert result is plugins
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
"tenant-1", PluginCategory.Model, 1, 100
"tenant-1",
PluginCategory.Model,
1,
100,
query="",
tags=(),
language="en_US",
)
invalidate_cache.assert_called_once_with("tenant-1")
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
@@ -928,14 +1021,42 @@ class TestPluginModelProviderCacheInvalidation:
invalidate_cache.assert_not_called()
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_list_model_category_invalidates_when_remote_model_plugin_disconnects(self) -> None:
"""The current model category result clears provider cache when the previous debug model disappears."""
@pytest.mark.parametrize(("page", "has_more"), [(1, True), (2, False)])
def test_list_model_category_does_not_reconcile_partial_page(self, page: int, has_more: bool) -> None:
"""Only an unfiltered, complete first page may write the remote model marker."""
installed_plugin = SimpleNamespace(
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:1.0.0",
source=PluginInstallationSource.Marketplace,
)
plugins = SimpleNamespace(list=[installed_plugin], has_more=True)
plugins = SimpleNamespace(list=[installed_plugin], has_more=has_more)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value="langgenius/debug-model:langgenius/debug-model:1.0.0",
),
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, page, 100)
assert result is plugins
invalidate_cache.assert_not_called()
store_marker.assert_not_called()
def test_list_model_category_complete_first_page_reconciles_remote_plugin_disconnect(self) -> None:
installed_plugin = SimpleNamespace(
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:1.0.0",
source=PluginInstallationSource.Marketplace,
)
plugins = SimpleNamespace(list=[installed_plugin], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
@@ -1157,72 +1278,19 @@ class TestPluginModelProviderCacheInvalidation:
assert result is True
invalidate_cache.assert_called_once_with("tenant-1")
@pytest.mark.parametrize(
"sqlite_session", [(Provider, ProviderCredential, TenantPreferredModelProvider)], indirect=True
)
def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(
self, sqlite_session: Session
) -> None:
def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(self) -> None:
"""Successful uninstall with plugin metadata also invalidates the mutated tenant provider cache."""
plugin_id = "langgenius/openai"
provider_name = f"{plugin_id}/openai"
plugin = SimpleNamespace(
installation_id="installation-1",
plugin_id=plugin_id,
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:1.0.0",
)
credential = ProviderCredential(
tenant_id=TENANT_ID,
provider_name=provider_name,
credential_name="Target credential",
encrypted_config="{}",
user_id=USER_ID,
)
other_credential = ProviderCredential(
tenant_id=OTHER_TENANT_ID,
provider_name=provider_name,
credential_name="Other credential",
encrypted_config="{}",
user_id=USER_ID,
)
sqlite_session.add_all([credential, other_credential])
sqlite_session.flush()
provider = Provider(
tenant_id=TENANT_ID,
provider_name=provider_name,
provider_type=ProviderType.CUSTOM,
credential_id=credential.id,
)
other_provider = Provider(
tenant_id=OTHER_TENANT_ID,
provider_name=provider_name,
provider_type=ProviderType.CUSTOM,
credential_id=other_credential.id,
)
preferred_provider = TenantPreferredModelProvider(
tenant_id=TENANT_ID,
provider_name=provider_name,
preferred_provider_type=ProviderType.CUSTOM,
)
other_preferred_provider = TenantPreferredModelProvider(
tenant_id=OTHER_TENANT_ID,
provider_name=provider_name,
preferred_provider_type=ProviderType.CUSTOM,
)
sqlite_session.add_all([provider, other_provider, preferred_provider, other_preferred_provider])
sqlite_session.commit()
credential_id = credential.id
other_credential_id = other_credential.id
provider_id = provider.id
other_provider_id = other_provider.id
preferred_provider_id = preferred_provider.id
other_preferred_provider_id = other_preferred_provider.id
session = _FakeSession()
with (
patch(f"{MODULE}.db", SimpleNamespace(engine=sqlite_session.get_bind())),
patch(f"{MODULE}.db", SimpleNamespace(engine=object())),
patch(f"{MODULE}.dify_config") as mock_config,
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(f"{MODULE}.ProviderCredentialsCache") as credentials_cache,
patch(f"{MODULE}.Session", return_value=session),
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
):
mock_config.ENTERPRISE_ENABLED = False
@@ -1232,26 +1300,8 @@ class TestPluginModelProviderCacheInvalidation:
from core.plugin.plugin_service import PluginService
result = PluginService.uninstall(TENANT_ID, "installation-1")
result = PluginService.uninstall("tenant-1", "installation-1")
assert result is True
installer.uninstall.assert_called_once_with(TENANT_ID, "installation-1")
invalidate_cache.assert_called_once_with(TENANT_ID)
credentials_cache.assert_called_once_with(
tenant_id=TENANT_ID,
identity_id=provider_id,
cache_type=ProviderCredentialsCacheType.PROVIDER,
)
credentials_cache.return_value.delete.assert_called_once_with()
sqlite_session.expunge_all()
assert sqlite_session.get(ProviderCredential, credential_id) is None
persisted_provider = sqlite_session.get(Provider, provider_id)
assert persisted_provider is not None
assert persisted_provider.credential_id is None
assert sqlite_session.get(TenantPreferredModelProvider, preferred_provider_id) is None
assert sqlite_session.get(ProviderCredential, other_credential_id) is not None
persisted_other_provider = sqlite_session.get(Provider, other_provider_id)
assert persisted_other_provider is not None
assert persisted_other_provider.credential_id == other_credential_id
assert sqlite_session.get(TenantPreferredModelProvider, other_preferred_provider_id) is not None
installer.uninstall.assert_called_once_with("tenant-1", "installation-1")
invalidate_cache.assert_called_once_with("tenant-1")
@@ -8,7 +8,6 @@ verification, marketplace upgrade flows, and uninstall with credential cleanup.
from __future__ import annotations
from collections.abc import Iterator
from typing import cast
from unittest.mock import MagicMock, patch
from uuid import uuid4
@@ -20,6 +19,7 @@ from sqlalchemy.orm import Session
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginVerification
from core.plugin.plugin_service import PluginService
from enums.deployment_edition import DeploymentEdition
from models import ProviderType
from models.engine import db
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
@@ -27,16 +27,20 @@ from services.errors.plugin import PluginInstallationForbiddenError
from services.feature_service import (
PluginInstallationPermissionModel,
PluginInstallationScope,
SystemFeatureModel,
)
def _make_permission(
def _make_features(
restrict_to_marketplace: bool = False,
scope: PluginInstallationScope = PluginInstallationScope.ALL,
) -> PluginInstallationPermissionModel:
return PluginInstallationPermissionModel(
restrict_to_marketplace_only=restrict_to_marketplace,
plugin_installation_scope=scope,
) -> SystemFeatureModel:
return SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
plugin_installation_permission=PluginInstallationPermissionModel(
restrict_to_marketplace_only=restrict_to_marketplace,
plugin_installation_scope=scope,
),
)
@@ -115,31 +119,22 @@ class TestFetchLatestPluginVersion:
class TestCheckMarketplaceOnlyPermission:
@patch("core.plugin.plugin_service.FeatureService")
def test_raises_when_restricted(self, mock_fs):
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=True)
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=True)
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_marketplace_only_permission()
@patch("core.plugin.plugin_service.FeatureService")
def test_passes_when_not_restricted(self, mock_fs):
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=False)
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=False)
PluginService._check_marketplace_only_permission() # should not raise
@patch("core.plugin.plugin_service.FeatureService")
def test_raises_when_scope_denies_all(self, mock_fs):
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
with pytest.raises(PluginInstallationForbiddenError, match="not allowed"):
PluginService._check_marketplace_only_permission()
class TestCheckPluginInstallationScope:
@patch("core.plugin.plugin_service.FeatureService")
def test_official_only_allows_langgenius(self, mock_fs):
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
scope=PluginInstallationScope.OFFICIAL_ONLY
)
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
verification = MagicMock()
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
@@ -147,16 +142,14 @@ class TestCheckPluginInstallationScope:
@patch("core.plugin.plugin_service.FeatureService")
def test_official_only_rejects_third_party(self, mock_fs):
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
scope=PluginInstallationScope.OFFICIAL_ONLY
)
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_plugin_installation_scope(None)
@patch("core.plugin.plugin_service.FeatureService")
def test_official_and_partners_allows_partner(self, mock_fs):
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
mock_fs.get_system_features.return_value = _make_features(
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
)
verification = MagicMock()
@@ -166,7 +159,7 @@ class TestCheckPluginInstallationScope:
@patch("core.plugin.plugin_service.FeatureService")
def test_official_and_partners_rejects_none(self, mock_fs):
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
mock_fs.get_system_features.return_value = _make_features(
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
)
@@ -175,7 +168,7 @@ class TestCheckPluginInstallationScope:
@patch("core.plugin.plugin_service.FeatureService")
def test_none_scope_always_raises(self, mock_fs):
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.NONE)
verification = MagicMock()
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
@@ -184,19 +177,10 @@ class TestCheckPluginInstallationScope:
@patch("core.plugin.plugin_service.FeatureService")
def test_all_scope_passes_any(self, mock_fs):
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.ALL)
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.ALL)
PluginService._check_plugin_installation_scope(None) # should not raise
@patch("core.plugin.plugin_service.FeatureService")
def test_unknown_scope_always_raises(self, mock_fs):
permission = _make_permission()
permission.plugin_installation_scope = cast(PluginInstallationScope, "unknown-scope")
mock_fs.get_plugin_installation_permission.return_value = permission
with pytest.raises(PluginInstallationForbiddenError, match="policy is invalid"):
PluginService._check_plugin_installation_scope(None)
class TestGetPluginIconUrl:
@patch("core.plugin.plugin_service.dify_config")
@@ -264,7 +248,7 @@ class TestUpgradePluginWithMarketplace:
@patch("core.plugin.plugin_service.dify_config")
def test_skips_download_when_already_installed(self, mock_config, mock_installer_cls, mock_fs, mock_marketplace):
mock_config.MARKETPLACE_ENABLED = True
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
mock_fs.get_system_features.return_value = _make_features()
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.return_value = MagicMock()
installer.upgrade_plugin.return_value = MagicMock()
@@ -280,7 +264,7 @@ class TestUpgradePluginWithMarketplace:
@patch("core.plugin.plugin_service.dify_config")
def test_downloads_when_not_installed(self, mock_config, mock_installer_cls, mock_fs, mock_download):
mock_config.MARKETPLACE_ENABLED = True
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
mock_fs.get_system_features.return_value = _make_features()
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
mock_download.return_value = b"pkg-bytes"
@@ -299,7 +283,7 @@ class TestUpgradePluginWithGithub:
@patch("core.plugin.plugin_service.FeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls: MagicMock, mock_fs: MagicMock):
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
mock_fs.get_system_features.return_value = _make_features()
installer = mock_installer_cls.return_value
installer.upgrade_plugin.return_value = MagicMock()
@@ -314,7 +298,7 @@ class TestUploadPkg:
@patch("core.plugin.plugin_service.FeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_runs_permission_and_scope_checks(self, mock_installer_cls: MagicMock, mock_fs: MagicMock):
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
mock_fs.get_system_features.return_value = _make_features()
upload_resp = MagicMock()
upload_resp.verification = None
mock_installer_cls.return_value.upload_pkg.return_value = upload_resp
@@ -338,7 +322,7 @@ class TestInstallFromMarketplacePkg:
@patch("core.plugin.plugin_service.dify_config")
def test_downloads_when_not_cached(self, mock_config, mock_installer_cls, mock_fs, mock_download):
mock_config.MARKETPLACE_ENABLED = True
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
mock_fs.get_system_features.return_value = _make_features()
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
mock_download.return_value = b"pkg"
@@ -360,7 +344,7 @@ class TestInstallFromMarketplacePkg:
@patch("core.plugin.plugin_service.dify_config")
def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls: MagicMock, mock_fs: MagicMock):
mock_config.MARKETPLACE_ENABLED = True
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
mock_fs.get_system_features.return_value = _make_features()
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.return_value = MagicMock()
decode_resp = MagicMock()
@@ -10,19 +10,14 @@ import io
import json
import logging
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from unittest.mock import Mock, patch
from unittest.mock import Mock, create_autospec, patch
import pytest
from pydantic import ValidationError
from sqlalchemy import Column, Engine, Integer, MetaData, String, Table, delete, event, func, select
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy import Column, Integer, MetaData, String, Table
from libs.archive_storage import ArchiveStorageNotConfiguredError
from models.enums import CreatorUserRole
from models.trigger import WorkflowTriggerLog
from models.workflow import (
WorkflowAppLog,
@@ -33,7 +28,6 @@ from models.workflow import (
WorkflowPauseReason,
WorkflowRun,
)
from services.retention.workflow_run import restore_archived_workflow_run as restore_module
from services.retention.workflow_run.restore_archived_workflow_run import (
SCHEMA_MAPPERS,
TABLE_MODELS,
@@ -42,49 +36,24 @@ from services.retention.workflow_run.restore_archived_workflow_run import (
)
@dataclass(frozen=True)
class Database:
"""Explicit SQLite engine, caller session, and real service-owned session factory."""
engine: Engine
session: Session
session_maker: sessionmaker[Session]
@pytest.fixture
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
WorkflowRun.metadata.create_all(
sqlite_engine,
tables=[WorkflowRun.__table__, WorkflowAppLog.__table__, WorkflowArchiveLog.__table__],
)
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
with session_maker() as session:
database = Database(engine=sqlite_engine, session=session, session_maker=session_maker)
monkeypatch.setattr(restore_module, "db", database)
# Production constructs PostgreSQL's equivalent statement; SQLite's
# dialect keeps the conflict behavior executable in these tests.
monkeypatch.setattr(restore_module, "pg_insert", sqlite_insert)
yield database
class WorkflowRunRestoreTestDataFactory:
"""
Factory for creating persisted-model-compatible test data.
Factory for creating test data and mock objects.
Provides reusable methods to create consistent mock objects for testing
workflow run restore operations.
"""
@staticmethod
def create_workflow_run(
def create_workflow_run_mock(
run_id: str = "run-123",
tenant_id: str = "tenant-123",
app_id: str = "app-123",
created_at: datetime | None = None,
**kwargs,
) -> WorkflowRun:
) -> Mock:
"""
Create a concrete WorkflowRun object.
Create a mock WorkflowRun object.
Args:
run_id: Unique identifier for the workflow run
@@ -94,44 +63,27 @@ class WorkflowRunRestoreTestDataFactory:
**kwargs: Additional attributes to set on the mock
Returns:
WorkflowRun object with specified attributes
Mock WorkflowRun object with specified attributes
"""
attrs = {
"id": run_id,
"tenant_id": tenant_id,
"app_id": app_id,
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app-run",
"version": "1",
"graph": None,
"inputs": None,
"status": "succeeded",
"outputs": "{}",
"error": None,
"elapsed_time": 0,
"total_tokens": 0,
"total_steps": 0,
"created_by_role": CreatorUserRole.ACCOUNT,
"created_by": "user-123",
"created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
"finished_at": None,
"exceptions_count": 0,
}
attrs.update(kwargs)
run = WorkflowRun(**attrs)
run = create_autospec(WorkflowRun, instance=True)
run.id = run_id
run.tenant_id = tenant_id
run.app_id = app_id
run.created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
for key, value in kwargs.items():
setattr(run, key, value)
return run
@staticmethod
def create_workflow_archive_log(
def create_workflow_archive_log_mock(
run_id: str = "run-123",
tenant_id: str = "tenant-123",
app_id: str = "app-123",
created_at: datetime | None = None,
**kwargs,
) -> WorkflowArchiveLog:
) -> Mock:
"""
Create a concrete WorkflowArchiveLog object.
Create a mock WorkflowArchiveLog object.
Args:
run_id: Unique identifier for the workflow run
@@ -141,32 +93,16 @@ class WorkflowRunRestoreTestDataFactory:
**kwargs: Additional attributes to set on the mock
Returns:
WorkflowArchiveLog object with specified attributes
Mock WorkflowArchiveLog object with specified attributes
"""
attrs = {
"tenant_id": tenant_id,
"app_id": app_id,
"workflow_id": "workflow-123",
"workflow_run_id": run_id,
"created_by_role": CreatorUserRole.ACCOUNT,
"created_by": "user-123",
"log_id": None,
"log_created_at": None,
"log_created_from": None,
"run_version": "1",
"run_status": "succeeded",
"run_triggered_from": "app-run",
"run_error": None,
"run_elapsed_time": 0,
"run_total_tokens": 0,
"run_total_steps": 0,
"run_created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
"run_finished_at": None,
"run_exceptions_count": 0,
"trigger_metadata": None,
}
attrs.update(kwargs)
return WorkflowArchiveLog(**attrs)
archive_log = create_autospec(WorkflowArchiveLog, instance=True)
archive_log.workflow_run_id = run_id
archive_log.tenant_id = tenant_id
archive_log.app_id = app_id
archive_log.run_created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
for key, value in kwargs.items():
setattr(archive_log, key, value)
return archive_log
@staticmethod
def create_archive_zip_mock(
@@ -201,7 +137,7 @@ class WorkflowRunRestoreTestDataFactory:
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app-run",
"triggered_from": "app",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
@@ -215,7 +151,7 @@ class WorkflowRunRestoreTestDataFactory:
"app_id": "app-123",
"workflow_id": "workflow-123",
"workflow_run_id": "run-123",
"created_from": "service-api",
"created_from": "app",
"created_by_role": "account",
"created_by": "user-123",
},
@@ -225,7 +161,7 @@ class WorkflowRunRestoreTestDataFactory:
"app_id": "app-123",
"workflow_id": "workflow-123",
"workflow_run_id": "run-123",
"created_from": "service-api",
"created_from": "app",
"created_by_role": "account",
"created_by": "user-123",
},
@@ -289,10 +225,14 @@ class TestGetWorkflowRunRepo:
"""Tests for WorkflowRunRestore._get_workflow_run_repo method."""
@patch("services.retention.workflow_run.restore_archived_workflow_run.DifyAPIRepositoryFactory")
def test_first_call_creates_repo(self, mock_factory, database: Database):
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
@patch("services.retention.workflow_run.restore_archived_workflow_run.db")
def test_first_call_creates_repo(self, mock_db, mock_sessionmaker, mock_factory):
"""First call should create and cache repository."""
restore = WorkflowRunRestore()
mock_session = Mock()
mock_sessionmaker.return_value = mock_session
mock_repo = Mock()
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
@@ -300,9 +240,8 @@ class TestGetWorkflowRunRepo:
assert result is mock_repo
assert restore.workflow_run_repo is mock_repo
session_maker = mock_factory.create_api_workflow_run_repository.call_args.args[0]
assert isinstance(session_maker, sessionmaker)
assert session_maker.kw["bind"] is database.engine
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
mock_factory.create_api_workflow_run_repository.assert_called_once_with(mock_session)
def test_cached_repo_returned(self):
"""Subsequent calls should return cached repository."""
@@ -553,27 +492,47 @@ class TestGetModelColumnInfo:
class TestRestoreTableRecords:
"""Tests for WorkflowRunRestore._restore_table_records method."""
def test_unknown_table_returns_zero(self, database: Database, caplog: pytest.LogCaptureFixture):
@patch("services.retention.workflow_run.restore_archived_workflow_run.TABLE_MODELS")
def test_unknown_table_returns_zero(self, mock_table_models, caplog: pytest.LogCaptureFixture):
"""Should return 0 for unknown table."""
restore = WorkflowRunRestore()
mock_table_models.get.return_value = None
mock_session = Mock()
records = [{"id": "test"}]
caplog.set_level(logging.WARNING, logger="services.retention.workflow_run.restore_archived_workflow_run")
result = restore._restore_table_records(database.session, "unknown_table", records, schema_version="1.0")
result = restore._restore_table_records(mock_session, "unknown_table", records, schema_version="1.0")
assert result == 0
assert "Unknown table: unknown_table" in caplog.messages
def test_empty_records_returns_zero(self, database: Database):
def test_empty_records_returns_zero(self):
"""Should return 0 for empty records list."""
restore = WorkflowRunRestore()
result = restore._restore_table_records(database.session, "workflow_runs", [], schema_version="1.0")
mock_session = Mock()
result = restore._restore_table_records(mock_session, "workflow_runs", [], schema_version="1.0")
assert result == 0
def test_successful_restore(self, database: Database):
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
def test_successful_restore(self, mock_cast, mock_pg_insert):
"""Should successfully restore records."""
restore = WorkflowRunRestore()
# Mock session and execution
mock_session = Mock()
mock_result = Mock()
mock_result.rowcount = 2
mock_session.execute.return_value = mock_result
mock_cast.return_value = mock_result
# Mock insert statement
mock_stmt = Mock()
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
mock_pg_insert.return_value = mock_stmt
records = [
{
"id": "test1",
@@ -581,7 +540,7 @@ class TestRestoreTableRecords:
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app-run",
"triggered_from": "app",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
@@ -593,7 +552,7 @@ class TestRestoreTableRecords:
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app-run",
"triggered_from": "app",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
@@ -601,20 +560,38 @@ class TestRestoreTableRecords:
},
]
result = restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
result = restore._restore_table_records(mock_session, "workflow_runs", records, schema_version="1.0")
assert result == 2
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 2
assert restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0") == 0
mock_session.execute.assert_called_once()
def test_missing_required_columns_raises_error(self, database: Database):
def test_missing_required_columns_raises_error(self):
"""Should raise ValueError for missing required columns."""
restore = WorkflowRunRestore()
records = [{"id": "test"}]
mock_session = Mock()
# Use a dedicated mock model to isolate required-column validation behavior.
mock_model = Mock()
with pytest.raises(ValueError, match="Missing required columns for workflow_runs"):
restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
# Mock a required column
required_column = Mock()
required_column.key = "required_field"
required_column.nullable = False
required_column.default = None
required_column.server_default = None
required_column.autoincrement = False
required_column.type = Mock()
# Mock the __table__ attribute properly
mock_table = Mock()
mock_table.columns = [required_column]
mock_model.__table__ = mock_table
records = [{"name": "test"}] # Missing required 'required_field'
with patch.dict(TABLE_MODELS, {"test_table": mock_model}):
with pytest.raises(ValueError, match="Missing required columns for test_table"):
restore._restore_table_records(mock_session, "test_table", records, schema_version="1.0")
# ---------------------------------------------------------------------------
@@ -626,38 +603,38 @@ class TestRestoreFromRun:
"""Tests for WorkflowRunRestore._restore_from_run method."""
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_archive_storage_not_configured(self, mock_get_storage, database: Database):
def test_archive_storage_not_configured(self, mock_get_storage):
"""Should handle ArchiveStorageNotConfiguredError."""
restore = WorkflowRunRestore()
mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("Storage not configured")
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
result = restore._restore_from_run(run, session_maker=database.session_maker)
result = restore._restore_from_run(run, session_maker=lambda: Mock())
assert result.success is False
assert "Storage not configured" in result.error
assert result.elapsed_time > 0
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_archive_bundle_not_found(self, mock_get_storage, database: Database):
def test_archive_bundle_not_found(self, mock_get_storage):
"""Should handle FileNotFoundError when archive bundle is missing."""
restore = WorkflowRunRestore()
mock_storage = Mock()
mock_storage.get_object.side_effect = FileNotFoundError("Bundle not found")
mock_get_storage.return_value = mock_storage
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
result = restore._restore_from_run(run, session_maker=database.session_maker)
result = restore._restore_from_run(run, session_maker=lambda: Mock())
assert result.success is False
assert "Archive bundle not found" in result.error
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_dry_run_mode(self, mock_get_storage, database: Database):
def test_dry_run_mode(self, mock_get_storage):
"""Should handle dry run mode correctly."""
restore = WorkflowRunRestore(dry_run=True)
@@ -667,16 +644,23 @@ class TestRestoreFromRun:
mock_storage.get_object.return_value = archive_data
mock_get_storage.return_value = mock_storage
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
result = restore._restore_from_run(run, session_maker=database.session_maker)
# Create a proper mock session with context manager support
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
assert result.success is True
assert result.restored_counts["workflow_runs"] == 1
assert result.restored_counts["workflow_app_logs"] == 2
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_successful_restore(self, mock_get_storage, database: Database):
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
def test_successful_restore(self, mock_cast, mock_pg_insert, mock_get_storage):
"""Should successfully restore from archive."""
restore = WorkflowRunRestore()
@@ -686,57 +670,53 @@ class TestRestoreFromRun:
mock_storage.get_object.return_value = archive_data
mock_get_storage.return_value = mock_storage
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
# Mock session with context manager support
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
def session_maker():
return mock_session
# Mock database execution to return integer counts
mock_result_workflow_runs = Mock()
mock_result_workflow_runs.rowcount = 1
mock_result_app_logs = Mock()
mock_result_app_logs.rowcount = 2
# Configure session.execute to return different results based on the table
def mock_execute(stmt):
if "workflow_runs" in str(stmt):
return mock_result_workflow_runs
else:
return mock_result_app_logs
mock_session.execute.side_effect = mock_execute
mock_cast.return_value = mock_result_workflow_runs
# Mock insert statement
mock_stmt = Mock()
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
mock_pg_insert.return_value = mock_stmt
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
# Mock repository methods
with patch.object(restore, "_get_workflow_run_repo") as mock_get_repo:
mock_repo = Mock()
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
)
mock_get_repo.return_value = mock_repo
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
result = restore._restore_from_run(run, session_maker=database.session_maker)
result = restore._restore_from_run(run, session_maker=session_maker)
assert result.success is True
assert result.restored_counts["workflow_runs"] == 1
assert result.restored_counts["workflow_app_logs"] == 2
database.session.expire_all()
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 2
assert database.session.scalar(select(func.count(WorkflowArchiveLog.id))) == 0
assert result.restored_counts["workflow_app_logs"] >= 1 # Just check it's restored
mock_session.commit.assert_called_once()
mock_repo.delete_archive_log_by_run_id.assert_called_once_with(mock_session, run.id)
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_insert_failure_rolls_back_all_tables(self, mock_get_storage, database: Database):
"""A later table failure must roll back earlier restored rows."""
restore = WorkflowRunRestore()
mock_storage = Mock()
mock_storage.get_object.return_value = WorkflowRunRestoreTestDataFactory.create_archive_zip_mock()
mock_get_storage.return_value = mock_storage
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
def fail_app_log_insert(_connection, _cursor, statement, _parameters, _context, _executemany):
if statement.startswith("INSERT INTO workflow_app_logs"):
raise RuntimeError("forced app-log insert failure")
event.listen(database.engine, "before_cursor_execute", fail_app_log_insert)
try:
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
result = restore._restore_from_run(run, session_maker=database.session_maker)
finally:
event.remove(database.engine, "before_cursor_execute", fail_app_log_insert)
assert result.success is False
assert result.error == "forced app-log insert failure"
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 0
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 0
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_invalid_archive_bundle(self, mock_get_storage, database: Database):
def test_invalid_archive_bundle(self, mock_get_storage):
"""Should handle invalid archive bundle."""
restore = WorkflowRunRestore()
@@ -745,17 +725,22 @@ class TestRestoreFromRun:
mock_storage.get_object.return_value = b"invalid zip data"
mock_get_storage.return_value = mock_storage
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
# Create proper mock session
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
result = restore._restore_from_run(run, session_maker=database.session_maker)
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
assert result.success is False
# The error message comes from zipfile.BadZipFile which says "File is not a zip file"
assert "File is not a zip file" in result.error
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_workflow_archive_log_input(self, mock_get_storage, database: Database):
def test_workflow_archive_log_input(self, mock_get_storage):
"""Should handle WorkflowArchiveLog input correctly."""
restore = WorkflowRunRestore(dry_run=True)
@@ -765,11 +750,14 @@ class TestRestoreFromRun:
mock_storage.get_object.return_value = archive_data
mock_get_storage.return_value = mock_storage
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
result = restore._restore_from_run(archive_log, session_maker=database.session_maker)
# Create proper mock session
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
result = restore._restore_from_run(archive_log, session_maker=lambda: mock_session)
assert result.success is True
assert result.run_id == archive_log.workflow_run_id
@@ -784,29 +772,39 @@ class TestRestoreFromRun:
class TestRestoreBatch:
"""Tests for WorkflowRunRestore.restore_batch method."""
def test_empty_tenant_ids_returns_empty(self, database: Database):
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
def test_empty_tenant_ids_returns_empty(self, mock_sessionmaker):
"""Should return empty list when tenant_ids is empty list."""
restore = WorkflowRunRestore()
result = restore.restore_batch(
tenant_ids=[],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
# Mock db.engine to avoid SQLAlchemy issues
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
result = restore.restore_batch(
tenant_ids=[],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
assert result == []
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
def test_successful_batch_restore(self, mock_executor, database: Database):
def test_successful_batch_restore(self, mock_executor):
"""Should successfully restore batch of workflow runs."""
restore = WorkflowRunRestore(workers=2)
# Mock session that supports context manager protocol
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
# Mock session factory that returns context manager sessions
mock_session_factory = Mock(return_value=mock_session)
# Mock repository and archive logs
mock_repo = Mock()
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-1")
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-2")
database.session.add_all([archive_log1, archive_log2])
database.session.commit()
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-1")
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-2")
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log1, archive_log2]
# Mock restore results
@@ -823,25 +821,38 @@ class TestRestoreBatch:
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch.object(restore, "_restore_from_run", side_effect=[result1, result2]):
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
results = restore.restore_batch(
tenant_ids=["tenant-1"],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
with patch(
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
) as mock_sessionmaker:
mock_sessionmaker.return_value = mock_session_factory
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
results = restore.restore_batch(
tenant_ids=["tenant-1"],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
assert len(results) == 2
assert results[0].run_id == "run-1"
assert results[1].run_id == "run-2"
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
def test_dry_run_batch_restore(self, mock_executor, database: Database):
def test_dry_run_batch_restore(self, mock_executor):
"""Should handle dry run mode for batch restore."""
restore = WorkflowRunRestore(dry_run=True)
# Mock session that supports context manager protocol
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
# Mock session factory that returns context manager sessions
mock_session_factory = Mock(return_value=mock_session)
mock_repo = Mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log]
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
@@ -856,11 +867,18 @@ class TestRestoreBatch:
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch.object(restore, "_restore_from_run", return_value=result):
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
results = restore.restore_batch(
tenant_ids=["tenant-1"],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
with patch(
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
) as mock_sessionmaker:
mock_sessionmaker.return_value = mock_session_factory
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
results = restore.restore_batch(
tenant_ids=["tenant-1"],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
assert len(results) == 1
assert results[0].success is True
@@ -889,14 +907,16 @@ class TestRestoreByRunId:
assert "not found" in result.error
assert result.run_id == "nonexistent-run"
def test_successful_restore_by_id(self, database: Database):
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
def test_successful_restore_by_id(self, mock_sessionmaker):
"""Should successfully restore by run ID."""
restore = WorkflowRunRestore()
mock_session = Mock()
mock_sessionmaker.return_value = mock_session
mock_repo = Mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
mock_repo.get_archived_log_by_run_id.return_value = archive_log
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={})
@@ -904,19 +924,24 @@ class TestRestoreByRunId:
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch.object(restore, "_restore_from_run", return_value=result):
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
actual_result = restore.restore_by_run_id("run-1")
# Mock db.engine to avoid SQLAlchemy issues
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
actual_result = restore.restore_by_run_id("run-1")
assert actual_result.success is True
assert actual_result.run_id == "run-1"
def test_dry_run_restore_by_id(self, database: Database):
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
def test_dry_run_restore_by_id(self, mock_sessionmaker):
"""Should handle dry run mode for restore by ID."""
restore = WorkflowRunRestore(dry_run=True)
mock_session = Mock()
mock_sessionmaker.return_value = mock_session
mock_repo = Mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
mock_repo.get_archived_log_by_run_id.return_value = archive_log
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
@@ -924,7 +949,10 @@ class TestRestoreByRunId:
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch.object(restore, "_restore_from_run", return_value=result):
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
actual_result = restore.restore_by_run_id("run-1")
# Mock db.engine to avoid SQLAlchemy issues
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
actual_result = restore.restore_by_run_id("run-1")
assert actual_result.success is True
assert actual_result.run_id == "run-1"
@@ -1010,7 +1038,8 @@ class TestIntegration:
"""Integration tests combining multiple components."""
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_full_restore_flow(self, mock_get_storage, database: Database):
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
def test_full_restore_flow(self, mock_executor, mock_get_storage):
"""Test complete restore flow with all components."""
restore = WorkflowRunRestore(workers=1)
@@ -1030,7 +1059,7 @@ class TestIntegration:
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app-run",
"triggered_from": "app",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
@@ -1043,20 +1072,48 @@ class TestIntegration:
mock_storage.get_object.return_value = archive_data
mock_get_storage.return_value = mock_storage
# Mock session that supports context manager protocol
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
# Mock session factory that returns context manager sessions
mock_session_factory = Mock(return_value=mock_session)
mock_result = Mock()
mock_result.rowcount = 1
mock_session.execute.return_value = mock_result
# Mock repository
mock_repo = Mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
mock_repo.get_archived_log_by_run_id.return_value = archive_log
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
)
# Mock ThreadPoolExecutor (not actually used in restore_by_run_id but needed for patch)
mock_executor_instance = Mock()
mock_executor_instance.__enter__ = Mock(return_value=mock_executor_instance)
mock_executor_instance.__exit__ = Mock(return_value=None)
mock_executor_instance.map = Mock(return_value=[])
mock_executor.return_value = mock_executor_instance
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
result = restore.restore_by_run_id("run-123")
with patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert") as mock_insert:
mock_stmt = Mock()
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
mock_insert.return_value = mock_stmt
with patch("services.retention.workflow_run.restore_archived_workflow_run.cast") as mock_cast:
mock_cast.return_value = mock_result
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
with patch(
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
) as mock_sessionmaker:
mock_sessionmaker.return_value = mock_session_factory
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
result = restore.restore_by_run_id("run-123")
assert result.success is True
assert result.restored_counts.get("workflow_runs") == 1
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
@@ -1,4 +1,5 @@
import json
from collections.abc import Iterator
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from uuid import UUID
@@ -10,6 +11,7 @@ from sqlalchemy.orm import Session
from configs import dify_config
from models.account import (
Account,
AccountIntegrate,
AccountStatus,
Tenant,
TenantAccountJoin,
@@ -112,6 +114,22 @@ class TestAccountService:
- Error conditions and edge cases
"""
@pytest.fixture
def sqlite_session(self, sqlite_engine) -> Iterator[Session]:
"""SQLite session with the account/workspace tables these service tests touch."""
tables = [
model.metadata.tables[model.__tablename__]
for model in (
Account,
Tenant,
TenantAccountJoin,
TenantPluginAutoUpgradeStrategy,
)
]
Account.metadata.create_all(sqlite_engine, tables=tables)
with Session(sqlite_engine, expire_on_commit=False) as session:
yield session
@pytest.fixture
def mock_password_dependencies(self):
"""Mock setup for password-related functions."""
@@ -1246,6 +1264,24 @@ class TestRegisterService:
- Error conditions and edge cases
"""
@pytest.fixture
def sqlite_session(self, sqlite_engine) -> Iterator[Session]:
"""SQLite session with the account/workspace tables registration flows touch."""
tables = [
model.metadata.tables[model.__tablename__]
for model in (
Account,
AccountIntegrate,
Tenant,
TenantAccountJoin,
TenantPluginAutoUpgradeStrategy,
DifySetup,
)
]
Account.metadata.create_all(sqlite_engine, tables=tables)
with Session(sqlite_engine, expire_on_commit=False) as session:
yield session
@pytest.fixture
def mock_redis_dependencies(self):
"""Mock setup for Redis-related functions."""
@@ -63,7 +63,6 @@ def _target(
) -> AgentConfigTarget:
agent_soul = soul or _soul()
return AgentConfigTarget(
tenant_id=TENANT,
agent_id=AGENT,
version_id=version_id,
kind=kind,
@@ -509,9 +508,7 @@ def test_manifest_uses_items_shape_without_download_urls() -> None:
),
)
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
skill_management_service.return_value.list_runtime_agent_skills.return_value = []
manifest = AgentConfigService._manifest_for_target(target)
manifest = AgentConfigService._manifest_for_target(target)
assert manifest == {
"agent_id": AGENT,
@@ -560,9 +557,7 @@ def test_manifest_preserves_missing_config_assets_and_pull_rejects_them() -> Non
target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False, soul=soul)
service = AgentConfigService()
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
skill_management_service.return_value.list_runtime_agent_skills.return_value = []
manifest = service._manifest_for_target(target)
manifest = service._manifest_for_target(target)
assert manifest["skills"]["items"][0]["is_missing"] is True # type: ignore[index]
assert manifest["files"]["items"][0]["is_missing"] is True # type: ignore[index]
@@ -611,44 +606,6 @@ def test_config_asset_refs_require_file_id_unless_marked_missing() -> None:
)
def test_manifest_appends_published_workspace_skills() -> None:
target = _target(
kind=AgentConfigVersionKind.DRAFT,
writable=False,
soul=_soul(
config_skills=[
AgentConfigSkillRefConfig(name="alpha", description="Alpha skill", file_id="tool-file-1")
]
),
)
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
skill_management_service.return_value.list_runtime_agent_skills.return_value = [
{
"id": "workspace-skill-id",
"name": "beta",
"file_id": "tool-file-2",
"description": "Beta workspace skill",
"size": 123,
"hash": "sha256:beta",
"mime_type": "application/zip",
},
{
"id": "duplicate",
"name": "alpha",
"file_id": "tool-file-ignored",
"description": "Duplicate workspace skill",
"size": 456,
"hash": "sha256:ignored",
"mime_type": "application/zip",
},
]
manifest = AgentConfigService._manifest_for_target(target)
assert [item["name"] for item in manifest["skills"]["items"]] == ["alpha", "beta"]
assert manifest["skills"]["items"][1]["file_id"] == "tool-file-2"
def test_preview_skill_file_returns_text_preview() -> None:
service = AgentConfigService()
target = _target(
@@ -1,23 +1,19 @@
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from sqlalchemy import event
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from graphon.model_runtime.entities.model_entities import ModelType
from models import Account
from models.model import App, AppMode, AppModelConfig, IconType
from models.model import App, AppMode, AppModelConfig
from models.workflow import Workflow
from services.agent.errors import AgentNameConflictError
from services.app_service import AppListParams, AppService, CreateAppParams
from services.app_service import AppService, CreateAppParams
class TestCreateAppTransactionBoundary:
@@ -240,92 +236,6 @@ class TestOpenapiVisibilityHelpers:
mock_session.execute.assert_called_once()
@pytest.mark.parametrize("sqlite_session", [(Account, App, AppModelConfig)], indirect=True)
def test_get_recent_apps_uses_one_tenant_scoped_projection_query(sqlite_session: Session) -> None:
tenant_id = str(uuid4())
other_tenant_id = str(uuid4())
account = Account(name="Recent Apps Author", email="recent-apps@example.com")
sqlite_session.add(account)
sqlite_session.flush()
def create_app(*, name: str, tenant_id: str, updated_at: datetime, mode: AppMode = AppMode.CHAT) -> App:
app = App()
app.id = str(uuid4())
app.tenant_id = tenant_id
app.name = name
app.description = ""
app.mode = mode
app.icon_type = IconType.EMOJI
app.icon = "🚀"
app.icon_background = "#FFFFFF"
app.enable_site = False
app.enable_api = False
app.created_by = account.id
app.maintainer = account.id
app.created_at = updated_at
app.updated_at = updated_at
app.use_icon_as_answer_icon = False
return app
newest = create_app(name="Newest", tenant_id=tenant_id, updated_at=datetime(2026, 7, 3))
legacy_agent = AppModelConfig(app_id=newest.id)
legacy_agent.agent_mode = '{"enabled": true, "strategy": "react"}'
newest.app_model_config_id = legacy_agent.id
second = create_app(
name="Second",
tenant_id=tenant_id,
updated_at=datetime(2026, 7, 2),
mode=AppMode.WORKFLOW,
)
second.icon_type = None
second.icon = None
second.icon_background = None
second.created_by = None
second.maintainer = None
channel = create_app(
name="Channel",
tenant_id=tenant_id,
updated_at=datetime(2026, 7, 5),
mode=AppMode.CHANNEL,
)
rag_pipeline = create_app(
name="RAG Pipeline",
tenant_id=tenant_id,
updated_at=datetime(2026, 7, 4),
mode=AppMode.RAG_PIPELINE,
)
oldest = create_app(name="Oldest", tenant_id=tenant_id, updated_at=datetime(2026, 7, 1))
foreign = create_app(name="Foreign", tenant_id=other_tenant_id, updated_at=datetime(2026, 7, 4))
sqlite_session.add_all([newest, legacy_agent, second, channel, rag_pipeline, oldest, foreign])
sqlite_session.commit()
statements: list[str] = []
bind = sqlite_session.get_bind()
def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
statements.append(statement)
event.listen(bind, "before_cursor_execute", record_sql)
try:
recent_apps = AppService().get_recent_apps(
account.id,
tenant_id,
AppListParams(limit=2),
sqlite_session,
)
finally:
event.remove(bind, "before_cursor_execute", record_sql)
assert [(app.name, app.mode, app.icon_type, app.author_name, app.maintainer) for app in recent_apps] == [
("Newest", AppMode.CHAT, IconType.EMOJI, "Recent Apps Author", account.id),
("Second", AppMode.WORKFLOW, None, None, None),
]
select_statements = [statement for statement in statements if statement.lstrip().upper().startswith("SELECT")]
assert len(select_statements) == 1
assert "count(" not in select_statements[0].lower()
assert "app_model_configs" not in select_statements[0].lower()
class TestAppMeta:
def test_loads_workflow_with_caller_session(self):
session = MagicMock()
@@ -1,92 +0,0 @@
import logging
import pytest
from enums.deployment_edition import DeploymentEdition
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, PluginInstallationScope, SystemFeatureModel
def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False)
permission = FeatureService.get_plugin_installation_permission()
assert permission.plugin_installation_scope is PluginInstallationScope.ALL
assert permission.restrict_to_marketplace_only is False
def test_get_plugin_installation_permission_parses_enterprise_policy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", True)
monkeypatch.setattr(
feature_service_module.EnterpriseService,
"get_info",
staticmethod(
lambda: {
"PluginInstallationPermission": {
"pluginInstallationScope": "official_only",
"restrictToMarketplaceOnly": True,
}
}
),
)
permission = FeatureService.get_plugin_installation_permission()
assert permission.plugin_installation_scope is PluginInstallationScope.OFFICIAL_ONLY
assert permission.restrict_to_marketplace_only is True
@pytest.mark.parametrize(
"invalid_permission",
[
{
"pluginInstallationScope": "unknown-scope",
"restrictToMarketplaceOnly": False,
},
{
"pluginInstallationScope": "all",
"restrictToMarketplaceOnly": "false",
},
],
ids=["unknown_scope", "non_boolean_marketplace_restriction"],
)
def test_invalid_enterprise_policy_denies_all_plugin_installations(
caplog: pytest.LogCaptureFixture,
invalid_permission: dict[str, object],
) -> None:
with caplog.at_level(logging.ERROR, logger="services.feature_service"):
permission = FeatureService._resolve_plugin_installation_permission(
{"PluginInstallationPermission": invalid_permission}
)
assert permission.plugin_installation_scope is PluginInstallationScope.NONE
assert permission.restrict_to_marketplace_only is True
assert "denying all plugin installations" in caplog.text
def test_system_features_exposes_only_validated_plugin_installation_policy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
feature_service_module.EnterpriseService,
"get_info",
staticmethod(
lambda: {
"PluginInstallationPermission": {
"pluginInstallationScope": "unknown-scope",
"restrictToMarketplaceOnly": False,
}
}
),
)
features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE)
FeatureService._fulfill_params_from_enterprise(features)
assert features.plugin_installation_permission.plugin_installation_scope is PluginInstallationScope.NONE
assert features.plugin_installation_permission.restrict_to_marketplace_only is True
+124 -111
View File
@@ -1,8 +1,6 @@
import base64
import hashlib
import os
from collections.abc import Iterator
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import pytest
@@ -11,8 +9,6 @@ from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import NotFound
from configs import dify_config
from extensions.storage.storage_type import StorageType
from models.base import TypeBase
from models.enums import CreatorUserRole
from models.model import Account, EndUser, UploadFile
from services.errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError
@@ -21,54 +17,31 @@ from services.file_service import FileService
class TestFileService:
@pytest.fixture
def sqlite_session_maker(self, sqlite_engine: Engine) -> sessionmaker[Session]:
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[UploadFile.__tablename__]])
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
def mock_db_session(self):
session = MagicMock(spec=Session)
# Mock context manager behavior
session.__enter__.return_value = session
return session
@pytest.fixture
def db_session(self, sqlite_session_maker: sessionmaker[Session]) -> Iterator[Session]:
with sqlite_session_maker() as session:
yield session
def mock_session_maker(self, mock_db_session):
maker = MagicMock(spec=sessionmaker)
maker.return_value = mock_db_session
return maker
@pytest.fixture
def file_service(self, sqlite_session_maker: sessionmaker[Session]) -> FileService:
return FileService(session_factory=sqlite_session_maker)
def file_service(self, mock_session_maker):
return FileService(session_factory=mock_session_maker)
@staticmethod
def _persist_upload_file(
session: Session,
*,
file_id: str = "file_id",
tenant_id: str = "tenant_id",
extension: str = "txt",
mime_type: str = "text/plain",
key: str = "key",
) -> UploadFile:
upload_file = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.LOCAL,
key=key,
name=f"test.{extension}",
size=10,
extension=extension,
mime_type=mime_type,
created_by_role=CreatorUserRole.ACCOUNT,
created_by="user_id",
created_at=datetime(2024, 1, 1, tzinfo=UTC),
used=False,
)
upload_file.id = file_id
session.add(upload_file)
session.commit()
return upload_file
def test_init_with_engine(self, sqlite_engine: Engine):
service = FileService(session_factory=sqlite_engine)
def test_init_with_engine(self):
engine = MagicMock(spec=Engine)
service = FileService(session_factory=engine)
assert isinstance(service._session_maker, sessionmaker)
def test_init_with_sessionmaker(self, sqlite_session_maker: sessionmaker[Session]):
service = FileService(session_factory=sqlite_session_maker)
assert service._session_maker == sqlite_session_maker
def test_init_with_sessionmaker(self):
maker = MagicMock(spec=sessionmaker)
service = FileService(session_factory=maker)
assert service._session_maker == maker
def test_init_invalid_factory(self):
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
@@ -79,11 +52,11 @@ class TestFileService:
@patch("services.file_service.extract_tenant_id")
@patch("services.file_service.file_helpers.get_signed_file_url")
def test_upload_file_success(
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, db_session: Session
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, mock_db_session
):
# Setup
mock_tenant_id.return_value = "tenant_id"
mock_now.return_value = datetime(2024, 1, 1, tzinfo=UTC)
mock_now.return_value = "2024-01-01"
mock_get_url.return_value = "http://signed-url"
user = MagicMock(spec=Account)
@@ -108,9 +81,8 @@ class TestFileService:
assert result.source_url == "http://signed-url"
mock_storage.save.assert_called_once()
persisted = db_session.get(UploadFile, result.id)
assert persisted is not None
assert persisted.hash == result.hash
mock_db_session.add.assert_called_once_with(result)
mock_db_session.commit.assert_called_once()
def test_upload_file_uses_explicit_resource_tenant(self, file_service: FileService):
user = MagicMock(spec=Account)
@@ -137,7 +109,7 @@ class TestFileService:
with pytest.raises(ValueError, match="Filename contains invalid characters"):
file_service.upload_file(filename="invalid/file.txt", content=b"", mimetype="text/plain", user=MagicMock())
def test_upload_file_long_filename(self, file_service: FileService, db_session: Session):
def test_upload_file_long_filename(self, file_service: FileService, mock_db_session):
# Setup
long_name = "a" * 210 + ".txt"
user = MagicMock(spec=Account)
@@ -152,7 +124,6 @@ class TestFileService:
result = file_service.upload_file(filename=long_name, content=b"test", mimetype="text/plain", user=user)
assert len(result.name) <= 205 # 200 + . + extension
assert result.name.endswith(".txt")
assert db_session.get(UploadFile, result.id) is not None
def test_upload_file_blocked_extension(self, file_service):
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe"):
@@ -174,7 +145,7 @@ class TestFileService:
with pytest.raises(FileTooLargeError):
file_service.upload_file(filename="test.jpg", content=content, mimetype="image/jpeg", user=MagicMock())
def test_upload_file_end_user(self, file_service: FileService, db_session: Session):
def test_upload_file_end_user(self, file_service: FileService, mock_db_session):
user = MagicMock(spec=EndUser)
user.id = "end_user_id"
@@ -186,7 +157,6 @@ class TestFileService:
mock_tenant.return_value = "tenant"
result = file_service.upload_file(filename="test.txt", content=b"test", mimetype="text/plain", user=user)
assert result.created_by_role == CreatorUserRole.END_USER
assert db_session.get(UploadFile, result.id) is not None
def test_is_file_size_within_limit(self):
with (
@@ -211,8 +181,12 @@ class TestFileService:
assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True
assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False
def test_get_file_base64_success(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session, key="test_key")
def test_get_file_base64_success(self, file_service: FileService, mock_db_session):
# Setup
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.key = "test_key"
mock_db_session.scalar.return_value = upload_file
with patch("services.file_service.storage") as mock_storage:
mock_storage.load_once.return_value = b"test content"
@@ -224,17 +198,16 @@ class TestFileService:
assert result == base64.b64encode(b"test content").decode()
mock_storage.load_once.assert_called_once_with("test_key")
def test_get_file_base64_not_found(self, file_service: FileService):
def test_get_file_base64_not_found(self, file_service: FileService, mock_db_session):
mock_db_session.scalar.return_value = None
with pytest.raises(NotFound, match="File not found"):
file_service.get_file_base64("non_existent")
def test_get_file_presigned_url_success(self, file_service: FileService, db_session: Session):
self._persist_upload_file(
db_session,
extension="png",
mime_type="image/png",
key="upload_files/tenant_id/icon.png",
)
def test_get_file_presigned_url_success(self, file_service: FileService, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
upload_file.key = "upload_files/tenant_id/icon.png"
upload_file.mime_type = "image/png"
mock_db_session.scalar.return_value = upload_file
with (
patch.object(dify_config, "FILES_ACCESS_TIMEOUT", 300),
@@ -251,11 +224,13 @@ class TestFileService:
content_type="image/png",
)
def test_get_file_presigned_url_not_found(self, file_service: FileService):
def test_get_file_presigned_url_not_found(self, file_service: FileService, mock_db_session):
mock_db_session.scalar.return_value = None
with pytest.raises(NotFound, match="File not found"):
file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id")
def test_upload_text_success(self, file_service: FileService, db_session: Session):
def test_upload_text_success(self, file_service: FileService, mock_db_session):
# Setup
text = "sample text"
text_name = "test.txt"
@@ -274,17 +249,21 @@ class TestFileService:
assert result.used is True
assert result.extension == "txt"
mock_storage.save.assert_called_once()
assert db_session.get(UploadFile, result.id) is not None
mock_db_session.add.assert_called_once()
mock_db_session.commit.assert_called_once()
def test_upload_text_long_name(self, file_service: FileService, db_session: Session):
def test_upload_text_long_name(self, file_service: FileService, mock_db_session):
long_name = "a" * 210
with patch("services.file_service.storage"):
result = file_service.upload_text("text", long_name, "user", "tenant")
assert len(result.name) == 200
assert db_session.get(UploadFile, result.id) is not None
def test_get_file_preview_success(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session, extension="pdf", mime_type="application/pdf")
def test_get_file_preview_success(self, file_service: FileService, mock_db_session):
# Setup
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.extension = "pdf"
mock_db_session.scalar.return_value = upload_file
with patch("services.file_service.ExtractProcessor.load_from_upload_file") as mock_extract:
mock_extract.return_value = "Extracted text content"
@@ -295,17 +274,27 @@ class TestFileService:
# Assert
assert result == "Extracted text content"
def test_get_file_preview_not_found(self, file_service: FileService):
def test_get_file_preview_not_found(self, file_service: FileService, mock_db_session):
mock_db_session.scalar.return_value = None
with pytest.raises(NotFound, match="File not found"):
file_service.get_file_preview("non_existent", "tenant_id")
def test_get_file_preview_unsupported_type(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session, extension="exe", mime_type="application/octet-stream")
def test_get_file_preview_unsupported_type(self, file_service: FileService, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.extension = "exe"
mock_db_session.scalar.return_value = upload_file
with pytest.raises(UnsupportedFileTypeError):
file_service.get_file_preview("file_id", "tenant_id")
def test_get_image_preview_success(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session, extension="jpg", mime_type="image/jpeg")
def test_get_image_preview_success(self, file_service: FileService, mock_db_session):
# Setup
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.extension = "jpg"
upload_file.mime_type = "image/jpeg"
upload_file.key = "key"
mock_db_session.scalar.return_value = upload_file
with (
patch("services.file_service.file_helpers.verify_image_signature") as mock_verify,
@@ -327,21 +316,28 @@ class TestFileService:
with pytest.raises(NotFound, match="File not found or signature is invalid"):
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
def test_get_image_preview_not_found(self, file_service: FileService):
def test_get_image_preview_not_found(self, file_service: FileService, mock_db_session):
mock_db_session.scalar.return_value = None
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
mock_verify.return_value = True
with pytest.raises(NotFound, match="File not found or signature is invalid"):
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
def test_get_image_preview_unsupported_type(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session)
def test_get_image_preview_unsupported_type(self, file_service: FileService, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.extension = "txt"
mock_db_session.scalar.return_value = upload_file
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
mock_verify.return_value = True
with pytest.raises(UnsupportedFileTypeError):
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
def test_get_file_generator_by_file_id_success(self, file_service: FileService, db_session: Session):
upload_file = self._persist_upload_file(db_session)
def test_get_file_generator_by_file_id_success(self, file_service: FileService, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.key = "key"
mock_db_session.scalar.return_value = upload_file
with (
patch("services.file_service.file_helpers.verify_file_signature") as mock_verify,
@@ -352,8 +348,7 @@ class TestFileService:
gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
assert list(gen) == [b"chunk"]
assert file.id == upload_file.id
assert file.key == upload_file.key
assert file == upload_file
def test_get_file_generator_by_file_id_invalid_sig(self, file_service):
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
@@ -361,14 +356,20 @@ class TestFileService:
with pytest.raises(NotFound, match="File not found or signature is invalid"):
file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
def test_get_file_generator_by_file_id_not_found(self, file_service: FileService):
def test_get_file_generator_by_file_id_not_found(self, file_service: FileService, mock_db_session):
mock_db_session.scalar.return_value = None
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
mock_verify.return_value = True
with pytest.raises(NotFound, match="File not found or signature is invalid"):
file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
def test_get_public_image_preview_success(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session, extension="png", mime_type="image/png")
def test_get_public_image_preview_success(self, file_service: FileService, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.extension = "png"
upload_file.mime_type = "image/png"
upload_file.key = "key"
mock_db_session.scalar.return_value = upload_file
with patch("services.file_service.storage") as mock_storage:
mock_storage.load.return_value = b"image content"
@@ -376,56 +377,66 @@ class TestFileService:
assert gen == b"image content"
assert mime == "image/png"
def test_get_public_image_preview_not_found(self, file_service: FileService):
def test_get_public_image_preview_not_found(self, file_service: FileService, mock_db_session):
mock_db_session.scalar.return_value = None
with pytest.raises(NotFound, match="File not found or signature is invalid"):
file_service.get_public_image_preview("file_id")
def test_get_public_image_preview_unsupported_type(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session)
def test_get_public_image_preview_unsupported_type(self, file_service: FileService, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.extension = "txt"
mock_db_session.scalar.return_value = upload_file
with pytest.raises(UnsupportedFileTypeError):
file_service.get_public_image_preview("file_id")
def test_get_file_content_success(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session)
def test_get_file_content_success(self, file_service: FileService, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.key = "key"
mock_db_session.scalar.return_value = upload_file
with patch("services.file_service.storage") as mock_storage:
mock_storage.load.return_value = b"hello world"
result = file_service.get_file_content("file_id")
assert result == "hello world"
def test_get_file_content_not_found(self, file_service: FileService):
def test_get_file_content_not_found(self, file_service: FileService, mock_db_session):
mock_db_session.scalar.return_value = None
with pytest.raises(NotFound, match="File not found"):
file_service.get_file_content("file_id")
def test_delete_file_success(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session)
def test_delete_file_success(self, file_service: FileService, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "file_id"
upload_file.key = "key"
# For session.scalar(select(...))
mock_db_session.scalar.return_value = upload_file
with patch("services.file_service.storage") as mock_storage:
file_service.delete_file("file_id")
mock_storage.delete.assert_called_once_with("key")
db_session.expire_all()
assert db_session.get(UploadFile, "file_id") is None
mock_db_session.delete.assert_called_once_with(upload_file)
def test_delete_file_not_found(self, file_service: FileService):
def test_delete_file_not_found(self, file_service: FileService, mock_db_session):
mock_db_session.scalar.return_value = None
file_service.delete_file("file_id")
# Should return without doing anything
def test_get_upload_files_by_ids_empty(self, db_session: Session):
result = FileService.get_upload_files_by_ids("tenant_id", [], session=db_session)
def test_get_upload_files_by_ids_empty(self):
session = MagicMock()
result = FileService.get_upload_files_by_ids("tenant_id", [], session=session)
assert result == {}
def test_get_upload_files_by_ids(self, db_session: Session):
upload_file = self._persist_upload_file(db_session, file_id="550e8400-e29b-41d4-a716-446655440000")
self._persist_upload_file(
db_session,
file_id="550e8400-e29b-41d4-a716-446655440001",
tenant_id="other-tenant",
)
def test_get_upload_files_by_ids(self):
upload_file = MagicMock(spec=UploadFile)
upload_file.id = "550e8400-e29b-41d4-a716-446655440000"
upload_file.tenant_id = "tenant_id"
session = MagicMock()
session.scalars().all.return_value = [upload_file]
result = FileService.get_upload_files_by_ids(
"tenant_id",
["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001"],
session=db_session,
"tenant_id", ["550e8400-e29b-41d4-a716-446655440000"], session=session
)
assert result["550e8400-e29b-41d4-a716-446655440000"] == upload_file
@@ -442,8 +453,10 @@ class TestFileService:
used.add("a (1).txt")
assert FileService._dedupe_zip_entry_name("a.txt", used) == "a (2).txt"
def test_build_upload_files_zip_tempfile(self, db_session: Session):
upload_file = self._persist_upload_file(db_session)
def test_build_upload_files_zip_tempfile(self):
upload_file = MagicMock(spec=UploadFile)
upload_file.name = "test.txt"
upload_file.key = "key"
with (
patch("services.file_service.storage") as mock_storage,
@@ -1,5 +1,6 @@
import dataclasses
import logging
from collections.abc import Iterator
from datetime import datetime, timedelta
from unittest.mock import MagicMock
@@ -41,13 +42,14 @@ from services.human_input_service import (
@pytest.fixture
def unbound_session_factory() -> sessionmaker[Session]:
"""Supply the required constructor dependency without enabling database access."""
return sessionmaker()
def sqlite_session_factory(sqlite_engine: Engine) -> Iterator[tuple[sessionmaker[Session], Session]]:
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
with factory() as session:
yield factory, session
def _make_app(mode: AppMode) -> App:
return App(
def _persist_app(sqlite_session: Session, mode: AppMode) -> App:
app = App(
id="app-id",
tenant_id="tenant-id",
name="Test App",
@@ -58,6 +60,9 @@ def _make_app(mode: AppMode) -> App:
enable_api=True,
max_active_requests=0,
)
sqlite_session.add(app)
sqlite_session.commit()
return app
@pytest.fixture
@@ -92,11 +97,14 @@ def sample_form_record():
)
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_enqueue_resume_dispatches_task_for_workflow(
mocker: MockerFixture,
sqlite_session_factory: sessionmaker[Session],
sqlite_session_factory,
sqlite_session: Session,
):
service = HumanInputService(sqlite_session_factory)
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
workflow_run = MagicMock()
workflow_run.app_id = "app-id"
@@ -108,8 +116,7 @@ def test_enqueue_resume_dispatches_task_for_workflow(
return_value=workflow_run_repo,
)
with sqlite_session_factory.begin() as arrange_session:
arrange_session.add(_make_app(AppMode.WORKFLOW))
_persist_app(sqlite_session, AppMode.WORKFLOW)
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
@@ -121,9 +128,10 @@ def test_enqueue_resume_dispatches_task_for_workflow(
def test_ensure_form_active_respects_global_timeout(
monkeypatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
monkeypatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
):
service = HumanInputService(unbound_session_factory)
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
expired_record = dataclasses.replace(
sample_form_record,
created_at=naive_utc_now() - timedelta(hours=2),
@@ -135,11 +143,14 @@ def test_ensure_form_active_respects_global_timeout(
service.ensure_form_active(Form(expired_record))
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_enqueue_resume_dispatches_task_for_advanced_chat(
mocker: MockerFixture,
sqlite_session_factory: sessionmaker[Session],
sqlite_session_factory,
sqlite_session: Session,
):
service = HumanInputService(sqlite_session_factory)
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
workflow_run = MagicMock()
workflow_run.app_id = "app-id"
@@ -151,8 +162,7 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
return_value=workflow_run_repo,
)
with sqlite_session_factory.begin() as arrange_session:
arrange_session.add(_make_app(AppMode.ADVANCED_CHAT))
_persist_app(sqlite_session, AppMode.ADVANCED_CHAT)
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
@@ -163,11 +173,14 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_enqueue_resume_skips_unsupported_app_mode(
mocker: MockerFixture,
sqlite_session_factory: sessionmaker[Session],
sqlite_session_factory,
sqlite_session: Session,
):
service = HumanInputService(sqlite_session_factory)
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
workflow_run = MagicMock()
workflow_run.app_id = "app-id"
@@ -179,8 +192,7 @@ def test_enqueue_resume_skips_unsupported_app_mode(
return_value=workflow_run_repo,
)
with sqlite_session_factory.begin() as arrange_session:
arrange_session.add(_make_app(AppMode.COMPLETION))
_persist_app(sqlite_session, AppMode.COMPLETION)
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
@@ -190,13 +202,14 @@ def test_enqueue_resume_skips_unsupported_app_mode(
def test_get_form_definition_by_token_for_console_uses_repository(
sample_form_record: HumanInputFormRecord, unbound_session_factory
sample_form_record: HumanInputFormRecord, sqlite_session_factory
):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
console_record = dataclasses.replace(sample_form_record, recipient_type=RecipientType.CONSOLE)
repo.get_by_token.return_value = console_record
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
form = service.get_form_definition_by_token_for_console("token")
repo.get_by_token.assert_called_once_with("token")
@@ -232,8 +245,9 @@ def _build_resumption_context_state(*, options: list[str], workflow_run_id: str)
def test_resolve_form_inputs_uses_runtime_select_options(
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
):
session_factory, _ = sqlite_session_factory
configured_input = SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(
@@ -258,7 +272,7 @@ def test_resolve_form_inputs_uses_runtime_select_options(
"services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
return_value=workflow_run_repo,
)
service = HumanInputService(unbound_session_factory)
service = HumanInputService(session_factory)
resolved_inputs = service.resolve_form_inputs(Form(record))
@@ -270,12 +284,13 @@ def test_resolve_form_inputs_uses_runtime_select_options(
def test_submit_form_by_token_calls_repository_and_enqueue(
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
repo.get_by_token.return_value = sample_form_record
repo.mark_submitted.return_value = sample_form_record
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
service.submit_form_by_token(
@@ -298,10 +313,11 @@ def test_submit_form_by_token_calls_repository_and_enqueue(
def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
sample_form_record, unbound_session_factory, mocker: MockerFixture
sample_form_record, sqlite_session_factory, mocker: MockerFixture
):
# ENG-635: a conversation-owned (Agent v2 chat) form routes to the chat
# resume, not the workflow resume.
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
conversation_record = dataclasses.replace(
sample_form_record,
@@ -310,7 +326,7 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
)
repo.get_by_token.return_value = conversation_record
repo.mark_submitted.return_value = conversation_record
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
workflow_enqueue_spy = mocker.patch.object(service, "enqueue_resume")
chat_enqueue_spy = mocker.patch.object(service, "enqueue_agent_app_resume")
@@ -327,8 +343,9 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
def test_submit_form_by_token_skips_enqueue_for_delivery_test(
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
test_record = dataclasses.replace(
sample_form_record,
@@ -337,7 +354,7 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(
)
repo.get_by_token.return_value = test_record
repo.mark_submitted.return_value = test_record
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
service.submit_form_by_token(
@@ -351,12 +368,13 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(
def test_submit_form_by_token_passes_submission_user_id(
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
repo.get_by_token.return_value = sample_form_record
repo.mark_submitted.return_value = sample_form_record
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
service.submit_form_by_token(
@@ -373,10 +391,11 @@ def test_submit_form_by_token_passes_submission_user_id(
enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id)
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, unbound_session_factory):
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record)
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
with pytest.raises(InvalidFormDataError) as exc_info:
service.submit_form_by_token(
@@ -390,7 +409,8 @@ def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormR
repo.mark_submitted.assert_not_called()
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, unbound_session_factory):
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition_with_input = FormDefinition(
@@ -402,7 +422,7 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
)
form_with_input = dataclasses.replace(sample_form_record, definition=definition_with_input)
repo.get_by_token.return_value = form_with_input
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
with pytest.raises(InvalidFormDataError) as exc_info:
service.submit_form_by_token(
@@ -416,6 +436,42 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
repo.mark_submitted.assert_not_called()
def test_validate_human_input_submission_accepts_select_file_and_file_list(sqlite_session_factory):
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
definition = FormDefinition.model_validate(
{
"form_content": "Pick one and upload files",
"inputs": [
{
"type": "select",
"output_variable_name": "decision",
"option_source": {
"type": "constant",
"value": ["approve", "reject"],
},
},
{
"type": "file",
"output_variable_name": "attachment",
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["remote_url"],
},
{
"type": "file-list",
"output_variable_name": "attachments",
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["remote_url"],
"number_limits": 3,
},
],
"user_actions": [{"id": "submit", "title": "Submit"}],
"rendered_content": "<p>Pick one and upload files</p>",
"expiration_time": naive_utc_now() + timedelta(hours=1),
}
)
@pytest.mark.parametrize(
("input_definition", "submitted_value", "expected_message"),
[
@@ -466,11 +522,12 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
)
def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads(
sample_form_record,
unbound_session_factory,
sqlite_session_factory,
input_definition,
submitted_value,
expected_message,
):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition.model_validate(
{
@@ -482,7 +539,7 @@ def test_validate_human_input_submission_rejects_invalid_select_and_file_payload
}
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
with pytest.raises(InvalidFormDataError) as exc_info:
service.submit_form_by_token(
@@ -512,7 +569,7 @@ def test_form_properties(sample_form_record: HumanInputFormRecord):
def test_form_submitted_error_init():
error = FormSubmittedError(form_id="test-form")
assert error.description == "This form has already been submitted by another user, form_id=test-form"
assert "form_id=test-form" in error.description
assert error.code == 412
@@ -523,55 +580,61 @@ def test_human_input_service_init_with_engine(sqlite_engine: Engine):
assert service._session_factory.kw["bind"] is sqlite_engine
def test_get_form_by_token_none(unbound_session_factory):
def test_get_form_by_token_none(sqlite_session_factory):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
repo.get_by_token.return_value = None
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
assert service.get_form_by_token("invalid") is None
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, unbound_session_factory):
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
repo.get_by_token.return_value = sample_form_record
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
# RecipientType mismatch
assert service.get_form_definition_by_token(RecipientType.CONSOLE, "token") is None
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, unbound_session_factory):
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
repo.get_by_token.return_value = sample_form_record
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
form = service.get_form_definition_by_token(RecipientType.STANDALONE_WEB_APP, "token")
assert form is not None
assert form.id == sample_form_record.form_id
def test_get_form_definition_by_token_for_console_mismatch(
sample_form_record: HumanInputFormRecord, unbound_session_factory
sample_form_record: HumanInputFormRecord, sqlite_session_factory
):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
repo.get_by_token.return_value = sample_form_record # is STANDALONE_WEB_APP
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
assert service.get_form_definition_by_token_for_console("token") is None
def test_submit_form_by_token_delivery_not_enabled(unbound_session_factory):
def test_submit_form_by_token_delivery_not_enabled(sqlite_session_factory):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
repo.get_by_token.return_value = None
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
with pytest.raises(human_input_service_module.WebAppDeliveryNotEnabledError):
service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "action", {})
def test_submit_form_by_token_no_workflow_run_id(
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
):
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
repo.get_by_token.return_value = sample_form_record
@@ -579,15 +642,16 @@ def test_submit_form_by_token_no_workflow_run_id(
result_record = dataclasses.replace(sample_form_record, workflow_run_id=None)
repo.mark_submitted.return_value = result_record
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "submit", {})
enqueue_spy.assert_not_called()
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unbound_session_factory):
service = HumanInputService(unbound_session_factory)
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
# Submitted
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
@@ -607,16 +671,18 @@ def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unb
service.ensure_form_active(Form(expired_time_record))
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, unbound_session_factory):
service = HumanInputService(unbound_session_factory)
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
with pytest.raises(human_input_service_module.FormSubmittedError):
service._ensure_not_submitted(Form(submitted_record))
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_session_factory):
service = HumanInputService(unbound_session_factory)
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, sqlite_session_factory):
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
workflow_run_repo = MagicMock()
workflow_run_repo.get_workflow_run_by_id_without_tenant.return_value = None
@@ -630,12 +696,15 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_sessio
assert "WorkflowRun not found" in str(excinfo.value)
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_enqueue_resume_app_not_found(
mocker,
sqlite_session_factory: sessionmaker[Session],
sqlite_session_factory,
sqlite_session: Session,
caplog: pytest.LogCaptureFixture,
):
service = HumanInputService(sqlite_session_factory)
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
workflow_run = MagicMock()
workflow_run.app_id = "app-id"
@@ -646,31 +715,26 @@ def test_enqueue_resume_app_not_found(
"services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
return_value=workflow_run_repo,
)
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
with caplog.at_level(logging.ERROR, logger="services.human_input_service"):
service.enqueue_resume("workflow-run-id")
assert (
"services.human_input_service",
logging.ERROR,
"App not found for WorkflowRun, workflow_run_id=workflow-run-id, app_id=app-id",
) in caplog.record_tuples
resume_task.apply_async.assert_not_called()
assert any(r.levelno >= logging.ERROR for r in caplog.records)
def test_is_globally_expired_zero_timeout(
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
):
service = HumanInputService(unbound_session_factory)
session_factory, _ = sqlite_session_factory
service = HumanInputService(session_factory)
monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
assert service._is_globally_expired(Form(sample_form_record)) is False
def test_submit_form_by_token_normalizes_select_and_files(
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
) -> None:
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
@@ -689,7 +753,7 @@ def test_submit_form_by_token_normalizes_select_and_files(
form_with_inputs = dataclasses.replace(sample_form_record, definition=definition)
repo.get_by_token.return_value = form_with_inputs
repo.mark_submitted.return_value = form_with_inputs
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
single_file = File(
file_id="file-1",
@@ -751,8 +815,9 @@ def test_submit_form_by_token_normalizes_select_and_files(
def test_submit_form_by_token_invalid_select_value(
sample_form_record: HumanInputFormRecord, unbound_session_factory
sample_form_record: HumanInputFormRecord, sqlite_session_factory
) -> None:
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
@@ -767,7 +832,7 @@ def test_submit_form_by_token_invalid_select_value(
expiration_time=sample_form_record.expiration_time,
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"):
service.submit_form_by_token(
@@ -779,8 +844,9 @@ def test_submit_form_by_token_invalid_select_value(
def test_submit_form_by_token_invalid_file_list_item(
sample_form_record: HumanInputFormRecord, unbound_session_factory
sample_form_record: HumanInputFormRecord, sqlite_session_factory
) -> None:
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
@@ -790,7 +856,7 @@ def test_submit_form_by_token_invalid_file_list_item(
expiration_time=sample_form_record.expiration_time,
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
with pytest.raises(
InvalidFormDataError,
@@ -805,8 +871,9 @@ def test_submit_form_by_token_invalid_file_list_item(
def test_submit_form_by_token_rejects_cross_tenant_file(
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
) -> None:
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
@@ -816,7 +883,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file(
expiration_time=sample_form_record.expiration_time,
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
mocker.patch("services.human_input_service.build_from_mapping", side_effect=ValueError("Invalid upload file"))
with pytest.raises(InvalidFormDataError, match="Invalid value for file input 'attachment'"):
@@ -837,8 +904,9 @@ def test_submit_form_by_token_rejects_cross_tenant_file(
def test_submit_form_by_token_rejects_cross_tenant_file_list(
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
) -> None:
session_factory, _ = sqlite_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
@@ -848,7 +916,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file_list(
expiration_time=sample_form_record.expiration_time,
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(unbound_session_factory, form_repository=repo)
service = HumanInputService(session_factory, form_repository=repo)
mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file"))
with pytest.raises(
@@ -5,12 +5,15 @@ from unittest.mock import MagicMock
import pytest
from core.entities.model_entities import ModelStatus
from core.entities.provider_entities import CredentialConfiguration
from core.plugin.entities.plugin import PluginInstallationSource
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.model_entities import FetchFrom, ModelType, ParameterRule, ParameterType
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
from models.provider import ProviderType
from services import model_provider_service as service_module
from services.errors.app_model_config import ProviderNotFoundError
from services.model_provider_service import ModelProviderService
from services.model_provider_service import ModelProviderService, _ProviderSummaryState
def _create_service_with_mocked_manager() -> tuple[ModelProviderService, MagicMock]:
@@ -96,6 +99,247 @@ class TestModelProviderServiceConfiguration:
assert result[0].provider == "openai"
assert result[0].custom_configuration.status.value == "no-configure"
def test_get_provider_summary_list_uses_lightweight_state_and_plugin_bindings(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
service = ModelProviderService()
provider = SimpleNamespace(
provider="langgenius/openai/openai",
label=I18nObject(en_US="OpenAI"),
description=I18nObject(en_US="OpenAI models"),
icon_small=I18nObject(en_US="icon.svg"),
icon_small_dark=I18nObject(en_US="icon-dark.svg"),
supported_model_types=[ModelType.LLM],
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
)
binding = SimpleNamespace(
provider="openai",
plugin_id="langgenius/openai",
installation_id="installation-1",
plugin_unique_identifier="langgenius/openai:1.2.3@checksum",
runtime_type="local",
source=PluginInstallationSource.Marketplace,
version="1.2.3",
)
state = _ProviderSummaryState(
has_custom_provider=True,
available_credentials=[
CredentialConfiguration(
credential_id="credential-1",
credential_name="Production",
),
CredentialConfiguration(
credential_id="credential-2",
credential_name="Backup",
),
],
has_custom_models=False,
current_credential_id="credential-1",
current_credential_name="Production",
current_credential_usable=True,
preferred_provider_type=ProviderType.CUSTOM,
)
call_order: list[str] = []
manager_constructor = MagicMock(side_effect=AssertionError("summary must not construct ProviderManager"))
monkeypatch.setattr(service, "_get_provider_manager", manager_constructor)
monkeypatch.setattr(
service_module.PluginService,
"list_model_provider_bindings",
MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("bindings") or [binding]),
)
monkeypatch.setattr(
service_module.PluginService,
"fetch_plugin_model_providers",
MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("providers") or [provider]),
)
monkeypatch.setattr(
service,
"_load_provider_summary_states",
MagicMock(return_value={provider.provider: state}),
)
monkeypatch.setattr(service, "_is_system_provider_enabled", MagicMock(return_value=False))
providers, plugins = service.get_provider_summary_list(tenant_id="tenant-1")
assert len(providers) == 1
assert providers[0].provider == provider.provider
assert providers[0].plugin_id == "langgenius/openai"
assert providers[0].is_configured is True
assert providers[0].custom_configuration.available_credentials == [
CredentialConfiguration(
credential_id="credential-1",
credential_name="Production",
),
CredentialConfiguration(
credential_id="credential-2",
credential_name="Backup",
),
]
assert providers[0].custom_configuration.current_credential_name == "Production"
assert providers[0].custom_configuration.current_credential_usable is True
assert providers[0].system_configuration.enabled is False
assert plugins["langgenius/openai"].installation_id == "installation-1"
assert plugins["langgenius/openai"].version == "1.2.3"
assert call_order == ["bindings", "providers"]
manager_constructor.assert_not_called()
def test_get_provider_summary_list_returns_all_unique_provider_metadata(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
service = ModelProviderService()
llm_provider = SimpleNamespace(
provider="langgenius/openai/openai",
label=I18nObject(en_US="OpenAI"),
description=None,
icon_small=None,
icon_small_dark=None,
supported_model_types=[ModelType.LLM],
configurate_methods=[],
)
embedding_provider = SimpleNamespace(
provider="langgenius/embedding/embedding",
label=I18nObject(en_US="Embedding"),
description=None,
icon_small=None,
icon_small_dark=None,
supported_model_types=[ModelType.TEXT_EMBEDDING],
configurate_methods=[],
)
llm_binding = SimpleNamespace(
provider="openai",
plugin_id="langgenius/openai",
installation_id="installation-openai",
plugin_unique_identifier="langgenius/openai:1.0.0@checksum",
runtime_type="local",
source=service_module.PluginInstallationSource.Marketplace,
version="1.0.0",
)
embedding_binding = SimpleNamespace(
provider="embedding",
plugin_id="langgenius/embedding",
installation_id="installation-embedding",
plugin_unique_identifier="langgenius/embedding:1.0.0@checksum",
runtime_type="local",
source=service_module.PluginInstallationSource.Marketplace,
version="1.0.0",
)
monkeypatch.setattr(
service_module.PluginService,
"list_model_provider_bindings",
MagicMock(return_value=[llm_binding, embedding_binding]),
)
monkeypatch.setattr(
service_module.PluginService,
"fetch_plugin_model_providers",
MagicMock(return_value=[llm_provider, llm_provider, embedding_provider]),
)
monkeypatch.setattr(service, "_load_provider_summary_states", MagicMock(return_value={}))
monkeypatch.setattr(service, "_is_system_provider_enabled", MagicMock(return_value=False))
monkeypatch.setattr(service_module, "is_filtered", MagicMock(return_value=False))
providers, plugins = service.get_provider_summary_list(tenant_id="tenant-1")
assert [provider.provider for provider in providers] == [
"langgenius/openai/openai",
"langgenius/embedding/embedding",
]
assert providers[0].is_configured is False
assert providers[0].custom_configuration.status.value == "no-configure"
assert providers[0].custom_configuration.available_credentials == []
assert set(plugins) == {"langgenius/openai", "langgenius/embedding"}
def test_preferred_provider_fallback_uses_custom_presence_not_configuration_status(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED")
state = _ProviderSummaryState(has_custom_provider=True)
preferred_provider_type = ModelProviderService._get_preferred_provider_type(
state,
custom_present=True,
system_enabled=True,
)
assert preferred_provider_type == ProviderType.CUSTOM
def test_load_provider_summary_states_reads_only_lightweight_columns(self, monkeypatch: pytest.MonkeyPatch) -> None:
canonical_provider = "langgenius/openai/openai"
session = MagicMock()
session.execute.side_effect = [
SimpleNamespace(
all=lambda: [
SimpleNamespace(
provider_name="openai",
credential_id="credential-legacy",
credential_provider_name="openai",
credential_name="Legacy",
),
SimpleNamespace(
provider_name=canonical_provider,
credential_id="credential-current",
credential_provider_name=canonical_provider,
credential_name="Production",
),
]
),
SimpleNamespace(
all=lambda: [
SimpleNamespace(
id="credential-legacy",
provider_name="openai",
credential_name="Legacy",
),
SimpleNamespace(
id="credential-current",
provider_name=canonical_provider,
credential_name="Production",
),
]
),
SimpleNamespace(all=lambda: [SimpleNamespace(provider_name="openai")]),
SimpleNamespace(
all=lambda: [
SimpleNamespace(
provider_name=canonical_provider,
preferred_provider_type=ProviderType.SYSTEM,
)
]
),
]
session_context = MagicMock()
session_context.__enter__.return_value = session
create_session = MagicMock(return_value=session_context)
monkeypatch.setattr(service_module.session_factory, "create_session", create_session)
states = ModelProviderService._load_provider_summary_states("tenant-1")
state = states[canonical_provider]
assert state.has_custom_provider is True
assert state.available_credentials == [
CredentialConfiguration(
credential_id="credential-legacy",
credential_name="Legacy",
),
CredentialConfiguration(
credential_id="credential-current",
credential_name="Production",
),
]
assert state.has_custom_models is True
assert state.current_credential_id == "credential-current"
assert state.current_credential_name == "Production"
assert state.current_credential_usable is True
assert state.preferred_provider_type == ProviderType.SYSTEM
statements = [str(execute_call.args[0]) for execute_call in session.execute.call_args_list]
assert len(statements) == 4
assert all("encrypted_config" not in statement for statement in statements)
assert "count(" not in statements[1].lower()
assert "provider_credentials.id" in statements[1]
assert "provider_credentials.credential_name" in statements[1]
assert "ORDER BY provider_credentials.created_at DESC, provider_credentials.id DESC" in statements[1]
assert "provider_model_credentials" in statements[2]
def test_get_models_by_provider_should_wrap_model_entities_with_tenant_context(self) -> None:
service, manager = _create_service_with_mocked_manager()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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