mirror of
https://github.com/langgenius/dify.git
synced 2026-05-07 02:46:32 +08:00
Merge branch 'main' into jzh
This commit is contained in:
commit
30ebf2bfa9
@ -353,6 +353,9 @@ BAIDU_VECTOR_DB_SHARD=1
|
||||
BAIDU_VECTOR_DB_REPLICAS=3
|
||||
BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER=DEFAULT_ANALYZER
|
||||
BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE=COARSE_MODE
|
||||
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT=500
|
||||
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO=0.05
|
||||
BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS=300
|
||||
|
||||
# Upstash configuration
|
||||
UPSTASH_VECTOR_URL=your-server-url
|
||||
|
||||
@ -51,3 +51,18 @@ class BaiduVectorDBConfig(BaseSettings):
|
||||
description="Parser mode for inverted index in Baidu Vector Database (default is COARSE_MODE)",
|
||||
default="COARSE_MODE",
|
||||
)
|
||||
|
||||
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT: int = Field(
|
||||
description="Auto build row count increment threshold (default is 500)",
|
||||
default=500,
|
||||
)
|
||||
|
||||
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO: float = Field(
|
||||
description="Auto build row count increment ratio threshold (default is 0.05)",
|
||||
default=0.05,
|
||||
)
|
||||
|
||||
BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS: int = Field(
|
||||
description="Timeout in seconds for rebuilding the index in Baidu Vector Database (default is 3600 seconds)",
|
||||
default=300,
|
||||
)
|
||||
|
||||
@ -9,6 +9,7 @@ from extensions.ext_database import db
|
||||
from libs.helper import TimestampField
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.dataset import Dataset
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App
|
||||
from services.api_token_service import ApiTokenCache
|
||||
|
||||
@ -47,7 +48,7 @@ def _get_resource(resource_id, tenant_id, resource_model):
|
||||
class BaseApiKeyListResource(Resource):
|
||||
method_decorators = [account_initialization_required, login_required, setup_required]
|
||||
|
||||
resource_type: str | None = None
|
||||
resource_type: ApiTokenType | None = None
|
||||
resource_model: type | None = None
|
||||
resource_id_field: str | None = None
|
||||
token_prefix: str | None = None
|
||||
@ -91,6 +92,7 @@ class BaseApiKeyListResource(Resource):
|
||||
)
|
||||
|
||||
key = ApiToken.generate_api_key(self.token_prefix or "", 24)
|
||||
assert self.resource_type is not None, "resource_type must be set"
|
||||
api_token = ApiToken()
|
||||
setattr(api_token, self.resource_id_field, resource_id)
|
||||
api_token.tenant_id = current_tenant_id
|
||||
@ -104,7 +106,7 @@ class BaseApiKeyListResource(Resource):
|
||||
class BaseApiKeyResource(Resource):
|
||||
method_decorators = [account_initialization_required, login_required, setup_required]
|
||||
|
||||
resource_type: str | None = None
|
||||
resource_type: ApiTokenType | None = None
|
||||
resource_model: type | None = None
|
||||
resource_id_field: str | None = None
|
||||
|
||||
@ -159,7 +161,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
"""Create a new API key for an app"""
|
||||
return super().post(resource_id)
|
||||
|
||||
resource_type = "app"
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = App
|
||||
resource_id_field = "app_id"
|
||||
token_prefix = "app-"
|
||||
@ -175,7 +177,7 @@ class AppApiKeyResource(BaseApiKeyResource):
|
||||
"""Delete an API key for an app"""
|
||||
return super().delete(resource_id, api_key_id)
|
||||
|
||||
resource_type = "app"
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = App
|
||||
resource_id_field = "app_id"
|
||||
|
||||
@ -199,7 +201,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
|
||||
"""Create a new API key for a dataset"""
|
||||
return super().post(resource_id)
|
||||
|
||||
resource_type = "dataset"
|
||||
resource_type = ApiTokenType.DATASET
|
||||
resource_model = Dataset
|
||||
resource_id_field = "dataset_id"
|
||||
token_prefix = "ds-"
|
||||
@ -215,6 +217,6 @@ class DatasetApiKeyResource(BaseApiKeyResource):
|
||||
"""Delete an API key for a dataset"""
|
||||
return super().delete(resource_id, api_key_id)
|
||||
|
||||
resource_type = "dataset"
|
||||
resource_type = ApiTokenType.DATASET
|
||||
resource_model = Dataset
|
||||
resource_id_field = "dataset_id"
|
||||
|
||||
@ -458,9 +458,7 @@ class ChatConversationApi(Resource):
|
||||
args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
subquery = (
|
||||
db.session.query(
|
||||
Conversation.id.label("conversation_id"), EndUser.session_id.label("from_end_user_session_id")
|
||||
)
|
||||
sa.select(Conversation.id.label("conversation_id"), EndUser.session_id.label("from_end_user_session_id"))
|
||||
.outerjoin(EndUser, Conversation.from_end_user_id == EndUser.id)
|
||||
.subquery()
|
||||
)
|
||||
@ -595,10 +593,8 @@ class ChatConversationDetailApi(Resource):
|
||||
|
||||
def _get_conversation(app_model, conversation_id):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
conversation = (
|
||||
db.session.query(Conversation)
|
||||
.where(Conversation.id == conversation_id, Conversation.app_id == app_model.id)
|
||||
.first()
|
||||
conversation = db.session.scalar(
|
||||
sa.select(Conversation).where(Conversation.id == conversation_id, Conversation.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
|
||||
@ -168,7 +168,7 @@ class InstructionGenerateApi(Resource):
|
||||
try:
|
||||
# Generate from nothing for a workflow node
|
||||
if (args.current in (code_template, "")) and args.node_id != "":
|
||||
app = db.session.query(App).where(App.id == args.flow_id).first()
|
||||
app = db.session.get(App, args.flow_id)
|
||||
if not app:
|
||||
return {"error": f"app {args.flow_id} not found"}, 400
|
||||
workflow = WorkflowService().get_draft_workflow(app_model=app)
|
||||
|
||||
@ -2,6 +2,7 @@ import json
|
||||
|
||||
from flask_restx import Resource, marshal_with
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console import console_ns
|
||||
@ -47,7 +48,7 @@ class AppMCPServerController(Resource):
|
||||
@get_app_model
|
||||
@marshal_with(app_server_model)
|
||||
def get(self, app_model):
|
||||
server = db.session.query(AppMCPServer).where(AppMCPServer.app_id == app_model.id).first()
|
||||
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
|
||||
return server
|
||||
|
||||
@console_ns.doc("create_app_mcp_server")
|
||||
@ -98,7 +99,7 @@ class AppMCPServerController(Resource):
|
||||
@edit_permission_required
|
||||
def put(self, app_model):
|
||||
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
|
||||
server = db.session.query(AppMCPServer).where(AppMCPServer.id == payload.id).first()
|
||||
server = db.session.get(AppMCPServer, payload.id)
|
||||
if not server:
|
||||
raise NotFound()
|
||||
|
||||
@ -135,11 +136,10 @@ class AppMCPServerRefreshController(Resource):
|
||||
@edit_permission_required
|
||||
def get(self, server_id):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
server = (
|
||||
db.session.query(AppMCPServer)
|
||||
.where(AppMCPServer.id == server_id)
|
||||
.where(AppMCPServer.tenant_id == current_tenant_id)
|
||||
.first()
|
||||
server = db.session.scalar(
|
||||
select(AppMCPServer)
|
||||
.where(AppMCPServer.id == server_id, AppMCPServer.tenant_id == current_tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
if not server:
|
||||
raise NotFound()
|
||||
|
||||
@ -69,9 +69,7 @@ class ModelConfigResource(Resource):
|
||||
|
||||
if app_model.mode == AppMode.AGENT_CHAT or app_model.is_agent:
|
||||
# get original app model config
|
||||
original_app_model_config = (
|
||||
db.session.query(AppModelConfig).where(AppModelConfig.id == app_model.app_model_config_id).first()
|
||||
)
|
||||
original_app_model_config = db.session.get(AppModelConfig, app_model.app_model_config_id)
|
||||
if original_app_model_config is None:
|
||||
raise ValueError("Original app model config not found")
|
||||
agent_mode = original_app_model_config.agent_mode_dict
|
||||
|
||||
@ -2,6 +2,7 @@ from typing import Literal
|
||||
|
||||
from flask_restx import Resource, marshal_with
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from constants.languages import supported_language
|
||||
@ -75,7 +76,7 @@ class AppSite(Resource):
|
||||
def post(self, app_model):
|
||||
args = AppSiteUpdatePayload.model_validate(console_ns.payload or {})
|
||||
current_user, _ = current_account_with_tenant()
|
||||
site = db.session.query(Site).where(Site.app_id == app_model.id).first()
|
||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||
if not site:
|
||||
raise NotFound
|
||||
|
||||
@ -124,7 +125,7 @@ class AppSiteAccessTokenReset(Resource):
|
||||
@marshal_with(app_site_model)
|
||||
def post(self, app_model):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
site = db.session.query(Site).where(Site.app_id == app_model.id).first()
|
||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||
|
||||
if not site:
|
||||
raise NotFound
|
||||
|
||||
@ -2,6 +2,8 @@ from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import ParamSpec, TypeVar, Union
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
@ -15,16 +17,14 @@ R1 = TypeVar("R1")
|
||||
|
||||
def _load_app_model(app_id: str) -> App | None:
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
app_model = (
|
||||
db.session.query(App)
|
||||
.where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal")
|
||||
.first()
|
||||
app_model = db.session.scalar(
|
||||
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
|
||||
)
|
||||
return app_model
|
||||
|
||||
|
||||
def _load_app_model_with_trial(app_id: str) -> App | None:
|
||||
app_model = db.session.query(App).where(App.id == app_id, App.status == "normal").first()
|
||||
app_model = db.session.scalar(select(App).where(App.id == app_id, App.status == "normal").limit(1))
|
||||
return app_model
|
||||
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ from fields.document_fields import document_status_fields
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import ApiToken, Dataset, Document, DocumentSegment, UploadFile
|
||||
from models.dataset import DatasetPermission, DatasetPermissionEnum
|
||||
from models.enums import SegmentStatus
|
||||
from models.enums import ApiTokenType, SegmentStatus
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
|
||||
@ -777,7 +777,7 @@ class DatasetIndexingStatusApi(Resource):
|
||||
class DatasetApiKeyApi(Resource):
|
||||
max_keys = 10
|
||||
token_prefix = "dataset-"
|
||||
resource_type = "dataset"
|
||||
resource_type = ApiTokenType.DATASET
|
||||
|
||||
@console_ns.doc("get_dataset_api_keys")
|
||||
@console_ns.doc(description="Get dataset API keys")
|
||||
@ -826,7 +826,7 @@ class DatasetApiKeyApi(Resource):
|
||||
|
||||
@console_ns.route("/datasets/api-keys/<uuid:api_key_id>")
|
||||
class DatasetApiDeleteApi(Resource):
|
||||
resource_type = "dataset"
|
||||
resource_type = ApiTokenType.DATASET
|
||||
|
||||
@console_ns.doc("delete_dataset_api_key")
|
||||
@console_ns.doc(description="Delete dataset API key")
|
||||
|
||||
@ -705,7 +705,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
app_id=self._application_generate_entity.app_config.app_id,
|
||||
workflow_id=self._workflow.id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
created_from=created_from.value,
|
||||
created_from=created_from,
|
||||
created_by_role=self._created_by_role,
|
||||
created_by=self._user_id,
|
||||
)
|
||||
|
||||
@ -918,11 +918,11 @@ class ProviderManager:
|
||||
|
||||
trail_pool = CreditPoolService.get_pool(
|
||||
tenant_id=tenant_id,
|
||||
pool_type=ProviderQuotaType.TRIAL.value,
|
||||
pool_type=ProviderQuotaType.TRIAL,
|
||||
)
|
||||
paid_pool = CreditPoolService.get_pool(
|
||||
tenant_id=tenant_id,
|
||||
pool_type=ProviderQuotaType.PAID.value,
|
||||
pool_type=ProviderQuotaType.PAID,
|
||||
)
|
||||
else:
|
||||
trail_pool = None
|
||||
|
||||
@ -13,6 +13,7 @@ from pymochow.exception import ServerError # type: ignore
|
||||
from pymochow.model.database import Database
|
||||
from pymochow.model.enum import FieldType, IndexState, IndexType, MetricType, ServerErrCode, TableState # type: ignore
|
||||
from pymochow.model.schema import (
|
||||
AutoBuildRowCountIncrement,
|
||||
Field,
|
||||
FilteringIndex,
|
||||
HNSWParams,
|
||||
@ -51,6 +52,9 @@ class BaiduConfig(BaseModel):
|
||||
replicas: int = 3
|
||||
inverted_index_analyzer: str = "DEFAULT_ANALYZER"
|
||||
inverted_index_parser_mode: str = "COARSE_MODE"
|
||||
auto_build_row_count_increment: int = 500
|
||||
auto_build_row_count_increment_ratio: float = 0.05
|
||||
rebuild_index_timeout_in_seconds: int = 300
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@ -107,18 +111,6 @@ class BaiduVector(BaseVector):
|
||||
rows.append(row)
|
||||
table.upsert(rows=rows)
|
||||
|
||||
# rebuild vector index after upsert finished
|
||||
table.rebuild_index(self.vector_index)
|
||||
timeout = 3600 # 1 hour timeout
|
||||
start_time = time.time()
|
||||
while True:
|
||||
time.sleep(1)
|
||||
index = table.describe_index(self.vector_index)
|
||||
if index.state == IndexState.NORMAL:
|
||||
break
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError(f"Index rebuild timeout after {timeout} seconds")
|
||||
|
||||
def text_exists(self, id: str) -> bool:
|
||||
res = self._db.table(self._collection_name).query(primary_key={VDBField.PRIMARY_KEY: id})
|
||||
if res and res.code == 0:
|
||||
@ -232,8 +224,14 @@ class BaiduVector(BaseVector):
|
||||
return self._client.database(self._client_config.database)
|
||||
|
||||
def _table_existed(self) -> bool:
|
||||
tables = self._db.list_table()
|
||||
return any(table.table_name == self._collection_name for table in tables)
|
||||
try:
|
||||
table = self._db.table(self._collection_name)
|
||||
except ServerError as e:
|
||||
if e.code == ServerErrCode.TABLE_NOT_EXIST:
|
||||
return False
|
||||
else:
|
||||
raise
|
||||
return True
|
||||
|
||||
def _create_table(self, dimension: int):
|
||||
# Try to grab distributed lock and create table
|
||||
@ -287,6 +285,11 @@ class BaiduVector(BaseVector):
|
||||
field=VDBField.VECTOR,
|
||||
metric_type=metric_type,
|
||||
params=HNSWParams(m=16, efconstruction=200),
|
||||
auto_build=True,
|
||||
auto_build_index_policy=AutoBuildRowCountIncrement(
|
||||
row_count_increment=self._client_config.auto_build_row_count_increment,
|
||||
row_count_increment_ratio=self._client_config.auto_build_row_count_increment_ratio,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@ -335,7 +338,7 @@ class BaiduVector(BaseVector):
|
||||
)
|
||||
|
||||
# Wait for table created
|
||||
timeout = 300 # 5 minutes timeout
|
||||
timeout = self._client_config.rebuild_index_timeout_in_seconds # default 5 minutes timeout
|
||||
start_time = time.time()
|
||||
while True:
|
||||
time.sleep(1)
|
||||
@ -345,6 +348,20 @@ class BaiduVector(BaseVector):
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError(f"Table creation timeout after {timeout} seconds")
|
||||
redis_client.set(table_exist_cache_key, 1, ex=3600)
|
||||
# rebuild vector index immediately after table created, make sure index is ready
|
||||
table.rebuild_index(self.vector_index)
|
||||
timeout = 3600 # 1 hour timeout
|
||||
self._wait_for_index_ready(table, timeout)
|
||||
|
||||
def _wait_for_index_ready(self, table, timeout: int = 3600):
|
||||
start_time = time.time()
|
||||
while True:
|
||||
time.sleep(1)
|
||||
index = table.describe_index(self.vector_index)
|
||||
if index.state == IndexState.NORMAL:
|
||||
break
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError(f"Index rebuild timeout after {timeout} seconds")
|
||||
|
||||
|
||||
class BaiduVectorFactory(AbstractVectorFactory):
|
||||
@ -369,5 +386,8 @@ class BaiduVectorFactory(AbstractVectorFactory):
|
||||
replicas=dify_config.BAIDU_VECTOR_DB_REPLICAS,
|
||||
inverted_index_analyzer=dify_config.BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER,
|
||||
inverted_index_parser_mode=dify_config.BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE,
|
||||
auto_build_row_count_increment=dify_config.BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT,
|
||||
auto_build_row_count_increment_ratio=dify_config.BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO,
|
||||
rebuild_index_timeout_in_seconds=dify_config.BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
@ -33,6 +33,7 @@ from core.rag.models.document import Document
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.dataset import Dataset, TidbAuthBinding
|
||||
from models.enums import TidbAuthBindingStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qdrant_client import grpc # noqa
|
||||
@ -452,7 +453,7 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory):
|
||||
password=new_cluster["password"],
|
||||
tenant_id=dataset.tenant_id,
|
||||
active=True,
|
||||
status="ACTIVE",
|
||||
status=TidbAuthBindingStatus.ACTIVE,
|
||||
)
|
||||
db.session.add(new_tidb_auth_binding)
|
||||
db.session.commit()
|
||||
|
||||
@ -9,6 +9,7 @@ from configs import dify_config
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.dataset import TidbAuthBinding
|
||||
from models.enums import TidbAuthBindingStatus
|
||||
|
||||
|
||||
class TidbService:
|
||||
@ -170,7 +171,7 @@ class TidbService:
|
||||
userPrefix = item["userPrefix"]
|
||||
if state == "ACTIVE" and len(userPrefix) > 0:
|
||||
cluster_info = tidb_serverless_list_map[item["clusterId"]]
|
||||
cluster_info.status = "ACTIVE"
|
||||
cluster_info.status = TidbAuthBindingStatus.ACTIVE
|
||||
cluster_info.account = f"{userPrefix}.root"
|
||||
db.session.add(cluster_info)
|
||||
db.session.commit()
|
||||
|
||||
@ -95,15 +95,11 @@ class FirecrawlApp:
|
||||
if response.status_code == 200:
|
||||
crawl_status_response = response.json()
|
||||
if crawl_status_response.get("status") == "completed":
|
||||
total = crawl_status_response.get("total", 0)
|
||||
if total == 0:
|
||||
# Normalize to avoid None bypassing the zero-guard when the API returns null.
|
||||
total = crawl_status_response.get("total") or 0
|
||||
if total <= 0:
|
||||
raise Exception("Failed to check crawl status. Error: No page found")
|
||||
data = crawl_status_response.get("data", [])
|
||||
url_data_list: list[FirecrawlDocumentData] = []
|
||||
for item in data:
|
||||
if isinstance(item, dict) and "metadata" in item and "markdown" in item:
|
||||
url_data = self._extract_common_fields(item)
|
||||
url_data_list.append(url_data)
|
||||
url_data_list = self._collect_all_crawl_pages(crawl_status_response, headers)
|
||||
if url_data_list:
|
||||
file_key = "website_files/" + job_id + ".txt"
|
||||
try:
|
||||
@ -120,6 +116,36 @@ class FirecrawlApp:
|
||||
self._handle_error(response, "check crawl status")
|
||||
raise RuntimeError("unreachable: _handle_error always raises")
|
||||
|
||||
def _collect_all_crawl_pages(
|
||||
self, first_page: dict[str, Any], headers: dict[str, str]
|
||||
) -> list[FirecrawlDocumentData]:
|
||||
"""Collect all crawl result pages by following pagination links.
|
||||
|
||||
Raises an exception if any paginated request fails, to avoid returning
|
||||
partial data that is inconsistent with the reported total.
|
||||
|
||||
The number of pages processed is capped at ``total`` (the
|
||||
server-reported page count) to guard against infinite loops caused by
|
||||
a misbehaving server that keeps returning a ``next`` URL.
|
||||
"""
|
||||
total: int = first_page.get("total") or 0
|
||||
url_data_list: list[FirecrawlDocumentData] = []
|
||||
current_page = first_page
|
||||
pages_processed = 0
|
||||
while True:
|
||||
for item in current_page.get("data", []):
|
||||
if isinstance(item, dict) and "metadata" in item and "markdown" in item:
|
||||
url_data_list.append(self._extract_common_fields(item))
|
||||
next_url: str | None = current_page.get("next")
|
||||
pages_processed += 1
|
||||
if not next_url or pages_processed >= total:
|
||||
break
|
||||
response = self._get_request(next_url, headers)
|
||||
if response.status_code != 200:
|
||||
self._handle_error(response, "fetch next crawl page")
|
||||
current_page = response.json()
|
||||
return url_data_list
|
||||
|
||||
def _format_crawl_status_response(
|
||||
self,
|
||||
status: str,
|
||||
|
||||
@ -43,7 +43,9 @@ from .enums import (
|
||||
IndexingStatus,
|
||||
ProcessRuleMode,
|
||||
SegmentStatus,
|
||||
SegmentType,
|
||||
SummaryStatus,
|
||||
TidbAuthBindingStatus,
|
||||
)
|
||||
from .model import App, Tag, TagBinding, UploadFile
|
||||
from .types import AdjustedJSON, BinaryData, EnumText, LongText, StringUUID, adjusted_json_index
|
||||
@ -998,7 +1000,9 @@ class ChildChunk(Base):
|
||||
# indexing fields
|
||||
index_node_id = mapped_column(String(255), nullable=True)
|
||||
index_node_hash = mapped_column(String(255), nullable=True)
|
||||
type = mapped_column(String(255), nullable=False, server_default=sa.text("'automatic'"))
|
||||
type: Mapped[SegmentType] = mapped_column(
|
||||
EnumText(SegmentType, length=255), nullable=False, server_default=sa.text("'automatic'")
|
||||
)
|
||||
created_by = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=sa.func.current_timestamp())
|
||||
updated_by = mapped_column(StringUUID, nullable=True)
|
||||
@ -1239,7 +1243,9 @@ class TidbAuthBinding(TypeBase):
|
||||
cluster_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
cluster_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
|
||||
status: Mapped[str] = mapped_column(sa.String(255), nullable=False, server_default=sa.text("'CREATING'"))
|
||||
status: Mapped[TidbAuthBindingStatus] = mapped_column(
|
||||
EnumText(TidbAuthBindingStatus, length=255), nullable=False, server_default=sa.text("'CREATING'")
|
||||
)
|
||||
account: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@ -222,6 +222,13 @@ class DatasetMetadataType(StrEnum):
|
||||
TIME = "time"
|
||||
|
||||
|
||||
class SegmentType(StrEnum):
|
||||
"""Document segment type"""
|
||||
|
||||
AUTOMATIC = "automatic"
|
||||
CUSTOMIZED = "customized"
|
||||
|
||||
|
||||
class SegmentStatus(StrEnum):
|
||||
"""Document segment status"""
|
||||
|
||||
@ -323,3 +330,10 @@ class ProviderQuotaType(StrEnum):
|
||||
if member.value == value:
|
||||
return member
|
||||
raise ValueError(f"No matching enum found for value '{value}'")
|
||||
|
||||
|
||||
class ApiTokenType(StrEnum):
|
||||
"""API Token type"""
|
||||
|
||||
APP = "app"
|
||||
DATASET = "dataset"
|
||||
|
||||
@ -21,7 +21,7 @@ from configs import dify_config
|
||||
from constants import DEFAULT_FILE_NUMBER_LIMITS
|
||||
from core.tools.signature import sign_tool_file
|
||||
from dify_graph.enums import WorkflowExecutionStatus
|
||||
from dify_graph.file import FILE_MODEL_IDENTITY, File, FileTransferMethod
|
||||
from dify_graph.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType
|
||||
from dify_graph.file import helpers as file_helpers
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from libs.helper import generate_string # type: ignore[import-not-found]
|
||||
@ -31,6 +31,7 @@ from .account import Account, Tenant
|
||||
from .base import Base, TypeBase, gen_uuidv4_string
|
||||
from .engine import db
|
||||
from .enums import (
|
||||
ApiTokenType,
|
||||
AppMCPServerStatus,
|
||||
AppStatus,
|
||||
BannerStatus,
|
||||
@ -43,6 +44,7 @@ from .enums import (
|
||||
MessageChainType,
|
||||
MessageFileBelongsTo,
|
||||
MessageStatus,
|
||||
ProviderQuotaType,
|
||||
TagType,
|
||||
)
|
||||
from .provider_ids import GenericProviderID
|
||||
@ -1783,7 +1785,7 @@ class MessageFile(TypeBase):
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
message_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
type: Mapped[FileType] = mapped_column(EnumText(FileType, length=255), nullable=False)
|
||||
transfer_method: Mapped[FileTransferMethod] = mapped_column(
|
||||
EnumText(FileTransferMethod, length=255), nullable=False
|
||||
)
|
||||
@ -2095,7 +2097,7 @@ class ApiToken(Base): # bug: this uses setattr so idk the field.
|
||||
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
|
||||
app_id = mapped_column(StringUUID, nullable=True)
|
||||
tenant_id = mapped_column(StringUUID, nullable=True)
|
||||
type = mapped_column(String(16), nullable=False)
|
||||
type: Mapped[ApiTokenType] = mapped_column(EnumText(ApiTokenType, length=16), nullable=False)
|
||||
token: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
last_used_at = mapped_column(sa.DateTime, nullable=True)
|
||||
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
@ -2490,7 +2492,9 @@ class TenantCreditPool(TypeBase):
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
pool_type: Mapped[str] = mapped_column(String(40), nullable=False, default="trial", server_default="trial")
|
||||
pool_type: Mapped[ProviderQuotaType] = mapped_column(
|
||||
EnumText(ProviderQuotaType, length=40), nullable=False, default=ProviderQuotaType.TRIAL, server_default="trial"
|
||||
)
|
||||
quota_limit: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
quota_used: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@ -1221,7 +1221,9 @@ class WorkflowAppLog(TypeBase):
|
||||
app_id: Mapped[str] = mapped_column(StringUUID)
|
||||
workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
workflow_run_id: Mapped[str] = mapped_column(StringUUID)
|
||||
created_from: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_from: Mapped[WorkflowAppLogCreatedFrom] = mapped_column(
|
||||
EnumText(WorkflowAppLogCreatedFrom, length=255), nullable=False
|
||||
)
|
||||
created_by_role: Mapped[CreatorUserRole] = mapped_column(EnumText(CreatorUserRole, length=255), nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
@ -1301,10 +1303,14 @@ class WorkflowArchiveLog(TypeBase):
|
||||
|
||||
log_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
log_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
log_created_from: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
log_created_from: Mapped[WorkflowAppLogCreatedFrom | None] = mapped_column(
|
||||
EnumText(WorkflowAppLogCreatedFrom, length=255), nullable=True
|
||||
)
|
||||
|
||||
run_version: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
run_status: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
run_status: Mapped[WorkflowExecutionStatus] = mapped_column(
|
||||
EnumText(WorkflowExecutionStatus, length=255), nullable=False
|
||||
)
|
||||
run_triggered_from: Mapped[WorkflowRunTriggeredFrom] = mapped_column(
|
||||
EnumText(WorkflowRunTriggeredFrom, length=255), nullable=False
|
||||
)
|
||||
|
||||
@ -8,6 +8,7 @@ from configs import dify_config
|
||||
from core.rag.datasource.vdb.tidb_on_qdrant.tidb_service import TidbService
|
||||
from extensions.ext_database import db
|
||||
from models.dataset import TidbAuthBinding
|
||||
from models.enums import TidbAuthBindingStatus
|
||||
|
||||
|
||||
@app.celery.task(queue="dataset")
|
||||
@ -57,7 +58,7 @@ def create_clusters(batch_size):
|
||||
account=new_cluster["account"],
|
||||
password=new_cluster["password"],
|
||||
active=False,
|
||||
status="CREATING",
|
||||
status=TidbAuthBindingStatus.CREATING,
|
||||
)
|
||||
db.session.add(tidb_auth_binding)
|
||||
db.session.commit()
|
||||
|
||||
@ -9,6 +9,7 @@ from configs import dify_config
|
||||
from core.rag.datasource.vdb.tidb_on_qdrant.tidb_service import TidbService
|
||||
from extensions.ext_database import db
|
||||
from models.dataset import TidbAuthBinding
|
||||
from models.enums import TidbAuthBindingStatus
|
||||
|
||||
|
||||
@app.celery.task(queue="dataset")
|
||||
@ -18,7 +19,10 @@ def update_tidb_serverless_status_task():
|
||||
try:
|
||||
# check the number of idle tidb serverless
|
||||
tidb_serverless_list = db.session.scalars(
|
||||
select(TidbAuthBinding).where(TidbAuthBinding.active == False, TidbAuthBinding.status == "CREATING")
|
||||
select(TidbAuthBinding).where(
|
||||
TidbAuthBinding.active == False,
|
||||
TidbAuthBinding.status == TidbAuthBindingStatus.CREATING,
|
||||
)
|
||||
).all()
|
||||
if len(tidb_serverless_list) == 0:
|
||||
return
|
||||
|
||||
@ -7,6 +7,7 @@ from configs import dify_config
|
||||
from core.errors.error import QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
from models import TenantCreditPool
|
||||
from models.enums import ProviderQuotaType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -16,7 +17,10 @@ class CreditPoolService:
|
||||
def create_default_pool(cls, tenant_id: str) -> TenantCreditPool:
|
||||
"""create default credit pool for new tenant"""
|
||||
credit_pool = TenantCreditPool(
|
||||
tenant_id=tenant_id, quota_limit=dify_config.HOSTED_POOL_CREDITS, quota_used=0, pool_type="trial"
|
||||
tenant_id=tenant_id,
|
||||
quota_limit=dify_config.HOSTED_POOL_CREDITS,
|
||||
quota_used=0,
|
||||
pool_type=ProviderQuotaType.TRIAL,
|
||||
)
|
||||
db.session.add(credit_pool)
|
||||
db.session.commit()
|
||||
|
||||
@ -58,6 +58,7 @@ from models.enums import (
|
||||
IndexingStatus,
|
||||
ProcessRuleMode,
|
||||
SegmentStatus,
|
||||
SegmentType,
|
||||
)
|
||||
from models.model import UploadFile
|
||||
from models.provider_ids import ModelProviderID
|
||||
@ -3786,7 +3787,7 @@ class SegmentService:
|
||||
child_chunk.word_count = len(child_chunk.content)
|
||||
child_chunk.updated_by = current_user.id
|
||||
child_chunk.updated_at = naive_utc_now()
|
||||
child_chunk.type = "customized"
|
||||
child_chunk.type = SegmentType.CUSTOMIZED
|
||||
update_child_chunks.append(child_chunk)
|
||||
else:
|
||||
new_child_chunks_args.append(child_chunk_update_args)
|
||||
@ -3845,7 +3846,7 @@ class SegmentService:
|
||||
child_chunk.word_count = len(content)
|
||||
child_chunk.updated_by = current_user.id
|
||||
child_chunk.updated_at = naive_utc_now()
|
||||
child_chunk.type = "customized"
|
||||
child_chunk.type = SegmentType.CUSTOMIZED
|
||||
db.session.add(child_chunk)
|
||||
VectorService.update_child_chunk_vector([], [child_chunk], [], dataset)
|
||||
db.session.commit()
|
||||
|
||||
@ -179,7 +179,7 @@ def _record_trigger_failure_log(
|
||||
app_id=workflow.app_id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API.value,
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=created_by_role,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
@ -13,6 +13,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken
|
||||
from services.api_token_service import ApiTokenCache, CachedApiToken
|
||||
|
||||
@ -279,7 +280,7 @@ class TestEndToEndCacheFlow:
|
||||
test_token = ApiToken()
|
||||
test_token.id = "test-e2e-id"
|
||||
test_token.token = test_token_value
|
||||
test_token.type = test_scope
|
||||
test_token.type = ApiTokenType.APP
|
||||
test_token.app_id = "test-app"
|
||||
test_token.tenant_id = "test-tenant"
|
||||
test_token.last_used_at = None
|
||||
|
||||
@ -0,0 +1,342 @@
|
||||
"""Authenticated controller integration tests for console message APIs."""
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.console.app.message import ChatMessagesQuery, FeedbackExportQuery, MessageFeedbackPayload
|
||||
from controllers.console.app.message import attach_message_extra_contents as _attach_message_extra_contents
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import ConversationFromSource, FeedbackRating
|
||||
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
create_console_app,
|
||||
)
|
||||
|
||||
|
||||
def _create_conversation(db_session: Session, app_id: str, account_id: str, mode: AppMode) -> Conversation:
|
||||
conversation = Conversation(
|
||||
app_id=app_id,
|
||||
app_model_config_id=None,
|
||||
model_provider=None,
|
||||
model_id="",
|
||||
override_model_configs=None,
|
||||
mode=mode,
|
||||
name="Test Conversation",
|
||||
inputs={},
|
||||
introduction="",
|
||||
system_instruction="",
|
||||
system_instruction_tokens=0,
|
||||
status="normal",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account_id,
|
||||
)
|
||||
db_session.add(conversation)
|
||||
db_session.commit()
|
||||
return conversation
|
||||
|
||||
|
||||
def _create_message(
|
||||
db_session: Session,
|
||||
app_id: str,
|
||||
conversation_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
created_at_offset_seconds: int = 0,
|
||||
) -> Message:
|
||||
created_at = naive_utc_now() + timedelta(seconds=created_at_offset_seconds)
|
||||
message = Message(
|
||||
app_id=app_id,
|
||||
model_provider=None,
|
||||
model_id="",
|
||||
override_model_configs=None,
|
||||
conversation_id=conversation_id,
|
||||
inputs={},
|
||||
query="Hello",
|
||||
message={"type": "text", "content": "Hello"},
|
||||
message_tokens=1,
|
||||
message_unit_price=Decimal("0.0001"),
|
||||
message_price_unit=Decimal("0.001"),
|
||||
answer="Hi there",
|
||||
answer_tokens=1,
|
||||
answer_unit_price=Decimal("0.0001"),
|
||||
answer_price_unit=Decimal("0.001"),
|
||||
parent_message_id=None,
|
||||
provider_response_latency=0,
|
||||
total_price=Decimal("0.0002"),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account_id,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
app_mode=AppMode.CHAT,
|
||||
)
|
||||
db_session.add(message)
|
||||
db_session.commit()
|
||||
return message
|
||||
|
||||
|
||||
class TestMessageValidators:
|
||||
def test_chat_messages_query_validators(self) -> None:
|
||||
assert ChatMessagesQuery.empty_to_none("") is None
|
||||
assert ChatMessagesQuery.empty_to_none("val") == "val"
|
||||
assert ChatMessagesQuery.validate_uuid(None) is None
|
||||
assert (
|
||||
ChatMessagesQuery.validate_uuid("123e4567-e89b-12d3-a456-426614174000")
|
||||
== "123e4567-e89b-12d3-a456-426614174000"
|
||||
)
|
||||
|
||||
def test_message_feedback_validators(self) -> None:
|
||||
assert (
|
||||
MessageFeedbackPayload.validate_message_id("123e4567-e89b-12d3-a456-426614174000")
|
||||
== "123e4567-e89b-12d3-a456-426614174000"
|
||||
)
|
||||
|
||||
def test_feedback_export_validators(self) -> None:
|
||||
assert FeedbackExportQuery.parse_bool(None) is None
|
||||
assert FeedbackExportQuery.parse_bool(True) is True
|
||||
assert FeedbackExportQuery.parse_bool("1") is True
|
||||
assert FeedbackExportQuery.parse_bool("0") is False
|
||||
assert FeedbackExportQuery.parse_bool("off") is False
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
FeedbackExportQuery.parse_bool("invalid")
|
||||
|
||||
|
||||
def test_chat_message_list_not_found(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/chat-messages",
|
||||
query_string={"conversation_id": str(uuid4())},
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["code"] == "not_found"
|
||||
|
||||
|
||||
def test_chat_message_list_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, app.mode)
|
||||
_create_message(db_session_with_containers, app.id, conversation.id, account.id, created_at_offset_seconds=0)
|
||||
second = _create_message(
|
||||
db_session_with_containers,
|
||||
app.id,
|
||||
conversation.id,
|
||||
account.id,
|
||||
created_at_offset_seconds=1,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"controllers.console.app.message.attach_message_extra_contents",
|
||||
side_effect=_attach_message_extra_contents,
|
||||
):
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/chat-messages",
|
||||
query_string={"conversation_id": conversation.id, "limit": 1},
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["limit"] == 1
|
||||
assert payload["has_more"] is True
|
||||
assert len(payload["data"]) == 1
|
||||
assert payload["data"][0]["id"] == second.id
|
||||
|
||||
|
||||
def test_message_feedback_not_found(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
|
||||
response = test_client_with_containers.post(
|
||||
f"/console/api/apps/{app.id}/feedbacks",
|
||||
json={"message_id": str(uuid4()), "rating": "like"},
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["code"] == "not_found"
|
||||
|
||||
|
||||
def test_message_feedback_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, app.mode)
|
||||
message = _create_message(db_session_with_containers, app.id, conversation.id, account.id)
|
||||
|
||||
response = test_client_with_containers.post(
|
||||
f"/console/api/apps/{app.id}/feedbacks",
|
||||
json={"message_id": message.id, "rating": "like"},
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"result": "success"}
|
||||
|
||||
feedback = db_session_with_containers.scalar(
|
||||
select(MessageFeedback).where(MessageFeedback.message_id == message.id)
|
||||
)
|
||||
assert feedback is not None
|
||||
assert feedback.rating == FeedbackRating.LIKE
|
||||
assert feedback.from_account_id == account.id
|
||||
|
||||
|
||||
def test_message_annotation_count(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, app.mode)
|
||||
message = _create_message(db_session_with_containers, app.id, conversation.id, account.id)
|
||||
db_session_with_containers.add(
|
||||
MessageAnnotation(
|
||||
app_id=app.id,
|
||||
conversation_id=conversation.id,
|
||||
message_id=message.id,
|
||||
question="Q",
|
||||
content="A",
|
||||
account_id=account.id,
|
||||
)
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/annotations/count",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"count": 1}
|
||||
|
||||
|
||||
def test_message_suggested_questions_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
message_id = str(uuid4())
|
||||
|
||||
with patch(
|
||||
"controllers.console.app.message.MessageService.get_suggested_questions_after_answer",
|
||||
return_value=["q1", "q2"],
|
||||
):
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/chat-messages/{message_id}/suggested-questions",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"data": ["q1", "q2"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "expected_status", "expected_code"),
|
||||
[
|
||||
(MessageNotExistsError(), 404, "not_found"),
|
||||
(ConversationNotExistsError(), 404, "not_found"),
|
||||
(ProviderTokenNotInitError(), 400, "provider_not_initialize"),
|
||||
(QuotaExceededError(), 400, "provider_quota_exceeded"),
|
||||
(ModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
|
||||
(SuggestedQuestionsAfterAnswerDisabledError(), 403, "app_suggested_questions_after_answer_disabled"),
|
||||
(Exception(), 500, "internal_server_error"),
|
||||
],
|
||||
)
|
||||
def test_message_suggested_questions_errors(
|
||||
exc: Exception,
|
||||
expected_status: int,
|
||||
expected_code: str,
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
message_id = str(uuid4())
|
||||
|
||||
with patch(
|
||||
"controllers.console.app.message.MessageService.get_suggested_questions_after_answer",
|
||||
side_effect=exc,
|
||||
):
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/chat-messages/{message_id}/suggested-questions",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == expected_status
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["code"] == expected_code
|
||||
|
||||
|
||||
def test_message_feedback_export_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
|
||||
with patch("services.feedback_service.FeedbackService.export_feedbacks", return_value={"exported": True}):
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/feedbacks/export",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"exported": True}
|
||||
|
||||
|
||||
def test_message_api_get_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, app.mode)
|
||||
message = _create_message(db_session_with_containers, app.id, conversation.id, account.id)
|
||||
|
||||
with patch(
|
||||
"controllers.console.app.message.attach_message_extra_contents",
|
||||
side_effect=_attach_message_extra_contents,
|
||||
):
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/messages/{message.id}",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["id"] == message.id
|
||||
@ -0,0 +1,334 @@
|
||||
"""Controller integration tests for console statistic routes."""
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import ConversationFromSource, FeedbackFromSource, FeedbackRating
|
||||
from models.model import AppMode, Conversation, Message, MessageFeedback
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
create_console_app,
|
||||
)
|
||||
|
||||
|
||||
def _create_conversation(
|
||||
db_session: Session,
|
||||
app_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
mode: AppMode,
|
||||
created_at_offset_days: int = 0,
|
||||
) -> Conversation:
|
||||
created_at = naive_utc_now() + timedelta(days=created_at_offset_days)
|
||||
conversation = Conversation(
|
||||
app_id=app_id,
|
||||
app_model_config_id=None,
|
||||
model_provider=None,
|
||||
model_id="",
|
||||
override_model_configs=None,
|
||||
mode=mode,
|
||||
name="Stats Conversation",
|
||||
inputs={},
|
||||
introduction="",
|
||||
system_instruction="",
|
||||
system_instruction_tokens=0,
|
||||
status="normal",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=account_id,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
db_session.add(conversation)
|
||||
db_session.commit()
|
||||
return conversation
|
||||
|
||||
|
||||
def _create_message(
|
||||
db_session: Session,
|
||||
app_id: str,
|
||||
conversation_id: str,
|
||||
*,
|
||||
from_account_id: str | None,
|
||||
from_end_user_id: str | None = None,
|
||||
message_tokens: int = 1,
|
||||
answer_tokens: int = 1,
|
||||
total_price: Decimal = Decimal("0.01"),
|
||||
provider_response_latency: float = 1.0,
|
||||
created_at_offset_days: int = 0,
|
||||
) -> Message:
|
||||
created_at = naive_utc_now() + timedelta(days=created_at_offset_days)
|
||||
message = Message(
|
||||
app_id=app_id,
|
||||
model_provider=None,
|
||||
model_id="",
|
||||
override_model_configs=None,
|
||||
conversation_id=conversation_id,
|
||||
inputs={},
|
||||
query="Hello",
|
||||
message={"type": "text", "content": "Hello"},
|
||||
message_tokens=message_tokens,
|
||||
message_unit_price=Decimal("0.001"),
|
||||
message_price_unit=Decimal("0.001"),
|
||||
answer="Hi there",
|
||||
answer_tokens=answer_tokens,
|
||||
answer_unit_price=Decimal("0.001"),
|
||||
answer_price_unit=Decimal("0.001"),
|
||||
parent_message_id=None,
|
||||
provider_response_latency=provider_response_latency,
|
||||
total_price=total_price,
|
||||
currency="USD",
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_end_user_id=from_end_user_id,
|
||||
from_account_id=from_account_id,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
app_mode=AppMode.CHAT,
|
||||
)
|
||||
db_session.add(message)
|
||||
db_session.commit()
|
||||
return message
|
||||
|
||||
|
||||
def _create_like_feedback(
|
||||
db_session: Session,
|
||||
app_id: str,
|
||||
conversation_id: str,
|
||||
message_id: str,
|
||||
account_id: str,
|
||||
) -> None:
|
||||
db_session.add(
|
||||
MessageFeedback(
|
||||
app_id=app_id,
|
||||
conversation_id=conversation_id,
|
||||
message_id=message_id,
|
||||
rating=FeedbackRating.LIKE,
|
||||
from_source=FeedbackFromSource.ADMIN,
|
||||
from_account_id=account_id,
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def test_daily_message_statistic(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, mode=app.mode)
|
||||
_create_message(db_session_with_containers, app.id, conversation.id, from_account_id=account.id)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/daily-messages",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"][0]["message_count"] == 1
|
||||
|
||||
|
||||
def test_daily_conversation_statistic(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, mode=app.mode)
|
||||
_create_message(db_session_with_containers, app.id, conversation.id, from_account_id=account.id)
|
||||
_create_message(db_session_with_containers, app.id, conversation.id, from_account_id=account.id)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/daily-conversations",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"][0]["conversation_count"] == 1
|
||||
|
||||
|
||||
def test_daily_terminals_statistic(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, mode=app.mode)
|
||||
_create_message(
|
||||
db_session_with_containers,
|
||||
app.id,
|
||||
conversation.id,
|
||||
from_account_id=None,
|
||||
from_end_user_id=str(uuid4()),
|
||||
)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/daily-end-users",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"][0]["terminal_count"] == 1
|
||||
|
||||
|
||||
def test_daily_token_cost_statistic(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, mode=app.mode)
|
||||
_create_message(
|
||||
db_session_with_containers,
|
||||
app.id,
|
||||
conversation.id,
|
||||
from_account_id=account.id,
|
||||
message_tokens=40,
|
||||
answer_tokens=60,
|
||||
total_price=Decimal("0.02"),
|
||||
)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/token-costs",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["data"][0]["token_count"] == 100
|
||||
assert Decimal(payload["data"][0]["total_price"]) == Decimal("0.02")
|
||||
|
||||
|
||||
def test_average_session_interaction_statistic(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, mode=app.mode)
|
||||
_create_message(db_session_with_containers, app.id, conversation.id, from_account_id=account.id)
|
||||
_create_message(db_session_with_containers, app.id, conversation.id, from_account_id=account.id)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/average-session-interactions",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"][0]["interactions"] == 2.0
|
||||
|
||||
|
||||
def test_user_satisfaction_rate_statistic(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, mode=app.mode)
|
||||
first = _create_message(db_session_with_containers, app.id, conversation.id, from_account_id=account.id)
|
||||
for _ in range(9):
|
||||
_create_message(db_session_with_containers, app.id, conversation.id, from_account_id=account.id)
|
||||
_create_like_feedback(db_session_with_containers, app.id, conversation.id, first.id, account.id)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/user-satisfaction-rate",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"][0]["rate"] == 100.0
|
||||
|
||||
|
||||
def test_average_response_time_statistic(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.COMPLETION)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, mode=app.mode)
|
||||
_create_message(
|
||||
db_session_with_containers,
|
||||
app.id,
|
||||
conversation.id,
|
||||
from_account_id=account.id,
|
||||
provider_response_latency=1.234,
|
||||
)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/average-response-time",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"][0]["latency"] == 1234.0
|
||||
|
||||
|
||||
def test_tokens_per_second_statistic(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
conversation = _create_conversation(db_session_with_containers, app.id, account.id, mode=app.mode)
|
||||
_create_message(
|
||||
db_session_with_containers,
|
||||
app.id,
|
||||
conversation.id,
|
||||
from_account_id=account.id,
|
||||
answer_tokens=31,
|
||||
provider_response_latency=2.0,
|
||||
)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/tokens-per-second",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"][0]["tps"] == 15.5
|
||||
|
||||
|
||||
def test_invalid_time_range(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", side_effect=ValueError("Invalid time")):
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/daily-messages?start=invalid&end=invalid",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "Invalid time"
|
||||
|
||||
|
||||
def test_time_range_params_passed(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
import datetime
|
||||
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
start = datetime.datetime.now()
|
||||
end = datetime.datetime.now()
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", return_value=(start, end)) as mock_parse:
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/statistics/daily-messages?start=something&end=something",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_parse.assert_called_once_with("something", "something", "UTC")
|
||||
@ -0,0 +1,415 @@
|
||||
"""Authenticated controller integration tests for workflow draft variable APIs."""
|
||||
|
||||
import uuid
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dify_graph.constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID
|
||||
from dify_graph.variables.segments import StringSegment
|
||||
from factories.variable_factory import segment_to_variable
|
||||
from models import Workflow
|
||||
from models.model import AppMode
|
||||
from models.workflow import WorkflowDraftVariable
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
create_console_app,
|
||||
)
|
||||
|
||||
|
||||
def _create_draft_workflow(
|
||||
db_session: Session,
|
||||
app_id: str,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
environment_variables: list | None = None,
|
||||
conversation_variables: list | None = None,
|
||||
) -> Workflow:
|
||||
workflow = Workflow.new(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
type="workflow",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph='{"nodes": [], "edges": []}',
|
||||
features="{}",
|
||||
created_by=account_id,
|
||||
environment_variables=environment_variables or [],
|
||||
conversation_variables=conversation_variables or [],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
db_session.add(workflow)
|
||||
db_session.commit()
|
||||
return workflow
|
||||
|
||||
|
||||
def _create_node_variable(
|
||||
db_session: Session,
|
||||
app_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
node_id: str = "node_1",
|
||||
name: str = "test_var",
|
||||
) -> WorkflowDraftVariable:
|
||||
variable = WorkflowDraftVariable.new_node_variable(
|
||||
app_id=app_id,
|
||||
user_id=user_id,
|
||||
node_id=node_id,
|
||||
name=name,
|
||||
value=StringSegment(value="test_value"),
|
||||
node_execution_id=str(uuid.uuid4()),
|
||||
visible=True,
|
||||
editable=True,
|
||||
)
|
||||
db_session.add(variable)
|
||||
db_session.commit()
|
||||
return variable
|
||||
|
||||
|
||||
def _create_system_variable(
|
||||
db_session: Session, app_id: str, user_id: str, name: str = "query"
|
||||
) -> WorkflowDraftVariable:
|
||||
variable = WorkflowDraftVariable.new_sys_variable(
|
||||
app_id=app_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
value=StringSegment(value="system-value"),
|
||||
node_execution_id=str(uuid.uuid4()),
|
||||
editable=True,
|
||||
)
|
||||
db_session.add(variable)
|
||||
db_session.commit()
|
||||
return variable
|
||||
|
||||
|
||||
def _build_environment_variable(name: str, value: str):
|
||||
return segment_to_variable(
|
||||
segment=StringSegment(value=value),
|
||||
selector=[ENVIRONMENT_VARIABLE_NODE_ID, name],
|
||||
name=name,
|
||||
description=f"Environment variable {name}",
|
||||
)
|
||||
|
||||
|
||||
def _build_conversation_variable(name: str, value: str):
|
||||
return segment_to_variable(
|
||||
segment=StringSegment(value=value),
|
||||
selector=[CONVERSATION_VARIABLE_NODE_ID, name],
|
||||
name=name,
|
||||
description=f"Conversation variable {name}",
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_variable_collection_get_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
_create_draft_workflow(db_session_with_containers, app.id, tenant.id, account.id)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/variables?page=1&limit=20",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"items": [], "total": 0}
|
||||
|
||||
|
||||
def test_workflow_variable_collection_get_not_exist(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/variables",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["code"] == "draft_workflow_not_exist"
|
||||
|
||||
|
||||
def test_workflow_variable_collection_delete(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
_create_node_variable(db_session_with_containers, app.id, account.id)
|
||||
_create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_2", name="other_var")
|
||||
|
||||
response = test_client_with_containers.delete(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/variables",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
remaining = db_session_with_containers.scalars(
|
||||
select(WorkflowDraftVariable).where(
|
||||
WorkflowDraftVariable.app_id == app.id,
|
||||
WorkflowDraftVariable.user_id == account.id,
|
||||
)
|
||||
).all()
|
||||
assert remaining == []
|
||||
|
||||
|
||||
def test_node_variable_collection_get_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
node_variable = _create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_123")
|
||||
_create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_456", name="other")
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/nodes/node_123/variables",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert [item["id"] for item in payload["items"]] == [node_variable.id]
|
||||
|
||||
|
||||
def test_node_variable_collection_get_invalid_node_id(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/nodes/sys/variables",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["code"] == "invalid_param"
|
||||
|
||||
|
||||
def test_node_variable_collection_delete(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
target = _create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_123")
|
||||
untouched = _create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_456")
|
||||
target_id = target.id
|
||||
untouched_id = untouched.id
|
||||
|
||||
response = test_client_with_containers.delete(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/nodes/node_123/variables",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert (
|
||||
db_session_with_containers.scalar(select(WorkflowDraftVariable).where(WorkflowDraftVariable.id == target_id))
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
db_session_with_containers.scalar(select(WorkflowDraftVariable).where(WorkflowDraftVariable.id == untouched_id))
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def test_variable_api_get_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
_create_draft_workflow(db_session_with_containers, app.id, tenant.id, account.id)
|
||||
variable = _create_node_variable(db_session_with_containers, app.id, account.id)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/variables/{variable.id}",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["id"] == variable.id
|
||||
assert payload["name"] == "test_var"
|
||||
|
||||
|
||||
def test_variable_api_get_not_found(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
_create_draft_workflow(db_session_with_containers, app.id, tenant.id, account.id)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/variables/{uuid.uuid4()}",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["code"] == "not_found"
|
||||
|
||||
|
||||
def test_variable_api_patch_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
_create_draft_workflow(db_session_with_containers, app.id, tenant.id, account.id)
|
||||
variable = _create_node_variable(db_session_with_containers, app.id, account.id)
|
||||
|
||||
response = test_client_with_containers.patch(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/variables/{variable.id}",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
json={"name": "renamed_var"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["id"] == variable.id
|
||||
assert payload["name"] == "renamed_var"
|
||||
|
||||
refreshed = db_session_with_containers.scalar(
|
||||
select(WorkflowDraftVariable).where(WorkflowDraftVariable.id == variable.id)
|
||||
)
|
||||
assert refreshed is not None
|
||||
assert refreshed.name == "renamed_var"
|
||||
|
||||
|
||||
def test_variable_api_delete_success(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
_create_draft_workflow(db_session_with_containers, app.id, tenant.id, account.id)
|
||||
variable = _create_node_variable(db_session_with_containers, app.id, account.id)
|
||||
|
||||
response = test_client_with_containers.delete(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/variables/{variable.id}",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert (
|
||||
db_session_with_containers.scalar(select(WorkflowDraftVariable).where(WorkflowDraftVariable.id == variable.id))
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_variable_reset_api_put_success_returns_no_content_without_execution(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
_create_draft_workflow(db_session_with_containers, app.id, tenant.id, account.id)
|
||||
variable = _create_node_variable(db_session_with_containers, app.id, account.id)
|
||||
|
||||
response = test_client_with_containers.put(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/variables/{variable.id}/reset",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert (
|
||||
db_session_with_containers.scalar(select(WorkflowDraftVariable).where(WorkflowDraftVariable.id == variable.id))
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_conversation_variable_collection_get(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
_create_draft_workflow(
|
||||
db_session_with_containers,
|
||||
app.id,
|
||||
tenant.id,
|
||||
account.id,
|
||||
conversation_variables=[_build_conversation_variable("session_name", "Alice")],
|
||||
)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/conversation-variables",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert [item["name"] for item in payload["items"]] == ["session_name"]
|
||||
|
||||
created = db_session_with_containers.scalars(
|
||||
select(WorkflowDraftVariable).where(
|
||||
WorkflowDraftVariable.app_id == app.id,
|
||||
WorkflowDraftVariable.user_id == account.id,
|
||||
WorkflowDraftVariable.node_id == CONVERSATION_VARIABLE_NODE_ID,
|
||||
)
|
||||
).all()
|
||||
assert len(created) == 1
|
||||
|
||||
|
||||
def test_system_variable_collection_get(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
variable = _create_system_variable(db_session_with_containers, app.id, account.id)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/system-variables",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert [item["id"] for item in payload["items"]] == [variable.id]
|
||||
|
||||
|
||||
def test_environment_variable_collection_get(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW)
|
||||
_create_draft_workflow(
|
||||
db_session_with_containers,
|
||||
app.id,
|
||||
tenant.id,
|
||||
account.id,
|
||||
environment_variables=[_build_environment_variable("api_key", "secret-value")],
|
||||
)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/apps/{app.id}/workflows/draft/environment-variables",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["items"][0]["name"] == "api_key"
|
||||
assert payload["items"][0]["value"] == "secret-value"
|
||||
@ -0,0 +1,131 @@
|
||||
"""Controller integration tests for API key data source auth routes."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.source import DataSourceApiKeyAuthBinding
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
)
|
||||
|
||||
|
||||
def test_get_api_key_auth_data_source(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
binding = DataSourceApiKeyAuthBinding(
|
||||
tenant_id=tenant.id,
|
||||
category="api_key",
|
||||
provider="custom_provider",
|
||||
credentials=json.dumps({"auth_type": "api_key", "config": {"api_key": "encrypted"}}),
|
||||
disabled=False,
|
||||
)
|
||||
db_session_with_containers.add(binding)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
"/console/api/api-key-auth/data-source",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert len(payload["sources"]) == 1
|
||||
assert payload["sources"][0]["provider"] == "custom_provider"
|
||||
|
||||
|
||||
def test_get_api_key_auth_data_source_empty(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, _tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
|
||||
response = test_client_with_containers.get(
|
||||
"/console/api/api-key-auth/data-source",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"sources": []}
|
||||
|
||||
|
||||
def test_create_binding_successful(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, _tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
|
||||
with (
|
||||
patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.validate_api_key_auth_args"),
|
||||
patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.create_provider_auth"),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/api-key-auth/data-source/binding",
|
||||
json={"category": "api_key", "provider": "custom", "credentials": {"key": "value"}},
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"result": "success"}
|
||||
|
||||
|
||||
def test_create_binding_failure(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, _tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
|
||||
with (
|
||||
patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.validate_api_key_auth_args"),
|
||||
patch(
|
||||
"controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.create_provider_auth",
|
||||
side_effect=ValueError("Invalid structure"),
|
||||
),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/api-key-auth/data-source/binding",
|
||||
json={"category": "api_key", "provider": "custom", "credentials": {"key": "value"}},
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["code"] == "auth_failed"
|
||||
assert payload["message"] == "Invalid structure"
|
||||
|
||||
|
||||
def test_delete_binding_successful(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
binding = DataSourceApiKeyAuthBinding(
|
||||
tenant_id=tenant.id,
|
||||
category="api_key",
|
||||
provider="custom_provider",
|
||||
credentials=json.dumps({"auth_type": "api_key", "config": {"api_key": "encrypted"}}),
|
||||
disabled=False,
|
||||
)
|
||||
db_session_with_containers.add(binding)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
response = test_client_with_containers.delete(
|
||||
f"/console/api/api-key-auth/data-source/{binding.id}",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert (
|
||||
db_session_with_containers.scalar(
|
||||
select(DataSourceApiKeyAuthBinding).where(DataSourceApiKeyAuthBinding.id == binding.id)
|
||||
)
|
||||
is None
|
||||
)
|
||||
@ -0,0 +1,120 @@
|
||||
"""Controller integration tests for console OAuth data source routes."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.source import DataSourceOauthBinding
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
)
|
||||
|
||||
|
||||
def test_get_oauth_url_successful(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
provider = MagicMock()
|
||||
provider.get_authorization_url.return_value = "http://oauth.provider/auth"
|
||||
|
||||
with (
|
||||
patch("controllers.console.auth.data_source_oauth.get_oauth_providers", return_value={"notion": provider}),
|
||||
patch("controllers.console.auth.data_source_oauth.dify_config.NOTION_INTEGRATION_TYPE", None),
|
||||
):
|
||||
response = test_client_with_containers.get(
|
||||
"/console/api/oauth/data-source/notion",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert tenant.id == account.current_tenant_id
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"data": "http://oauth.provider/auth"}
|
||||
provider.get_authorization_url.assert_called_once()
|
||||
|
||||
|
||||
def test_get_oauth_url_invalid_provider(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, _tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
|
||||
with patch("controllers.console.auth.data_source_oauth.get_oauth_providers", return_value={"notion": MagicMock()}):
|
||||
response = test_client_with_containers.get(
|
||||
"/console/api/oauth/data-source/unknown_provider",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json() == {"error": "Invalid provider"}
|
||||
|
||||
|
||||
def test_oauth_callback_successful(test_client_with_containers: FlaskClient) -> None:
|
||||
with patch("controllers.console.auth.data_source_oauth.get_oauth_providers", return_value={"notion": MagicMock()}):
|
||||
response = test_client_with_containers.get("/console/api/oauth/data-source/callback/notion?code=mock_code")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "code=mock_code" in response.location
|
||||
|
||||
|
||||
def test_oauth_callback_missing_code(test_client_with_containers: FlaskClient) -> None:
|
||||
with patch("controllers.console.auth.data_source_oauth.get_oauth_providers", return_value={"notion": MagicMock()}):
|
||||
response = test_client_with_containers.get("/console/api/oauth/data-source/callback/notion")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "error=Access%20denied" in response.location
|
||||
|
||||
|
||||
def test_oauth_callback_invalid_provider(test_client_with_containers: FlaskClient) -> None:
|
||||
with patch("controllers.console.auth.data_source_oauth.get_oauth_providers", return_value={"notion": MagicMock()}):
|
||||
response = test_client_with_containers.get("/console/api/oauth/data-source/callback/invalid?code=mock_code")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json() == {"error": "Invalid provider"}
|
||||
|
||||
|
||||
def test_get_binding_successful(test_client_with_containers: FlaskClient) -> None:
|
||||
provider = MagicMock()
|
||||
with patch("controllers.console.auth.data_source_oauth.get_oauth_providers", return_value={"notion": provider}):
|
||||
response = test_client_with_containers.get("/console/api/oauth/data-source/binding/notion?code=auth_code_123")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"result": "success"}
|
||||
provider.get_access_token.assert_called_once_with("auth_code_123")
|
||||
|
||||
|
||||
def test_get_binding_missing_code(test_client_with_containers: FlaskClient) -> None:
|
||||
with patch("controllers.console.auth.data_source_oauth.get_oauth_providers", return_value={"notion": MagicMock()}):
|
||||
response = test_client_with_containers.get("/console/api/oauth/data-source/binding/notion?code=")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json() == {"error": "Invalid code"}
|
||||
|
||||
|
||||
def test_sync_successful(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
binding = DataSourceOauthBinding(
|
||||
tenant_id=tenant.id,
|
||||
access_token="test-access-token",
|
||||
provider="notion",
|
||||
source_info={"workspace_name": "Workspace", "workspace_icon": None, "workspace_id": tenant.id, "pages": []},
|
||||
disabled=False,
|
||||
)
|
||||
db_session_with_containers.add(binding)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
provider = MagicMock()
|
||||
with patch("controllers.console.auth.data_source_oauth.get_oauth_providers", return_value={"notion": provider}):
|
||||
response = test_client_with_containers.get(
|
||||
f"/console/api/oauth/data-source/notion/{binding.id}/sync",
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"result": "success"}
|
||||
provider.sync_data_source.assert_called_once_with(binding.id)
|
||||
@ -0,0 +1,365 @@
|
||||
"""Controller integration tests for console OAuth server routes."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.model import OAuthProviderApp
|
||||
from services.oauth_server import OAUTH_ACCESS_TOKEN_EXPIRES_IN
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
ensure_dify_setup,
|
||||
)
|
||||
|
||||
|
||||
def _build_oauth_provider_app() -> OAuthProviderApp:
|
||||
return OAuthProviderApp(
|
||||
app_icon="icon_url",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_secret",
|
||||
app_label={"en-US": "Test App"},
|
||||
redirect_uris=["http://localhost/callback"],
|
||||
scope="read,write",
|
||||
)
|
||||
|
||||
|
||||
def test_oauth_provider_successful_post(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider",
|
||||
json={"client_id": "test_client_id", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert payload["app_icon"] == "icon_url"
|
||||
assert payload["app_label"] == {"en-US": "Test App"}
|
||||
assert payload["scope"] == "read,write"
|
||||
|
||||
|
||||
def test_oauth_provider_invalid_redirect_uri(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider",
|
||||
json={"client_id": "test_client_id", "redirect_uri": "http://invalid/callback"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert "redirect_uri is invalid" in payload["message"]
|
||||
|
||||
|
||||
def test_oauth_provider_invalid_client_id(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider",
|
||||
json={"client_id": "test_invalid_client_id", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
payload = response.get_json()
|
||||
assert payload is not None
|
||||
assert "client_id is invalid" in payload["message"]
|
||||
|
||||
|
||||
def test_oauth_authorize_successful(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
account, _tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.sign_oauth_authorization_code",
|
||||
return_value="auth_code_123",
|
||||
) as mock_sign,
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/authorize",
|
||||
json={"client_id": "test_client_id"},
|
||||
headers=authenticate_console_client(test_client_with_containers, account),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"code": "auth_code_123"}
|
||||
mock_sign.assert_called_once_with("test_client_id", account.id)
|
||||
|
||||
|
||||
def test_oauth_token_authorization_code_grant(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.sign_oauth_access_token",
|
||||
return_value=("access_123", "refresh_123"),
|
||||
),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/token",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "authorization_code",
|
||||
"code": "auth_code",
|
||||
"client_secret": "test_secret",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {
|
||||
"access_token": "access_123",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
"refresh_token": "refresh_123",
|
||||
}
|
||||
|
||||
|
||||
def test_oauth_token_authorization_code_grant_missing_code(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/token",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "authorization_code",
|
||||
"client_secret": "test_secret",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "code is required"
|
||||
|
||||
|
||||
def test_oauth_token_authorization_code_grant_invalid_secret(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/token",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "authorization_code",
|
||||
"code": "auth_code",
|
||||
"client_secret": "invalid_secret",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "client_secret is invalid"
|
||||
|
||||
|
||||
def test_oauth_token_authorization_code_grant_invalid_redirect_uri(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/token",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "authorization_code",
|
||||
"code": "auth_code",
|
||||
"client_secret": "test_secret",
|
||||
"redirect_uri": "http://invalid/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "redirect_uri is invalid"
|
||||
|
||||
|
||||
def test_oauth_token_refresh_token_grant(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.sign_oauth_access_token",
|
||||
return_value=("new_access", "new_refresh"),
|
||||
),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/token",
|
||||
json={"client_id": "test_client_id", "grant_type": "refresh_token", "refresh_token": "refresh_123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {
|
||||
"access_token": "new_access",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
"refresh_token": "new_refresh",
|
||||
}
|
||||
|
||||
|
||||
def test_oauth_token_refresh_token_grant_missing_token(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/token",
|
||||
json={"client_id": "test_client_id", "grant_type": "refresh_token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "refresh_token is required"
|
||||
|
||||
|
||||
def test_oauth_token_invalid_grant_type(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/token",
|
||||
json={"client_id": "test_client_id", "grant_type": "invalid_grant"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "invalid grant_type"
|
||||
|
||||
|
||||
def test_oauth_account_successful_retrieval(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
account, _tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
account.avatar = "avatar_url"
|
||||
db_session_with_containers.commit()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.validate_oauth_access_token",
|
||||
return_value=account,
|
||||
),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/account",
|
||||
json={"client_id": "test_client_id"},
|
||||
headers={"Authorization": "Bearer valid_access_token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {
|
||||
"name": "Test User",
|
||||
"email": account.email,
|
||||
"avatar": "avatar_url",
|
||||
"interface_language": "en-US",
|
||||
"timezone": "UTC",
|
||||
}
|
||||
|
||||
|
||||
def test_oauth_account_missing_authorization_header(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/account",
|
||||
json={"client_id": "test_client_id"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"error": "Authorization header is required"}
|
||||
|
||||
|
||||
def test_oauth_account_invalid_authorization_header_format(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
ensure_dify_setup(db_session_with_containers)
|
||||
|
||||
with patch(
|
||||
"controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app",
|
||||
return_value=_build_oauth_provider_app(),
|
||||
):
|
||||
response = test_client_with_containers.post(
|
||||
"/console/api/oauth/provider/account",
|
||||
json={"client_id": "test_client_id"},
|
||||
headers={"Authorization": "InvalidFormat"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"error": "Invalid Authorization header format"}
|
||||
@ -1,17 +1,10 @@
|
||||
"""
|
||||
Test suite for password reset authentication flows.
|
||||
"""Testcontainers integration tests for password reset authentication flows."""
|
||||
|
||||
This module tests the password reset mechanism including:
|
||||
- Password reset email sending
|
||||
- Verification code validation
|
||||
- Password reset with token
|
||||
- Rate limiting and security checks
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.auth.error import (
|
||||
EmailCodeError,
|
||||
@ -28,31 +21,12 @@ from controllers.console.auth.forgot_password import (
|
||||
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_forgot_password_session():
|
||||
with patch("controllers.console.auth.forgot_password.Session") as mock_session_cls:
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value.__enter__.return_value = mock_session
|
||||
mock_session_cls.return_value.__exit__.return_value = None
|
||||
yield mock_session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_forgot_password_db():
|
||||
with patch("controllers.console.auth.forgot_password.db") as mock_db:
|
||||
mock_db.engine = MagicMock()
|
||||
yield mock_db
|
||||
|
||||
|
||||
class TestForgotPasswordSendEmailApi:
|
||||
"""Test cases for sending password reset emails."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
"""Create Flask test application."""
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
def app(self, flask_app_with_containers):
|
||||
return flask_app_with_containers
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account(self):
|
||||
@ -62,7 +36,6 @@ class TestForgotPasswordSendEmailApi:
|
||||
account.name = "Test User"
|
||||
return account
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email")
|
||||
@ -73,20 +46,10 @@ class TestForgotPasswordSendEmailApi:
|
||||
mock_send_email,
|
||||
mock_get_account,
|
||||
mock_is_ip_limit,
|
||||
mock_wraps_db,
|
||||
app,
|
||||
mock_account,
|
||||
):
|
||||
"""
|
||||
Test successful password reset email sending.
|
||||
|
||||
Verifies that:
|
||||
- Email is sent to valid account
|
||||
- Reset token is generated and returned
|
||||
- IP rate limiting is checked
|
||||
"""
|
||||
# Arrange
|
||||
mock_wraps_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_is_ip_limit.return_value = False
|
||||
mock_get_account.return_value = mock_account
|
||||
mock_send_email.return_value = "reset_token_123"
|
||||
@ -104,9 +67,8 @@ class TestForgotPasswordSendEmailApi:
|
||||
assert response["data"] == "reset_token_123"
|
||||
mock_send_email.assert_called_once()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit")
|
||||
def test_send_reset_email_ip_rate_limited(self, mock_is_ip_limit, mock_db, app):
|
||||
def test_send_reset_email_ip_rate_limited(self, mock_is_ip_limit, app):
|
||||
"""
|
||||
Test password reset email blocked by IP rate limit.
|
||||
|
||||
@ -115,7 +77,6 @@ class TestForgotPasswordSendEmailApi:
|
||||
- No email is sent when rate limited
|
||||
"""
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_is_ip_limit.return_value = True
|
||||
|
||||
# Act & Assert
|
||||
@ -133,7 +94,6 @@ class TestForgotPasswordSendEmailApi:
|
||||
(None, "en-US"), # Defaults to en-US when not provided
|
||||
],
|
||||
)
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email")
|
||||
@ -144,7 +104,6 @@ class TestForgotPasswordSendEmailApi:
|
||||
mock_send_email,
|
||||
mock_get_account,
|
||||
mock_is_ip_limit,
|
||||
mock_wraps_db,
|
||||
app,
|
||||
mock_account,
|
||||
language_input,
|
||||
@ -158,7 +117,6 @@ class TestForgotPasswordSendEmailApi:
|
||||
- Unsupported languages default to en-US
|
||||
"""
|
||||
# Arrange
|
||||
mock_wraps_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_is_ip_limit.return_value = False
|
||||
mock_get_account.return_value = mock_account
|
||||
mock_send_email.return_value = "token"
|
||||
@ -180,13 +138,9 @@ class TestForgotPasswordCheckApi:
|
||||
"""Test cases for verifying password reset codes."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
"""Create Flask test application."""
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
def app(self, flask_app_with_containers):
|
||||
return flask_app_with_containers
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
@ -199,7 +153,6 @@ class TestForgotPasswordCheckApi:
|
||||
mock_revoke_token,
|
||||
mock_get_data,
|
||||
mock_is_rate_limit,
|
||||
mock_db,
|
||||
app,
|
||||
):
|
||||
"""
|
||||
@ -212,7 +165,6 @@ class TestForgotPasswordCheckApi:
|
||||
- Rate limit is reset on success
|
||||
"""
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
|
||||
mock_generate_token.return_value = (None, "new_token")
|
||||
@ -236,7 +188,6 @@ class TestForgotPasswordCheckApi:
|
||||
)
|
||||
mock_reset_rate_limit.assert_called_once_with("test@example.com")
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
@ -249,10 +200,8 @@ class TestForgotPasswordCheckApi:
|
||||
mock_revoke_token,
|
||||
mock_get_data,
|
||||
mock_is_rate_limit,
|
||||
mock_db,
|
||||
app,
|
||||
):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {"email": "User@Example.com", "code": "999888"}
|
||||
mock_generate_token.return_value = (None, "fresh-token")
|
||||
@ -271,9 +220,8 @@ class TestForgotPasswordCheckApi:
|
||||
mock_revoke_token.assert_called_once_with("upper_token")
|
||||
mock_reset_rate_limit.assert_called_once_with("user@example.com")
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
def test_verify_code_rate_limited(self, mock_is_rate_limit, mock_db, app):
|
||||
def test_verify_code_rate_limited(self, mock_is_rate_limit, app):
|
||||
"""
|
||||
Test code verification blocked by rate limit.
|
||||
|
||||
@ -282,7 +230,6 @@ class TestForgotPasswordCheckApi:
|
||||
- Prevents brute force attacks on verification codes
|
||||
"""
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_is_rate_limit.return_value = True
|
||||
|
||||
# Act & Assert
|
||||
@ -295,10 +242,9 @@ class TestForgotPasswordCheckApi:
|
||||
with pytest.raises(EmailPasswordResetLimitError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_verify_code_invalid_token(self, mock_get_data, mock_is_rate_limit, mock_db, app):
|
||||
def test_verify_code_invalid_token(self, mock_get_data, mock_is_rate_limit, app):
|
||||
"""
|
||||
Test code verification with invalid token.
|
||||
|
||||
@ -306,7 +252,6 @@ class TestForgotPasswordCheckApi:
|
||||
- InvalidTokenError is raised for invalid/expired tokens
|
||||
"""
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = None
|
||||
|
||||
@ -320,10 +265,9 @@ class TestForgotPasswordCheckApi:
|
||||
with pytest.raises(InvalidTokenError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_verify_code_email_mismatch(self, mock_get_data, mock_is_rate_limit, mock_db, app):
|
||||
def test_verify_code_email_mismatch(self, mock_get_data, mock_is_rate_limit, app):
|
||||
"""
|
||||
Test code verification with mismatched email.
|
||||
|
||||
@ -332,7 +276,6 @@ class TestForgotPasswordCheckApi:
|
||||
- Prevents token abuse
|
||||
"""
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {"email": "original@example.com", "code": "123456"}
|
||||
|
||||
@ -346,11 +289,10 @@ class TestForgotPasswordCheckApi:
|
||||
with pytest.raises(InvalidEmailError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.add_forgot_password_error_rate_limit")
|
||||
def test_verify_code_wrong_code(self, mock_add_rate_limit, mock_get_data, mock_is_rate_limit, mock_db, app):
|
||||
def test_verify_code_wrong_code(self, mock_add_rate_limit, mock_get_data, mock_is_rate_limit, app):
|
||||
"""
|
||||
Test code verification with incorrect code.
|
||||
|
||||
@ -359,7 +301,6 @@ class TestForgotPasswordCheckApi:
|
||||
- Rate limit counter is incremented
|
||||
"""
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
|
||||
|
||||
@ -380,11 +321,8 @@ class TestForgotPasswordResetApi:
|
||||
"""Test cases for resetting password with verified token."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
"""Create Flask test application."""
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
def app(self, flask_app_with_containers):
|
||||
return flask_app_with_containers
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account(self):
|
||||
@ -394,7 +332,6 @@ class TestForgotPasswordResetApi:
|
||||
account.name = "Test User"
|
||||
return account
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
|
||||
@ -405,7 +342,6 @@ class TestForgotPasswordResetApi:
|
||||
mock_get_account,
|
||||
mock_revoke_token,
|
||||
mock_get_data,
|
||||
mock_wraps_db,
|
||||
app,
|
||||
mock_account,
|
||||
):
|
||||
@ -418,7 +354,6 @@ class TestForgotPasswordResetApi:
|
||||
- Success response is returned
|
||||
"""
|
||||
# Arrange
|
||||
mock_wraps_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_data.return_value = {"email": "test@example.com", "phase": "reset"}
|
||||
mock_get_account.return_value = mock_account
|
||||
mock_get_tenants.return_value = [MagicMock()]
|
||||
@ -436,9 +371,8 @@ class TestForgotPasswordResetApi:
|
||||
assert response["result"] == "success"
|
||||
mock_revoke_token.assert_called_once_with("valid_token")
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_reset_password_mismatch(self, mock_get_data, mock_db, app):
|
||||
def test_reset_password_mismatch(self, mock_get_data, app):
|
||||
"""
|
||||
Test password reset with mismatched passwords.
|
||||
|
||||
@ -447,7 +381,6 @@ class TestForgotPasswordResetApi:
|
||||
- No password update occurs
|
||||
"""
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_data.return_value = {"email": "test@example.com", "phase": "reset"}
|
||||
|
||||
# Act & Assert
|
||||
@ -460,9 +393,8 @@ class TestForgotPasswordResetApi:
|
||||
with pytest.raises(PasswordMismatchError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_reset_password_invalid_token(self, mock_get_data, mock_db, app):
|
||||
def test_reset_password_invalid_token(self, mock_get_data, app):
|
||||
"""
|
||||
Test password reset with invalid token.
|
||||
|
||||
@ -470,7 +402,6 @@ class TestForgotPasswordResetApi:
|
||||
- InvalidTokenError is raised for invalid/expired tokens
|
||||
"""
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_data.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
@ -483,9 +414,8 @@ class TestForgotPasswordResetApi:
|
||||
with pytest.raises(InvalidTokenError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_reset_password_wrong_phase(self, mock_get_data, mock_db, app):
|
||||
def test_reset_password_wrong_phase(self, mock_get_data, app):
|
||||
"""
|
||||
Test password reset with token not in reset phase.
|
||||
|
||||
@ -494,7 +424,6 @@ class TestForgotPasswordResetApi:
|
||||
- Prevents use of verification-phase tokens for reset
|
||||
"""
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_data.return_value = {"email": "test@example.com", "phase": "verify"}
|
||||
|
||||
# Act & Assert
|
||||
@ -507,13 +436,10 @@ class TestForgotPasswordResetApi:
|
||||
with pytest.raises(InvalidTokenError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
|
||||
def test_reset_password_account_not_found(
|
||||
self, mock_get_account, mock_revoke_token, mock_get_data, mock_wraps_db, app
|
||||
):
|
||||
def test_reset_password_account_not_found(self, mock_get_account, mock_revoke_token, mock_get_data, app):
|
||||
"""
|
||||
Test password reset for non-existent account.
|
||||
|
||||
@ -521,7 +447,6 @@ class TestForgotPasswordResetApi:
|
||||
- AccountNotFound is raised when account doesn't exist
|
||||
"""
|
||||
# Arrange
|
||||
mock_wraps_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_data.return_value = {"email": "nonexistent@example.com", "phase": "reset"}
|
||||
mock_get_account.return_value = None
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
"""Shared helpers for authenticated console controller integration tests."""
|
||||
|
||||
import uuid
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from constants import HEADER_NAME_CSRF_TOKEN
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.token import _real_cookie_name, generate_csrf_token
|
||||
from models import Account, DifySetup, Tenant, TenantAccountJoin
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from models.model import App, AppMode
|
||||
from services.account_service import AccountService
|
||||
|
||||
|
||||
def ensure_dify_setup(db_session: Session) -> None:
|
||||
"""Create a setup marker once so setup-protected console routes can be exercised."""
|
||||
if db_session.scalar(select(DifySetup).limit(1)) is not None:
|
||||
return
|
||||
|
||||
db_session.add(DifySetup(version=dify_config.project.version))
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def create_console_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]:
|
||||
"""Create an initialized owner account with a current tenant."""
|
||||
account = Account(
|
||||
email=f"test-{uuid.uuid4()}@example.com",
|
||||
name="Test User",
|
||||
interface_language="en-US",
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
account.initialized_at = naive_utc_now()
|
||||
db_session.add(account)
|
||||
db_session.commit()
|
||||
|
||||
tenant = Tenant(name="Test Tenant", status="normal")
|
||||
db_session.add(tenant)
|
||||
db_session.commit()
|
||||
|
||||
db_session.add(
|
||||
TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
current=True,
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
account.set_tenant_id(tenant.id)
|
||||
account.timezone = "UTC"
|
||||
db_session.commit()
|
||||
|
||||
ensure_dify_setup(db_session)
|
||||
return account, tenant
|
||||
|
||||
|
||||
def create_console_app(db_session: Session, tenant_id: str, account_id: str, mode: AppMode) -> App:
|
||||
"""Create a minimal app row that can be loaded by get_app_model."""
|
||||
app = App(
|
||||
tenant_id=tenant_id,
|
||||
name="Test App",
|
||||
mode=mode,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
created_by=account_id,
|
||||
)
|
||||
db_session.add(app)
|
||||
db_session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def authenticate_console_client(test_client: FlaskClient, account: Account) -> dict[str, str]:
|
||||
"""Attach console auth cookies/headers for endpoints guarded by login_required."""
|
||||
access_token = AccountService.get_account_jwt_token(account)
|
||||
csrf_token = generate_csrf_token(account.id)
|
||||
test_client.set_cookie(_real_cookie_name("csrf_token"), csrf_token, domain="localhost")
|
||||
return {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
HEADER_NAME_CSRF_TOKEN: csrf_token,
|
||||
}
|
||||
@ -27,7 +27,7 @@ from models.human_input import (
|
||||
HumanInputFormRecipient,
|
||||
RecipientType,
|
||||
)
|
||||
from models.workflow import WorkflowAppLog, WorkflowPause, WorkflowPauseReason, WorkflowRun
|
||||
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowPause, WorkflowPauseReason, WorkflowRun
|
||||
from repositories.entities.workflow_pause import WorkflowPauseEntity
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import (
|
||||
DifyAPISQLAlchemyWorkflowRunRepository,
|
||||
@ -218,7 +218,7 @@ class TestDeleteRunsWithRelated:
|
||||
app_id=test_scope.app_id,
|
||||
workflow_id=test_scope.workflow_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=test_scope.user_id,
|
||||
)
|
||||
@ -278,7 +278,7 @@ class TestCountRunsWithRelated:
|
||||
app_id=test_scope.app_id,
|
||||
workflow_id=test_scope.workflow_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=test_scope.user_id,
|
||||
)
|
||||
|
||||
@ -525,3 +525,147 @@ class TestAPIBasedExtensionService:
|
||||
# Try to get extension with wrong tenant ID
|
||||
with pytest.raises(ValueError, match="API based extension is not found"):
|
||||
APIBasedExtensionService.get_with_tenant_id(tenant2.id, created_extension.id)
|
||||
|
||||
def test_save_extension_api_key_exactly_four_chars_rejected(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""API key with exactly 4 characters should be rejected (boundary)."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
assert tenant is not None
|
||||
|
||||
extension_data = APIBasedExtension(
|
||||
tenant_id=tenant.id,
|
||||
name=fake.company(),
|
||||
api_endpoint=f"https://{fake.domain_name()}/api",
|
||||
api_key="1234",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="api_key must be at least 5 characters"):
|
||||
APIBasedExtensionService.save(extension_data)
|
||||
|
||||
def test_save_extension_api_key_exactly_five_chars_accepted(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""API key with exactly 5 characters should be accepted (boundary)."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
assert tenant is not None
|
||||
|
||||
extension_data = APIBasedExtension(
|
||||
tenant_id=tenant.id,
|
||||
name=fake.company(),
|
||||
api_endpoint=f"https://{fake.domain_name()}/api",
|
||||
api_key="12345",
|
||||
)
|
||||
|
||||
saved = APIBasedExtensionService.save(extension_data)
|
||||
assert saved.id is not None
|
||||
|
||||
def test_save_extension_requestor_constructor_error(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Exception raised by requestor constructor is wrapped in ValueError."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
assert tenant is not None
|
||||
|
||||
mock_external_service_dependencies["requestor"].side_effect = RuntimeError("bad config")
|
||||
|
||||
extension_data = APIBasedExtension(
|
||||
tenant_id=tenant.id,
|
||||
name=fake.company(),
|
||||
api_endpoint=f"https://{fake.domain_name()}/api",
|
||||
api_key=fake.password(length=20),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="connection error: bad config"):
|
||||
APIBasedExtensionService.save(extension_data)
|
||||
|
||||
def test_save_extension_network_exception(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Network exceptions during ping are wrapped in ValueError."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
assert tenant is not None
|
||||
|
||||
mock_external_service_dependencies["requestor_instance"].request.side_effect = ConnectionError(
|
||||
"network failure"
|
||||
)
|
||||
|
||||
extension_data = APIBasedExtension(
|
||||
tenant_id=tenant.id,
|
||||
name=fake.company(),
|
||||
api_endpoint=f"https://{fake.domain_name()}/api",
|
||||
api_key=fake.password(length=20),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="connection error: network failure"):
|
||||
APIBasedExtensionService.save(extension_data)
|
||||
|
||||
def test_save_extension_update_duplicate_name_rejected(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Updating an existing extension to use another extension's name should fail."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
assert tenant is not None
|
||||
|
||||
ext1 = APIBasedExtensionService.save(
|
||||
APIBasedExtension(
|
||||
tenant_id=tenant.id,
|
||||
name="Extension Alpha",
|
||||
api_endpoint=f"https://{fake.domain_name()}/api",
|
||||
api_key=fake.password(length=20),
|
||||
)
|
||||
)
|
||||
ext2 = APIBasedExtensionService.save(
|
||||
APIBasedExtension(
|
||||
tenant_id=tenant.id,
|
||||
name="Extension Beta",
|
||||
api_endpoint=f"https://{fake.domain_name()}/api",
|
||||
api_key=fake.password(length=20),
|
||||
)
|
||||
)
|
||||
|
||||
# Try to rename ext2 to ext1's name
|
||||
ext2.name = "Extension Alpha"
|
||||
with pytest.raises(ValueError, match="name must be unique, it is already existed"):
|
||||
APIBasedExtensionService.save(ext2)
|
||||
|
||||
def test_get_all_returns_empty_for_different_tenant(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Extensions from one tenant should not be visible to another."""
|
||||
fake = Faker()
|
||||
_, tenant1 = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
_, tenant2 = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
assert tenant1 is not None
|
||||
|
||||
APIBasedExtensionService.save(
|
||||
APIBasedExtension(
|
||||
tenant_id=tenant1.id,
|
||||
name=fake.company(),
|
||||
api_endpoint=f"https://{fake.domain_name()}/api",
|
||||
api_key=fake.password(length=20),
|
||||
)
|
||||
)
|
||||
|
||||
assert tenant2 is not None
|
||||
result = APIBasedExtensionService.get_all_by_tenant_id(tenant2.id)
|
||||
assert result == []
|
||||
|
||||
@ -6,6 +6,7 @@ import pytest
|
||||
|
||||
from core.errors.error import QuotaExceededError
|
||||
from models import TenantCreditPool
|
||||
from models.enums import ProviderQuotaType
|
||||
from services.credit_pool_service import CreditPoolService
|
||||
|
||||
|
||||
@ -20,7 +21,7 @@ class TestCreditPoolService:
|
||||
|
||||
assert isinstance(pool, TenantCreditPool)
|
||||
assert pool.tenant_id == tenant_id
|
||||
assert pool.pool_type == "trial"
|
||||
assert pool.pool_type == ProviderQuotaType.TRIAL
|
||||
assert pool.quota_used == 0
|
||||
assert pool.quota_limit > 0
|
||||
|
||||
@ -28,14 +29,14 @@ class TestCreditPoolService:
|
||||
tenant_id = self._create_tenant_id()
|
||||
CreditPoolService.create_default_pool(tenant_id)
|
||||
|
||||
result = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="trial")
|
||||
result = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type=ProviderQuotaType.TRIAL)
|
||||
|
||||
assert result is not None
|
||||
assert result.tenant_id == tenant_id
|
||||
assert result.pool_type == "trial"
|
||||
assert result.pool_type == ProviderQuotaType.TRIAL
|
||||
|
||||
def test_get_pool_returns_none_when_not_exists(self, db_session_with_containers):
|
||||
result = CreditPoolService.get_pool(tenant_id=self._create_tenant_id(), pool_type="trial")
|
||||
result = CreditPoolService.get_pool(tenant_id=self._create_tenant_id(), pool_type=ProviderQuotaType.TRIAL)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@ -694,3 +694,19 @@ class TestDatasetServiceBatchUpdateDocumentStatus:
|
||||
|
||||
patched_dependencies["redis_client"].setex.assert_called_once_with(f"document_{doc1.id}_indexing", 600, 1)
|
||||
patched_dependencies["add_task"].delay.assert_called_once_with(doc1.id)
|
||||
|
||||
def test_batch_update_invalid_action_raises_value_error(
|
||||
self, db_session_with_containers: Session, patched_dependencies
|
||||
):
|
||||
"""Test that an invalid action raises ValueError."""
|
||||
factory = DocumentBatchUpdateIntegrationDataFactory
|
||||
dataset = factory.create_dataset(db_session_with_containers)
|
||||
doc = factory.create_document(db_session_with_containers, dataset)
|
||||
user = UserDouble(id=str(uuid4()))
|
||||
|
||||
patched_dependencies["redis_client"].get.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid action"):
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=[doc.id], action="invalid_action", user=user
|
||||
)
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
"""Testcontainers integration tests for DatasetService.create_empty_rag_pipeline_dataset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from models.account import Account, Tenant, TenantAccountJoin
|
||||
from services.dataset_service import DatasetService
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, RagPipelineDatasetCreateEntity
|
||||
|
||||
|
||||
class TestDatasetServiceCreateRagPipelineDataset:
|
||||
def _create_tenant_and_account(self, db_session_with_containers) -> tuple[Tenant, Account]:
|
||||
tenant = Tenant(name=f"Tenant {uuid4()}")
|
||||
db_session_with_containers.add(tenant)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
account = Account(
|
||||
name=f"Account {uuid4()}",
|
||||
email=f"ds_create_{uuid4()}@example.com",
|
||||
password="hashed",
|
||||
password_salt="salt",
|
||||
interface_language="en-US",
|
||||
timezone="UTC",
|
||||
)
|
||||
db_session_with_containers.add(account)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
role="owner",
|
||||
current=True,
|
||||
)
|
||||
db_session_with_containers.add(join)
|
||||
db_session_with_containers.commit()
|
||||
return tenant, account
|
||||
|
||||
def _build_entity(self, name: str = "Test Dataset") -> RagPipelineDatasetCreateEntity:
|
||||
icon_info = IconInfo(icon="\U0001f4d9", icon_background="#FFF4ED", icon_type="emoji")
|
||||
return RagPipelineDatasetCreateEntity(
|
||||
name=name,
|
||||
description="",
|
||||
icon_info=icon_info,
|
||||
permission="only_me",
|
||||
)
|
||||
|
||||
def test_create_rag_pipeline_dataset_raises_when_current_user_id_is_none(self, db_session_with_containers):
|
||||
tenant, _ = self._create_tenant_and_account(db_session_with_containers)
|
||||
|
||||
mock_user = Mock(id=None)
|
||||
with patch("services.dataset_service.current_user", mock_user):
|
||||
with pytest.raises(ValueError, match="Current user or current user id not found"):
|
||||
DatasetService.create_empty_rag_pipeline_dataset(
|
||||
tenant_id=tenant.id,
|
||||
rag_pipeline_dataset_create_entity=self._build_entity(),
|
||||
)
|
||||
@ -142,3 +142,11 @@ def test_apply_display_status_filter_returns_same_when_invalid(db_session_with_c
|
||||
|
||||
rows = db_session_with_containers.scalars(filtered).all()
|
||||
assert {row.id for row in rows} == {doc1.id, doc2.id}
|
||||
|
||||
|
||||
def test_normalize_display_status_alias_mapping():
|
||||
"""Test that normalize_display_status maps aliases correctly."""
|
||||
assert DocumentService.normalize_display_status("ACTIVE") == "available"
|
||||
assert DocumentService.normalize_display_status("enabled") == "available"
|
||||
assert DocumentService.normalize_display_status("archived") == "archived"
|
||||
assert DocumentService.normalize_display_status("unknown") is None
|
||||
|
||||
@ -414,3 +414,144 @@ class TestEndUserServiceGetEndUserById:
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestEndUserServiceCreateBatch:
|
||||
"""Integration tests for EndUserService.create_end_user_batch."""
|
||||
|
||||
@pytest.fixture
|
||||
def factory(self):
|
||||
return TestEndUserServiceFactory()
|
||||
|
||||
def _create_multiple_apps(self, db_session_with_containers, factory, count: int = 3):
|
||||
"""Create multiple apps under the same tenant."""
|
||||
first_app = factory.create_app_and_account(db_session_with_containers)
|
||||
tenant_id = first_app.tenant_id
|
||||
apps = [first_app]
|
||||
for _ in range(count - 1):
|
||||
app = App(
|
||||
tenant_id=tenant_id,
|
||||
name=f"App {uuid4()}",
|
||||
description="",
|
||||
mode="chat",
|
||||
icon_type="emoji",
|
||||
icon="bot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
api_rpm=100,
|
||||
api_rph=100,
|
||||
is_demo=False,
|
||||
is_public=False,
|
||||
is_universal=False,
|
||||
created_by=first_app.created_by,
|
||||
updated_by=first_app.updated_by,
|
||||
)
|
||||
db_session_with_containers.add(app)
|
||||
db_session_with_containers.commit()
|
||||
all_apps = db_session_with_containers.query(App).filter(App.tenant_id == tenant_id).all()
|
||||
return tenant_id, all_apps
|
||||
|
||||
def test_create_batch_empty_app_ids(self, db_session_with_containers):
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API, tenant_id=str(uuid4()), app_ids=[], user_id="user-1"
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
def test_create_batch_creates_users_for_all_apps(self, db_session_with_containers, factory):
|
||||
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3)
|
||||
app_ids = [a.id for a in apps]
|
||||
user_id = f"user-{uuid4()}"
|
||||
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
assert len(result) == 3
|
||||
for app_id in app_ids:
|
||||
assert app_id in result
|
||||
assert result[app_id].session_id == user_id
|
||||
assert result[app_id].type == InvokeFrom.SERVICE_API
|
||||
|
||||
def test_create_batch_default_session_id(self, db_session_with_containers, factory):
|
||||
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2)
|
||||
app_ids = [a.id for a in apps]
|
||||
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=""
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
for end_user in result.values():
|
||||
assert end_user.session_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID
|
||||
assert end_user._is_anonymous is True
|
||||
|
||||
def test_create_batch_deduplicate_app_ids(self, db_session_with_containers, factory):
|
||||
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2)
|
||||
app_ids = [apps[0].id, apps[1].id, apps[0].id, apps[1].id]
|
||||
user_id = f"user-{uuid4()}"
|
||||
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
def test_create_batch_returns_existing_users(self, db_session_with_containers, factory):
|
||||
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2)
|
||||
app_ids = [a.id for a in apps]
|
||||
user_id = f"user-{uuid4()}"
|
||||
|
||||
# Create batch first time
|
||||
first_result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
# Create batch second time — should return existing users
|
||||
second_result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
assert len(second_result) == 2
|
||||
for app_id in app_ids:
|
||||
assert first_result[app_id].id == second_result[app_id].id
|
||||
|
||||
def test_create_batch_partial_existing_users(self, db_session_with_containers, factory):
|
||||
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3)
|
||||
user_id = f"user-{uuid4()}"
|
||||
|
||||
# Create for first 2 apps
|
||||
first_result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API,
|
||||
tenant_id=tenant_id,
|
||||
app_ids=[apps[0].id, apps[1].id],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Create for all 3 apps — should reuse first 2, create 3rd
|
||||
all_result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API,
|
||||
tenant_id=tenant_id,
|
||||
app_ids=[a.id for a in apps],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
assert len(all_result) == 3
|
||||
assert all_result[apps[0].id].id == first_result[apps[0].id].id
|
||||
assert all_result[apps[1].id].id == first_result[apps[1].id].id
|
||||
assert all_result[apps[2].id].session_id == user_id
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invoke_type",
|
||||
[InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP, InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER],
|
||||
)
|
||||
def test_create_batch_all_invoke_types(self, db_session_with_containers, invoke_type, factory):
|
||||
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=1)
|
||||
user_id = f"user-{uuid4()}"
|
||||
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=invoke_type, tenant_id=tenant_id, app_ids=[apps[0].id], user_id=user_id
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[apps[0].id].type == invoke_type
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
"""
|
||||
Testcontainers integration tests for FileService helpers.
|
||||
|
||||
Covers:
|
||||
- ZIP tempfile building (sanitization + deduplication + content writes)
|
||||
- tenant-scoped batch lookup behavior (get_upload_files_by_ids)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
|
||||
import services.file_service as file_service_module
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import UploadFile
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
def _create_upload_file(db_session, *, tenant_id: str, key: str, name: str) -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id,
|
||||
storage_type=StorageType.OPENDAL,
|
||||
key=key,
|
||||
name=name,
|
||||
size=100,
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid4()),
|
||||
created_at=datetime.now(UTC),
|
||||
used=False,
|
||||
)
|
||||
db_session.add(upload_file)
|
||||
db_session.commit()
|
||||
return upload_file
|
||||
|
||||
|
||||
def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure ZIP entry names are safe and unique while preserving extensions."""
|
||||
upload_files: list[Any] = [
|
||||
SimpleNamespace(name="a/b.txt", key="k1"),
|
||||
SimpleNamespace(name="c/b.txt", key="k2"),
|
||||
SimpleNamespace(name="../b.txt", key="k3"),
|
||||
]
|
||||
|
||||
data_by_key: dict[str, list[bytes]] = {"k1": [b"one"], "k2": [b"two"], "k3": [b"three"]}
|
||||
|
||||
def _load(key: str, stream: bool = True) -> list[bytes]:
|
||||
assert stream is True
|
||||
return data_by_key[key]
|
||||
|
||||
monkeypatch.setattr(file_service_module.storage, "load", _load)
|
||||
|
||||
with FileService.build_upload_files_zip_tempfile(upload_files=upload_files) as tmp:
|
||||
with ZipFile(tmp, mode="r") as zf:
|
||||
assert zf.namelist() == ["b.txt", "b (1).txt", "b (2).txt"]
|
||||
assert zf.read("b.txt") == b"one"
|
||||
assert zf.read("b (1).txt") == b"two"
|
||||
assert zf.read("b (2).txt") == b"three"
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_returns_empty_when_no_ids(db_session_with_containers) -> None:
|
||||
"""Ensure empty input returns an empty mapping without hitting the database."""
|
||||
assert FileService.get_upload_files_by_ids(str(uuid4()), []) == {}
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_returns_id_keyed_mapping(db_session_with_containers) -> None:
|
||||
"""Ensure batch lookup returns a dict keyed by stringified UploadFile ids."""
|
||||
tenant_id = str(uuid4())
|
||||
file1 = _create_upload_file(db_session_with_containers, tenant_id=tenant_id, key="k1", name="file1.txt")
|
||||
file2 = _create_upload_file(db_session_with_containers, tenant_id=tenant_id, key="k2", name="file2.txt")
|
||||
|
||||
result = FileService.get_upload_files_by_ids(tenant_id, [file1.id, file1.id, file2.id])
|
||||
|
||||
assert set(result.keys()) == {file1.id, file2.id}
|
||||
assert result[file1.id].id == file1.id
|
||||
assert result[file2.id].id == file2.id
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_filters_by_tenant(db_session_with_containers) -> None:
|
||||
"""Ensure files from other tenants are not returned."""
|
||||
tenant_a = str(uuid4())
|
||||
tenant_b = str(uuid4())
|
||||
file_a = _create_upload_file(db_session_with_containers, tenant_id=tenant_a, key="ka", name="a.txt")
|
||||
_create_upload_file(db_session_with_containers, tenant_id=tenant_b, key="kb", name="b.txt")
|
||||
|
||||
result = FileService.get_upload_files_by_ids(tenant_a, [file_a.id])
|
||||
|
||||
assert set(result.keys()) == {file_a.id}
|
||||
@ -8,6 +8,7 @@ import pytest
|
||||
from faker import Faker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dify_graph.file.enums import FileType
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
@ -253,7 +254,7 @@ class TestMessagesCleanServiceIntegration:
|
||||
# MessageFile
|
||||
file = MessageFile(
|
||||
message_id=message.id,
|
||||
type="image",
|
||||
type=FileType.IMAGE,
|
||||
transfer_method="local_file",
|
||||
url="http://example.com/test.jpg",
|
||||
belongs_to=MessageFileBelongsTo.USER,
|
||||
|
||||
@ -0,0 +1,174 @@
|
||||
"""Testcontainers integration tests for OAuthServerService."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from models.model import OAuthProviderApp
|
||||
from services.oauth_server import (
|
||||
OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
OAUTH_ACCESS_TOKEN_REDIS_KEY,
|
||||
OAUTH_AUTHORIZATION_CODE_REDIS_KEY,
|
||||
OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
OAUTH_REFRESH_TOKEN_REDIS_KEY,
|
||||
OAuthGrantType,
|
||||
OAuthServerService,
|
||||
)
|
||||
|
||||
|
||||
class TestOAuthServerServiceGetProviderApp:
|
||||
"""DB-backed tests for get_oauth_provider_app."""
|
||||
|
||||
def _create_oauth_provider_app(self, db_session_with_containers, *, client_id: str) -> OAuthProviderApp:
|
||||
app = OAuthProviderApp(
|
||||
app_icon="icon.png",
|
||||
client_id=client_id,
|
||||
client_secret=str(uuid4()),
|
||||
app_label={"en-US": "Test OAuth App"},
|
||||
redirect_uris=["https://example.com/callback"],
|
||||
scope="read",
|
||||
)
|
||||
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):
|
||||
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 == created.id
|
||||
|
||||
def test_get_oauth_provider_app_returns_none_when_not_exists(self, db_session_with_containers):
|
||||
result = OAuthServerService.get_oauth_provider_app(f"nonexistent-{uuid4()}")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestOAuthServerServiceTokenOperations:
|
||||
"""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):
|
||||
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")
|
||||
|
||||
assert code == str(deterministic_uuid)
|
||||
mock_redis.set.assert_called_once_with(
|
||||
OAUTH_AUTHORIZATION_CODE_REDIS_KEY.format(client_id="client-1", code=code),
|
||||
"user-1",
|
||||
ex=600,
|
||||
)
|
||||
|
||||
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"):
|
||||
OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.AUTHORIZATION_CODE,
|
||||
code="bad-code",
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
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",
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
assert access_token == str(token_uuids[0])
|
||||
assert refresh_token == str(token_uuids[1])
|
||||
code_key = OAUTH_AUTHORIZATION_CODE_REDIS_KEY.format(client_id="client-1", code="code-1")
|
||||
mock_redis.delete.assert_called_once_with(code_key)
|
||||
mock_redis.set.assert_any_call(
|
||||
OAUTH_ACCESS_TOKEN_REDIS_KEY.format(client_id="client-1", token=access_token),
|
||||
b"user-1",
|
||||
ex=OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
mock_redis.set.assert_any_call(
|
||||
OAUTH_REFRESH_TOKEN_REDIS_KEY.format(client_id="client-1", token=refresh_token),
|
||||
b"user-1",
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
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"):
|
||||
OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.REFRESH_TOKEN,
|
||||
refresh_token="stale-token",
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
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",
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
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):
|
||||
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):
|
||||
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")
|
||||
|
||||
assert refresh_token == str(deterministic_uuid)
|
||||
mock_redis.set.assert_called_once_with(
|
||||
OAUTH_REFRESH_TOKEN_REDIS_KEY.format(client_id="client-2", token=refresh_token),
|
||||
"user-2",
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
def test_validate_access_token_returns_none_when_not_found(self, mock_redis):
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_validate_access_token_loads_user_when_exists(self, mock_redis):
|
||||
mock_redis.get.return_value = b"user-88"
|
||||
expected_user = MagicMock()
|
||||
|
||||
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")
|
||||
|
||||
assert result is expected_user
|
||||
mock_load.assert_called_once_with("user-88")
|
||||
@ -396,11 +396,6 @@ class TestSavedMessageService:
|
||||
|
||||
assert "User is required" in str(exc_info.value)
|
||||
|
||||
# Verify no database operations were performed
|
||||
|
||||
saved_messages = db_session_with_containers.query(SavedMessage).all()
|
||||
assert len(saved_messages) == 0
|
||||
|
||||
def test_save_error_no_user(self, db_session_with_containers: Session, mock_external_service_dependencies):
|
||||
"""
|
||||
Test error handling when saving message with no user.
|
||||
@ -497,124 +492,140 @@ class TestSavedMessageService:
|
||||
# The message should still exist, only the saved_message should be deleted
|
||||
assert db_session_with_containers.query(Message).where(Message.id == message.id).first() is not None
|
||||
|
||||
def test_pagination_by_last_id_error_no_user(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test error handling when no user is provided.
|
||||
|
||||
This test verifies:
|
||||
- Proper error handling for missing user
|
||||
- ValueError is raised when user is None
|
||||
- No database operations are performed
|
||||
"""
|
||||
# Arrange: Create test data
|
||||
fake = Faker()
|
||||
def test_save_for_end_user(self, db_session_with_containers: Session, mock_external_service_dependencies):
|
||||
"""Test saving a message for an EndUser."""
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
end_user = self._create_test_end_user(db_session_with_containers, app)
|
||||
message = self._create_test_message(db_session_with_containers, app, end_user)
|
||||
|
||||
# Act & Assert: Verify proper error handling
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
SavedMessageService.pagination_by_last_id(app_model=app, user=None, last_id=None, limit=10)
|
||||
mock_external_service_dependencies["message_service"].get_message.return_value = message
|
||||
|
||||
assert "User is required" in str(exc_info.value)
|
||||
SavedMessageService.save(app_model=app, user=end_user, message_id=message.id)
|
||||
|
||||
# Verify no database operations were performed for this specific test
|
||||
# Note: We don't check total count as other tests may have created data
|
||||
# Instead, we verify that the error was properly raised
|
||||
pass
|
||||
|
||||
def test_save_error_no_user(self, db_session_with_containers: Session, mock_external_service_dependencies):
|
||||
"""
|
||||
Test error handling when saving message with no user.
|
||||
|
||||
This test verifies:
|
||||
- Method returns early when user is None
|
||||
- No database operations are performed
|
||||
- No exceptions are raised
|
||||
"""
|
||||
# Arrange: Create test data
|
||||
fake = Faker()
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
message = self._create_test_message(db_session_with_containers, app, account)
|
||||
|
||||
# Act: Execute the method under test with None user
|
||||
result = SavedMessageService.save(app_model=app, user=None, message_id=message.id)
|
||||
|
||||
# Assert: Verify the expected outcomes
|
||||
assert result is None
|
||||
|
||||
# Verify no saved message was created
|
||||
|
||||
saved_message = (
|
||||
saved = (
|
||||
db_session_with_containers.query(SavedMessage)
|
||||
.where(
|
||||
SavedMessage.app_id == app.id,
|
||||
SavedMessage.message_id == message.id,
|
||||
)
|
||||
.where(SavedMessage.app_id == app.id, SavedMessage.message_id == message.id)
|
||||
.first()
|
||||
)
|
||||
assert saved is not None
|
||||
assert saved.created_by == end_user.id
|
||||
assert saved.created_by_role == "end_user"
|
||||
|
||||
assert saved_message is None
|
||||
|
||||
def test_delete_success_existing_message(
|
||||
def test_save_duplicate_is_idempotent(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test successful deletion of an existing saved message.
|
||||
|
||||
This test verifies:
|
||||
- Proper deletion of existing saved message
|
||||
- Correct database state after deletion
|
||||
- No errors during deletion process
|
||||
"""
|
||||
# Arrange: Create test data
|
||||
fake = Faker()
|
||||
"""Test that saving an already-saved message does not create a duplicate."""
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
message = self._create_test_message(db_session_with_containers, app, account)
|
||||
|
||||
# Create a saved message first
|
||||
saved_message = SavedMessage(
|
||||
app_id=app.id,
|
||||
message_id=message.id,
|
||||
created_by_role="account",
|
||||
created_by=account.id,
|
||||
)
|
||||
mock_external_service_dependencies["message_service"].get_message.return_value = message
|
||||
|
||||
db_session_with_containers.add(saved_message)
|
||||
# Save once
|
||||
SavedMessageService.save(app_model=app, user=account, message_id=message.id)
|
||||
# Save again
|
||||
SavedMessageService.save(app_model=app, user=account, message_id=message.id)
|
||||
|
||||
count = (
|
||||
db_session_with_containers.query(SavedMessage)
|
||||
.where(SavedMessage.app_id == app.id, SavedMessage.message_id == message.id)
|
||||
.count()
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
def test_delete_without_user_does_nothing(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test that deleting without a user is a no-op."""
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
message = self._create_test_message(db_session_with_containers, app, account)
|
||||
|
||||
# Pre-create a saved message
|
||||
saved = SavedMessage(app_id=app.id, message_id=message.id, created_by_role="account", created_by=account.id)
|
||||
db_session_with_containers.add(saved)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
# Verify saved message exists
|
||||
SavedMessageService.delete(app_model=app, user=None, message_id=message.id)
|
||||
|
||||
# Should still exist
|
||||
assert (
|
||||
db_session_with_containers.query(SavedMessage)
|
||||
.where(SavedMessage.app_id == app.id, SavedMessage.message_id == message.id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def test_delete_non_existent_does_nothing(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test that deleting a non-existent saved message is a no-op."""
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
|
||||
# Should not raise — use a valid UUID that doesn't exist in DB
|
||||
from uuid import uuid4
|
||||
|
||||
SavedMessageService.delete(app_model=app, user=account, message_id=str(uuid4()))
|
||||
|
||||
def test_delete_for_end_user(self, db_session_with_containers: Session, mock_external_service_dependencies):
|
||||
"""Test deleting a saved message for an EndUser."""
|
||||
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
|
||||
end_user = self._create_test_end_user(db_session_with_containers, app)
|
||||
message = self._create_test_message(db_session_with_containers, app, end_user)
|
||||
|
||||
saved = SavedMessage(app_id=app.id, message_id=message.id, created_by_role="end_user", created_by=end_user.id)
|
||||
db_session_with_containers.add(saved)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
SavedMessageService.delete(app_model=app, user=end_user, message_id=message.id)
|
||||
|
||||
assert (
|
||||
db_session_with_containers.query(SavedMessage)
|
||||
.where(SavedMessage.app_id == app.id, SavedMessage.message_id == message.id)
|
||||
.first()
|
||||
is None
|
||||
)
|
||||
|
||||
def test_delete_only_affects_own_saved_messages(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test that delete only removes the requesting user's saved message."""
|
||||
app, account1 = self._create_test_app_and_account(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
end_user = self._create_test_end_user(db_session_with_containers, app)
|
||||
message = self._create_test_message(db_session_with_containers, app, account1)
|
||||
|
||||
# Both users save the same message
|
||||
saved_account = SavedMessage(
|
||||
app_id=app.id, message_id=message.id, created_by_role="account", created_by=account1.id
|
||||
)
|
||||
saved_end_user = SavedMessage(
|
||||
app_id=app.id, message_id=message.id, created_by_role="end_user", created_by=end_user.id
|
||||
)
|
||||
db_session_with_containers.add_all([saved_account, saved_end_user])
|
||||
db_session_with_containers.commit()
|
||||
|
||||
# Delete only account1's saved message
|
||||
SavedMessageService.delete(app_model=app, user=account1, message_id=message.id)
|
||||
|
||||
# Account's saved message should be gone
|
||||
assert (
|
||||
db_session_with_containers.query(SavedMessage)
|
||||
.where(
|
||||
SavedMessage.app_id == app.id,
|
||||
SavedMessage.message_id == message.id,
|
||||
SavedMessage.created_by_role == "account",
|
||||
SavedMessage.created_by == account.id,
|
||||
SavedMessage.created_by == account1.id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
is None
|
||||
)
|
||||
|
||||
# Act: Execute the method under test
|
||||
SavedMessageService.delete(app_model=app, user=account, message_id=message.id)
|
||||
|
||||
# Assert: Verify the expected outcomes
|
||||
# Check if saved message was deleted from database
|
||||
deleted_saved_message = (
|
||||
# End user's saved message should still exist
|
||||
assert (
|
||||
db_session_with_containers.query(SavedMessage)
|
||||
.where(
|
||||
SavedMessage.app_id == app.id,
|
||||
SavedMessage.message_id == message.id,
|
||||
SavedMessage.created_by_role == "account",
|
||||
SavedMessage.created_by == account.id,
|
||||
SavedMessage.created_by == end_user.id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
assert deleted_saved_message is None
|
||||
|
||||
# Verify database state
|
||||
db_session_with_containers.commit()
|
||||
# The message should still exist, only the saved_message should be deleted
|
||||
assert db_session_with_containers.query(Message).where(Message.id == message.id).first() is not None
|
||||
|
||||
@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
from dify_graph.entities.workflow_execution import WorkflowExecutionStatus
|
||||
from models import EndUser, Workflow, WorkflowAppLog, WorkflowRun
|
||||
from models.enums import CreatorUserRole
|
||||
from models.workflow import WorkflowAppLogCreatedFrom
|
||||
from services.account_service import AccountService, TenantService
|
||||
|
||||
# Delay import of AppService to avoid circular dependency
|
||||
@ -221,7 +222,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -357,7 +358,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run_1.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -399,7 +400,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run_2.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -441,7 +442,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run_4.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -521,7 +522,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -627,7 +628,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -732,7 +733,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -860,7 +861,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -902,7 +903,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="web-app",
|
||||
created_from=WorkflowAppLogCreatedFrom.WEB_APP,
|
||||
created_by_role=CreatorUserRole.END_USER,
|
||||
created_by=end_user.id,
|
||||
)
|
||||
@ -1037,7 +1038,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -1125,7 +1126,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -1279,7 +1280,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -1379,7 +1380,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
@ -1481,7 +1482,7 @@ class TestWorkflowAppService:
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from="service-api",
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
)
|
||||
|
||||
@ -536,3 +536,151 @@ class TestApiToolManageService:
|
||||
# Verify mock interactions
|
||||
mock_external_service_dependencies["encrypter"].assert_called_once()
|
||||
mock_external_service_dependencies["provider_controller"].from_db.assert_called_once()
|
||||
|
||||
def test_delete_api_tool_provider_success(
|
||||
self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test successful deletion of an API tool provider."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
schema = self._create_test_openapi_schema()
|
||||
provider_name = fake.unique.word()
|
||||
|
||||
ApiToolManageService.create_api_tool_provider(
|
||||
user_id=account.id,
|
||||
tenant_id=tenant.id,
|
||||
provider_name=provider_name,
|
||||
icon={"content": "🔧", "background": "#FFF"},
|
||||
credentials={"auth_type": "none"},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema=schema,
|
||||
privacy_policy="",
|
||||
custom_disclaimer="",
|
||||
labels=[],
|
||||
)
|
||||
|
||||
provider = (
|
||||
db_session_with_containers.query(ApiToolProvider)
|
||||
.filter(ApiToolProvider.tenant_id == tenant.id, ApiToolProvider.name == provider_name)
|
||||
.first()
|
||||
)
|
||||
assert provider is not None
|
||||
|
||||
result = ApiToolManageService.delete_api_tool_provider(account.id, tenant.id, provider_name)
|
||||
|
||||
assert result == {"result": "success"}
|
||||
deleted = (
|
||||
db_session_with_containers.query(ApiToolProvider)
|
||||
.filter(ApiToolProvider.tenant_id == tenant.id, ApiToolProvider.name == provider_name)
|
||||
.first()
|
||||
)
|
||||
assert deleted is None
|
||||
|
||||
def test_delete_api_tool_provider_not_found(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test deletion raises ValueError when provider not found."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="you have not added provider"):
|
||||
ApiToolManageService.delete_api_tool_provider(account.id, tenant.id, "nonexistent")
|
||||
|
||||
def test_update_api_tool_provider_not_found(
|
||||
self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test update raises ValueError when original provider not found."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not exists"):
|
||||
ApiToolManageService.update_api_tool_provider(
|
||||
user_id=account.id,
|
||||
tenant_id=tenant.id,
|
||||
provider_name="new-name",
|
||||
original_provider="nonexistent",
|
||||
icon={},
|
||||
credentials={"auth_type": "none"},
|
||||
_schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema=self._create_test_openapi_schema(),
|
||||
privacy_policy=None,
|
||||
custom_disclaimer="",
|
||||
labels=[],
|
||||
)
|
||||
|
||||
def test_update_api_tool_provider_missing_auth_type(
|
||||
self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test update raises ValueError when auth_type is missing from credentials."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
schema = self._create_test_openapi_schema()
|
||||
provider_name = fake.unique.word()
|
||||
|
||||
ApiToolManageService.create_api_tool_provider(
|
||||
user_id=account.id,
|
||||
tenant_id=tenant.id,
|
||||
provider_name=provider_name,
|
||||
icon={"content": "🔧", "background": "#FFF"},
|
||||
credentials={"auth_type": "none"},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema=schema,
|
||||
privacy_policy="",
|
||||
custom_disclaimer="",
|
||||
labels=[],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="auth_type is required"):
|
||||
ApiToolManageService.update_api_tool_provider(
|
||||
user_id=account.id,
|
||||
tenant_id=tenant.id,
|
||||
provider_name=provider_name,
|
||||
original_provider=provider_name,
|
||||
icon={},
|
||||
credentials={},
|
||||
_schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema=schema,
|
||||
privacy_policy=None,
|
||||
custom_disclaimer="",
|
||||
labels=[],
|
||||
)
|
||||
|
||||
def test_list_api_tool_provider_tools_not_found(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test listing tools raises ValueError when provider not found."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="you have not added provider"):
|
||||
ApiToolManageService.list_api_tool_provider_tools(account.id, tenant.id, "nonexistent")
|
||||
|
||||
def test_test_api_tool_preview_invalid_schema_type(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test preview raises ValueError for invalid schema type."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid schema type"):
|
||||
ApiToolManageService.test_api_tool_preview(
|
||||
tenant_id=tenant.id,
|
||||
provider_name="provider-a",
|
||||
tool_name="tool-a",
|
||||
credentials={"auth_type": "none"},
|
||||
parameters={},
|
||||
schema_type="bad-schema-type",
|
||||
schema="schema",
|
||||
)
|
||||
|
||||
@ -1043,3 +1043,112 @@ class TestWorkflowToolManageService:
|
||||
# After the fix, this should always be 0
|
||||
# For now, we document that the record may exist, demonstrating the bug
|
||||
# assert tool_count == 0 # Expected after fix
|
||||
|
||||
def test_delete_workflow_tool_success(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test successful deletion of a workflow tool."""
|
||||
fake = Faker()
|
||||
app, account, workflow = self._create_test_app_and_account(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
tool_name = fake.unique.word()
|
||||
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id=account.id,
|
||||
tenant_id=account.current_tenant.id,
|
||||
workflow_app_id=app.id,
|
||||
name=tool_name,
|
||||
label=fake.word(),
|
||||
icon={"type": "emoji", "emoji": "🔧"},
|
||||
description=fake.text(max_nb_chars=200),
|
||||
parameters=self._create_test_workflow_tool_parameters(),
|
||||
)
|
||||
|
||||
tool = (
|
||||
db_session_with_containers.query(WorkflowToolProvider)
|
||||
.where(WorkflowToolProvider.tenant_id == account.current_tenant.id, WorkflowToolProvider.name == tool_name)
|
||||
.first()
|
||||
)
|
||||
assert tool is not None
|
||||
|
||||
result = WorkflowToolManageService.delete_workflow_tool(account.id, account.current_tenant.id, tool.id)
|
||||
|
||||
assert result == {"result": "success"}
|
||||
deleted = (
|
||||
db_session_with_containers.query(WorkflowToolProvider).where(WorkflowToolProvider.id == tool.id).first()
|
||||
)
|
||||
assert deleted is None
|
||||
|
||||
def test_list_tenant_workflow_tools_empty(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test listing workflow tools when none exist returns empty list."""
|
||||
fake = Faker()
|
||||
app, account, workflow = self._create_test_app_and_account(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
result = WorkflowToolManageService.list_tenant_workflow_tools(account.id, account.current_tenant.id)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_get_workflow_tool_by_tool_id_not_found(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test that get_workflow_tool_by_tool_id raises ValueError when tool not found."""
|
||||
fake = Faker()
|
||||
app, account, workflow = self._create_test_app_and_account(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Tool not found"):
|
||||
WorkflowToolManageService.get_workflow_tool_by_tool_id(account.id, account.current_tenant.id, fake.uuid4())
|
||||
|
||||
def test_get_workflow_tool_by_app_id_not_found(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test that get_workflow_tool_by_app_id raises ValueError when tool not found."""
|
||||
fake = Faker()
|
||||
app, account, workflow = self._create_test_app_and_account(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Tool not found"):
|
||||
WorkflowToolManageService.get_workflow_tool_by_app_id(account.id, account.current_tenant.id, fake.uuid4())
|
||||
|
||||
def test_list_single_workflow_tools_not_found(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test that list_single_workflow_tools raises ValueError when tool not found."""
|
||||
fake = Faker()
|
||||
app, account, workflow = self._create_test_app_and_account(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
WorkflowToolManageService.list_single_workflow_tools(account.id, account.current_tenant.id, fake.uuid4())
|
||||
|
||||
def test_create_workflow_tool_with_labels(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Test that labels are forwarded to ToolLabelManager when provided."""
|
||||
fake = Faker()
|
||||
app, account, workflow = self._create_test_app_and_account(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
result = WorkflowToolManageService.create_workflow_tool(
|
||||
user_id=account.id,
|
||||
tenant_id=account.current_tenant.id,
|
||||
workflow_app_id=app.id,
|
||||
name=fake.unique.word(),
|
||||
label=fake.word(),
|
||||
icon={"type": "emoji", "emoji": "🔧"},
|
||||
description=fake.text(max_nb_chars=200),
|
||||
parameters=self._create_test_workflow_tool_parameters(),
|
||||
labels=["label-1", "label-2"],
|
||||
)
|
||||
|
||||
assert result == {"result": "success"}
|
||||
mock_external_service_dependencies["tool_label_manager"].update_tool_labels.assert_called_once()
|
||||
|
||||
@ -281,12 +281,10 @@ class TestSiteEndpoints:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
site = MagicMock()
|
||||
query = MagicMock()
|
||||
query.where.return_value.first.return_value = site
|
||||
monkeypatch.setattr(
|
||||
site_module.db,
|
||||
"session",
|
||||
MagicMock(query=lambda *_args, **_kwargs: query, commit=lambda: None),
|
||||
MagicMock(scalar=lambda *_args, **_kwargs: site, commit=lambda: None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
site_module,
|
||||
@ -305,12 +303,10 @@ class TestSiteEndpoints:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
site = MagicMock()
|
||||
query = MagicMock()
|
||||
query.where.return_value.first.return_value = site
|
||||
monkeypatch.setattr(
|
||||
site_module.db,
|
||||
"session",
|
||||
MagicMock(query=lambda *_args, **_kwargs: query, commit=lambda: None),
|
||||
MagicMock(scalar=lambda *_args, **_kwargs: site, commit=lambda: None),
|
||||
)
|
||||
monkeypatch.setattr(site_module.Site, "generate_code", lambda *_args, **_kwargs: "code")
|
||||
monkeypatch.setattr(
|
||||
|
||||
@ -82,12 +82,8 @@ def test_chat_conversation_list_advanced_chat_calls_paginate(app, monkeypatch: p
|
||||
def test_get_conversation_updates_read_at(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
conversation = SimpleNamespace(id="c1", app_id="app-1")
|
||||
|
||||
query = MagicMock()
|
||||
query.where.return_value = query
|
||||
query.first.return_value = conversation
|
||||
|
||||
session = MagicMock()
|
||||
session.query.return_value = query
|
||||
session.scalar.return_value = conversation
|
||||
|
||||
monkeypatch.setattr(conversation_module, "current_account_with_tenant", lambda: (_make_account(), "t1"))
|
||||
monkeypatch.setattr(conversation_module.db, "session", session)
|
||||
@ -101,12 +97,8 @@ def test_get_conversation_updates_read_at(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
|
||||
|
||||
def test_get_conversation_missing_raises_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
query = MagicMock()
|
||||
query.where.return_value = query
|
||||
query.first.return_value = None
|
||||
|
||||
session = MagicMock()
|
||||
session.query.return_value = query
|
||||
session.scalar.return_value = None
|
||||
|
||||
monkeypatch.setattr(conversation_module, "current_account_with_tenant", lambda: (_make_account(), "t1"))
|
||||
monkeypatch.setattr(conversation_module.db, "session", session)
|
||||
|
||||
@ -24,7 +24,7 @@ def test_get_conversation_mark_read_keeps_updated_at_unchanged():
|
||||
),
|
||||
patch("controllers.console.app.conversation.db.session", autospec=True) as mock_session,
|
||||
):
|
||||
mock_session.query.return_value.where.return_value.first.return_value = conversation
|
||||
mock_session.scalar.return_value = conversation
|
||||
|
||||
_get_conversation(app_model, "conversation-id")
|
||||
|
||||
|
||||
@ -73,8 +73,7 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
|
||||
|
||||
query = SimpleNamespace(where=lambda *_args, **_kwargs: query, first=lambda: None)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(query=lambda *_args, **_kwargs: query))
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: None))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
@ -99,8 +98,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
|
||||
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
query = SimpleNamespace(where=lambda *_args, **_kwargs: query, first=lambda: app_model)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(query=lambda *_args, **_kwargs: query))
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
_install_workflow_service(monkeypatch, workflow=None)
|
||||
|
||||
with app.test_request_context(
|
||||
@ -126,8 +124,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
|
||||
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
query = SimpleNamespace(where=lambda *_args, **_kwargs: query, first=lambda: app_model)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(query=lambda *_args, **_kwargs: query))
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
|
||||
workflow = SimpleNamespace(graph_dict={"nodes": []})
|
||||
_install_workflow_service(monkeypatch, workflow=workflow)
|
||||
@ -155,8 +152,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
query = SimpleNamespace(where=lambda *_args, **_kwargs: query, first=lambda: app_model)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(query=lambda *_args, **_kwargs: query))
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
|
||||
workflow = SimpleNamespace(
|
||||
graph_dict={
|
||||
|
||||
@ -1,320 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask, request
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from controllers.console.app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from controllers.console.app.message import (
|
||||
ChatMessageListApi,
|
||||
ChatMessagesQuery,
|
||||
FeedbackExportQuery,
|
||||
MessageAnnotationCountApi,
|
||||
MessageApi,
|
||||
MessageFeedbackApi,
|
||||
MessageFeedbackExportApi,
|
||||
MessageFeedbackPayload,
|
||||
MessageSuggestedQuestionApi,
|
||||
)
|
||||
from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from models import App, AppMode
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config["TESTING"] = True
|
||||
flask_app.config["RESTX_MASK_HEADER"] = "X-Fields"
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account():
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
account = MagicMock(spec=Account)
|
||||
account.id = "user_123"
|
||||
account.timezone = "UTC"
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.is_admin_or_owner = True
|
||||
account.current_tenant.current_role = "owner"
|
||||
account.has_edit_permission = True
|
||||
return account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_model():
|
||||
app_model = MagicMock(spec=App)
|
||||
app_model.id = "app_123"
|
||||
app_model.mode = AppMode.CHAT
|
||||
app_model.tenant_id = "tenant_123"
|
||||
return app_model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_csrf():
|
||||
with patch("libs.login.check_csrf_token") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
import contextlib
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def setup_test_context(
|
||||
test_app, endpoint_class, route_path, method, mock_account, mock_app_model, payload=None, qs=None
|
||||
):
|
||||
with (
|
||||
patch("extensions.ext_database.db") as mock_db,
|
||||
patch("controllers.console.app.wraps.db", mock_db),
|
||||
patch("controllers.console.wraps.db", mock_db),
|
||||
patch("controllers.console.app.message.db", mock_db),
|
||||
patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
patch("controllers.console.app.message.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
):
|
||||
# Set up a generic query mock that usually returns mock_app_model when getting app
|
||||
app_query_mock = MagicMock()
|
||||
app_query_mock.filter.return_value.first.return_value = mock_app_model
|
||||
app_query_mock.filter.return_value.filter.return_value.first.return_value = mock_app_model
|
||||
app_query_mock.where.return_value.first.return_value = mock_app_model
|
||||
app_query_mock.where.return_value.where.return_value.first.return_value = mock_app_model
|
||||
|
||||
data_query_mock = MagicMock()
|
||||
|
||||
def query_side_effect(*args, **kwargs):
|
||||
if args and hasattr(args[0], "__name__") and args[0].__name__ == "App":
|
||||
return app_query_mock
|
||||
return data_query_mock
|
||||
|
||||
mock_db.session.query.side_effect = query_side_effect
|
||||
mock_db.data_query = data_query_mock
|
||||
|
||||
# Let the caller override the stat db query logic
|
||||
proxy_mock = LocalProxy(lambda: mock_account)
|
||||
|
||||
query_string = "&".join([f"{k}={v}" for k, v in (qs or {}).items()])
|
||||
full_path = f"{route_path}?{query_string}" if qs else route_path
|
||||
|
||||
with (
|
||||
patch("libs.login.current_user", proxy_mock),
|
||||
patch("flask_login.current_user", proxy_mock),
|
||||
patch("controllers.console.app.message.attach_message_extra_contents", return_value=None),
|
||||
):
|
||||
with test_app.test_request_context(full_path, method=method, json=payload):
|
||||
request.view_args = {"app_id": "app_123"}
|
||||
|
||||
if "suggested-questions" in route_path:
|
||||
# simplistic extraction for message_id
|
||||
parts = route_path.split("chat-messages/")
|
||||
if len(parts) > 1:
|
||||
request.view_args["message_id"] = parts[1].split("/")[0]
|
||||
elif "messages/" in route_path and "chat-messages" not in route_path:
|
||||
parts = route_path.split("messages/")
|
||||
if len(parts) > 1:
|
||||
request.view_args["message_id"] = parts[1].split("/")[0]
|
||||
|
||||
api_instance = endpoint_class()
|
||||
|
||||
# Check if it has a dispatch_request or method
|
||||
if hasattr(api_instance, method.lower()):
|
||||
yield api_instance, mock_db, request.view_args
|
||||
|
||||
|
||||
class TestMessageValidators:
|
||||
def test_chat_messages_query_validators(self):
|
||||
# Test empty_to_none
|
||||
assert ChatMessagesQuery.empty_to_none("") is None
|
||||
assert ChatMessagesQuery.empty_to_none("val") == "val"
|
||||
|
||||
# Test validate_uuid
|
||||
assert ChatMessagesQuery.validate_uuid(None) is None
|
||||
assert (
|
||||
ChatMessagesQuery.validate_uuid("123e4567-e89b-12d3-a456-426614174000")
|
||||
== "123e4567-e89b-12d3-a456-426614174000"
|
||||
)
|
||||
|
||||
def test_message_feedback_validators(self):
|
||||
assert (
|
||||
MessageFeedbackPayload.validate_message_id("123e4567-e89b-12d3-a456-426614174000")
|
||||
== "123e4567-e89b-12d3-a456-426614174000"
|
||||
)
|
||||
|
||||
def test_feedback_export_validators(self):
|
||||
assert FeedbackExportQuery.parse_bool(None) is None
|
||||
assert FeedbackExportQuery.parse_bool(True) is True
|
||||
assert FeedbackExportQuery.parse_bool("1") is True
|
||||
assert FeedbackExportQuery.parse_bool("0") is False
|
||||
assert FeedbackExportQuery.parse_bool("off") is False
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
FeedbackExportQuery.parse_bool("invalid")
|
||||
|
||||
|
||||
class TestMessageEndpoints:
|
||||
def test_chat_message_list_not_found(self, app, mock_account, mock_app_model):
|
||||
with setup_test_context(
|
||||
app,
|
||||
ChatMessageListApi,
|
||||
"/apps/app_123/chat-messages",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
qs={"conversation_id": "123e4567-e89b-12d3-a456-426614174000"},
|
||||
) as (api, mock_db, v_args):
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
api.get(**v_args)
|
||||
|
||||
def test_chat_message_list_success(self, app, mock_account, mock_app_model):
|
||||
with setup_test_context(
|
||||
app,
|
||||
ChatMessageListApi,
|
||||
"/apps/app_123/chat-messages",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
qs={"conversation_id": "123e4567-e89b-12d3-a456-426614174000", "limit": 1},
|
||||
) as (api, mock_db, v_args):
|
||||
mock_conv = MagicMock()
|
||||
mock_conv.id = "123e4567-e89b-12d3-a456-426614174000"
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.id = "msg_123"
|
||||
mock_msg.feedbacks = []
|
||||
mock_msg.annotation = None
|
||||
mock_msg.annotation_hit_history = None
|
||||
mock_msg.agent_thoughts = []
|
||||
mock_msg.message_files = []
|
||||
mock_msg.extra_contents = []
|
||||
mock_msg.message = {}
|
||||
mock_msg.message_metadata_dict = {}
|
||||
|
||||
# scalar() is called twice: first for conversation lookup, second for has_more check
|
||||
mock_db.session.scalar.side_effect = [mock_conv, False]
|
||||
scalars_result = MagicMock()
|
||||
scalars_result.all.return_value = [mock_msg]
|
||||
mock_db.session.scalars.return_value = scalars_result
|
||||
|
||||
resp = api.get(**v_args)
|
||||
assert resp["limit"] == 1
|
||||
assert resp["has_more"] is False
|
||||
assert len(resp["data"]) == 1
|
||||
|
||||
def test_message_feedback_not_found(self, app, mock_account, mock_app_model):
|
||||
with setup_test_context(
|
||||
app,
|
||||
MessageFeedbackApi,
|
||||
"/apps/app_123/feedbacks",
|
||||
"POST",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
payload={"message_id": "123e4567-e89b-12d3-a456-426614174000"},
|
||||
) as (api, mock_db, v_args):
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
api.post(**v_args)
|
||||
|
||||
def test_message_feedback_success(self, app, mock_account, mock_app_model):
|
||||
payload = {"message_id": "123e4567-e89b-12d3-a456-426614174000", "rating": "like"}
|
||||
with setup_test_context(
|
||||
app, MessageFeedbackApi, "/apps/app_123/feedbacks", "POST", mock_account, mock_app_model, payload=payload
|
||||
) as (api, mock_db, v_args):
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.admin_feedback = None
|
||||
mock_db.session.scalar.return_value = mock_msg
|
||||
|
||||
resp = api.post(**v_args)
|
||||
assert resp == {"result": "success"}
|
||||
|
||||
def test_message_annotation_count(self, app, mock_account, mock_app_model):
|
||||
with setup_test_context(
|
||||
app, MessageAnnotationCountApi, "/apps/app_123/annotations/count", "GET", mock_account, mock_app_model
|
||||
) as (api, mock_db, v_args):
|
||||
mock_db.session.scalar.return_value = 5
|
||||
|
||||
resp = api.get(**v_args)
|
||||
assert resp == {"count": 5}
|
||||
|
||||
@patch("controllers.console.app.message.MessageService")
|
||||
def test_message_suggested_questions_success(self, mock_msg_srv, app, mock_account, mock_app_model):
|
||||
mock_msg_srv.get_suggested_questions_after_answer.return_value = ["q1", "q2"]
|
||||
|
||||
with setup_test_context(
|
||||
app,
|
||||
MessageSuggestedQuestionApi,
|
||||
"/apps/app_123/chat-messages/msg_123/suggested-questions",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
) as (api, mock_db, v_args):
|
||||
resp = api.get(**v_args)
|
||||
assert resp == {"data": ["q1", "q2"]}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "expected_exc"),
|
||||
[
|
||||
(MessageNotExistsError, NotFound),
|
||||
(ConversationNotExistsError, NotFound),
|
||||
(ProviderTokenNotInitError, ProviderNotInitializeError),
|
||||
(QuotaExceededError, ProviderQuotaExceededError),
|
||||
(ModelCurrentlyNotSupportError, ProviderModelCurrentlyNotSupportError),
|
||||
(SuggestedQuestionsAfterAnswerDisabledError, AppSuggestedQuestionsAfterAnswerDisabledError),
|
||||
(Exception, InternalServerError),
|
||||
],
|
||||
)
|
||||
@patch("controllers.console.app.message.MessageService")
|
||||
def test_message_suggested_questions_errors(
|
||||
self, mock_msg_srv, exc, expected_exc, app, mock_account, mock_app_model
|
||||
):
|
||||
mock_msg_srv.get_suggested_questions_after_answer.side_effect = exc()
|
||||
|
||||
with setup_test_context(
|
||||
app,
|
||||
MessageSuggestedQuestionApi,
|
||||
"/apps/app_123/chat-messages/msg_123/suggested-questions",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
) as (api, mock_db, v_args):
|
||||
with pytest.raises(expected_exc):
|
||||
api.get(**v_args)
|
||||
|
||||
@patch("services.feedback_service.FeedbackService.export_feedbacks")
|
||||
def test_message_feedback_export_success(self, mock_export, app, mock_account, mock_app_model):
|
||||
mock_export.return_value = {"exported": True}
|
||||
|
||||
with setup_test_context(
|
||||
app, MessageFeedbackExportApi, "/apps/app_123/feedbacks/export", "GET", mock_account, mock_app_model
|
||||
) as (api, mock_db, v_args):
|
||||
resp = api.get(**v_args)
|
||||
assert resp == {"exported": True}
|
||||
|
||||
def test_message_api_get_success(self, app, mock_account, mock_app_model):
|
||||
with setup_test_context(
|
||||
app, MessageApi, "/apps/app_123/messages/msg_123", "GET", mock_account, mock_app_model
|
||||
) as (api, mock_db, v_args):
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.id = "msg_123"
|
||||
mock_msg.feedbacks = []
|
||||
mock_msg.annotation = None
|
||||
mock_msg.annotation_hit_history = None
|
||||
mock_msg.agent_thoughts = []
|
||||
mock_msg.message_files = []
|
||||
mock_msg.extra_contents = []
|
||||
mock_msg.message = {}
|
||||
mock_msg.message_metadata_dict = {}
|
||||
|
||||
mock_db.session.scalar.return_value = mock_msg
|
||||
|
||||
resp = api.get(**v_args)
|
||||
assert resp["id"] == "msg_123"
|
||||
@ -92,10 +92,7 @@ def test_post_encrypts_agent_tool_parameters(app, monkeypatch: pytest.MonkeyPatc
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
query = MagicMock()
|
||||
query.where.return_value = query
|
||||
query.first.return_value = original_config
|
||||
session.query.return_value = query
|
||||
session.get.return_value = original_config
|
||||
monkeypatch.setattr(model_config_module.db, "session", session)
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
||||
@ -1,275 +0,0 @@
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask, request
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from controllers.console.app.statistic import (
|
||||
AverageResponseTimeStatistic,
|
||||
AverageSessionInteractionStatistic,
|
||||
DailyConversationStatistic,
|
||||
DailyMessageStatistic,
|
||||
DailyTerminalsStatistic,
|
||||
DailyTokenCostStatistic,
|
||||
TokensPerSecondStatistic,
|
||||
UserSatisfactionRateStatistic,
|
||||
)
|
||||
from models import App, AppMode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account():
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
account = MagicMock(spec=Account)
|
||||
account.id = "user_123"
|
||||
account.timezone = "UTC"
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.is_admin_or_owner = True
|
||||
account.current_tenant.current_role = "owner"
|
||||
account.has_edit_permission = True
|
||||
return account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_model():
|
||||
app_model = MagicMock(spec=App)
|
||||
app_model.id = "app_123"
|
||||
app_model.mode = AppMode.CHAT
|
||||
app_model.tenant_id = "tenant_123"
|
||||
return app_model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_csrf():
|
||||
with patch("libs.login.check_csrf_token") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def setup_test_context(
|
||||
test_app, endpoint_class, route_path, mock_account, mock_app_model, mock_rs, mock_parse_ret=(None, None)
|
||||
):
|
||||
with (
|
||||
patch("controllers.console.app.statistic.db") as mock_db_stat,
|
||||
patch("controllers.console.app.wraps.db") as mock_db_wraps,
|
||||
patch("controllers.console.wraps.db", mock_db_wraps),
|
||||
patch(
|
||||
"controllers.console.app.statistic.current_account_with_tenant", return_value=(mock_account, "tenant_123")
|
||||
),
|
||||
patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
):
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value = mock_rs
|
||||
|
||||
mock_begin = MagicMock()
|
||||
mock_begin.__enter__.return_value = mock_conn
|
||||
mock_db_stat.engine.begin.return_value = mock_begin
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.first.return_value = mock_app_model
|
||||
mock_query.filter.return_value.filter.return_value.first.return_value = mock_app_model
|
||||
mock_query.where.return_value.first.return_value = mock_app_model
|
||||
mock_query.where.return_value.where.return_value.first.return_value = mock_app_model
|
||||
mock_db_wraps.session.query.return_value = mock_query
|
||||
|
||||
proxy_mock = LocalProxy(lambda: mock_account)
|
||||
|
||||
with patch("libs.login.current_user", proxy_mock), patch("flask_login.current_user", proxy_mock):
|
||||
with test_app.test_request_context(route_path, method="GET"):
|
||||
request.view_args = {"app_id": "app_123"}
|
||||
api_instance = endpoint_class()
|
||||
response = api_instance.get(app_id="app_123")
|
||||
return response
|
||||
|
||||
|
||||
class TestStatisticEndpoints:
|
||||
def test_daily_message_statistic(self, app, mock_account, mock_app_model):
|
||||
mock_row = MagicMock()
|
||||
mock_row.date = "2023-01-01"
|
||||
mock_row.message_count = 10
|
||||
mock_row.interactions = Decimal(0)
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", return_value=(None, None)):
|
||||
response = setup_test_context(
|
||||
app,
|
||||
DailyMessageStatistic,
|
||||
"/apps/app_123/statistics/daily-messages?start=2023-01-01 00:00&end=2023-01-02 00:00",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[mock_row],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["data"][0]["message_count"] == 10
|
||||
|
||||
def test_daily_conversation_statistic(self, app, mock_account, mock_app_model):
|
||||
mock_row = MagicMock()
|
||||
mock_row.date = "2023-01-01"
|
||||
mock_row.conversation_count = 5
|
||||
mock_row.interactions = Decimal(0)
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", return_value=(None, None)):
|
||||
response = setup_test_context(
|
||||
app,
|
||||
DailyConversationStatistic,
|
||||
"/apps/app_123/statistics/daily-conversations",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[mock_row],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["data"][0]["conversation_count"] == 5
|
||||
|
||||
def test_daily_terminals_statistic(self, app, mock_account, mock_app_model):
|
||||
mock_row = MagicMock()
|
||||
mock_row.date = "2023-01-01"
|
||||
mock_row.terminal_count = 2
|
||||
mock_row.interactions = Decimal(0)
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", return_value=(None, None)):
|
||||
response = setup_test_context(
|
||||
app,
|
||||
DailyTerminalsStatistic,
|
||||
"/apps/app_123/statistics/daily-end-users",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[mock_row],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["data"][0]["terminal_count"] == 2
|
||||
|
||||
def test_daily_token_cost_statistic(self, app, mock_account, mock_app_model):
|
||||
mock_row = MagicMock()
|
||||
mock_row.date = "2023-01-01"
|
||||
mock_row.token_count = 100
|
||||
mock_row.total_price = Decimal("0.02")
|
||||
mock_row.interactions = Decimal(0)
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", return_value=(None, None)):
|
||||
response = setup_test_context(
|
||||
app,
|
||||
DailyTokenCostStatistic,
|
||||
"/apps/app_123/statistics/token-costs",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[mock_row],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["data"][0]["token_count"] == 100
|
||||
assert response.json["data"][0]["total_price"] == "0.02"
|
||||
|
||||
def test_average_session_interaction_statistic(self, app, mock_account, mock_app_model):
|
||||
mock_row = MagicMock()
|
||||
mock_row.date = "2023-01-01"
|
||||
mock_row.interactions = Decimal("3.523")
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", return_value=(None, None)):
|
||||
response = setup_test_context(
|
||||
app,
|
||||
AverageSessionInteractionStatistic,
|
||||
"/apps/app_123/statistics/average-session-interactions",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[mock_row],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["data"][0]["interactions"] == 3.52
|
||||
|
||||
def test_user_satisfaction_rate_statistic(self, app, mock_account, mock_app_model):
|
||||
mock_row = MagicMock()
|
||||
mock_row.date = "2023-01-01"
|
||||
mock_row.message_count = 100
|
||||
mock_row.feedback_count = 10
|
||||
mock_row.interactions = Decimal(0)
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", return_value=(None, None)):
|
||||
response = setup_test_context(
|
||||
app,
|
||||
UserSatisfactionRateStatistic,
|
||||
"/apps/app_123/statistics/user-satisfaction-rate",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[mock_row],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["data"][0]["rate"] == 100.0
|
||||
|
||||
def test_average_response_time_statistic(self, app, mock_account, mock_app_model):
|
||||
mock_app_model.mode = AppMode.COMPLETION
|
||||
mock_row = MagicMock()
|
||||
mock_row.date = "2023-01-01"
|
||||
mock_row.latency = 1.234
|
||||
mock_row.interactions = Decimal(0)
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", return_value=(None, None)):
|
||||
response = setup_test_context(
|
||||
app,
|
||||
AverageResponseTimeStatistic,
|
||||
"/apps/app_123/statistics/average-response-time",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[mock_row],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["data"][0]["latency"] == 1234.0
|
||||
|
||||
def test_tokens_per_second_statistic(self, app, mock_account, mock_app_model):
|
||||
mock_row = MagicMock()
|
||||
mock_row.date = "2023-01-01"
|
||||
mock_row.tokens_per_second = 15.5
|
||||
mock_row.interactions = Decimal(0)
|
||||
|
||||
with patch("controllers.console.app.statistic.parse_time_range", return_value=(None, None)):
|
||||
response = setup_test_context(
|
||||
app,
|
||||
TokensPerSecondStatistic,
|
||||
"/apps/app_123/statistics/tokens-per-second",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[mock_row],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["data"][0]["tps"] == 15.5
|
||||
|
||||
@patch("controllers.console.app.statistic.parse_time_range")
|
||||
def test_invalid_time_range(self, mock_parse, app, mock_account, mock_app_model):
|
||||
mock_parse.side_effect = ValueError("Invalid time")
|
||||
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
with pytest.raises(BadRequest):
|
||||
setup_test_context(
|
||||
app,
|
||||
DailyMessageStatistic,
|
||||
"/apps/app_123/statistics/daily-messages?start=invalid&end=invalid",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[],
|
||||
)
|
||||
|
||||
@patch("controllers.console.app.statistic.parse_time_range")
|
||||
def test_time_range_params_passed(self, mock_parse, app, mock_account, mock_app_model):
|
||||
import datetime
|
||||
|
||||
start = datetime.datetime.now()
|
||||
end = datetime.datetime.now()
|
||||
mock_parse.return_value = (start, end)
|
||||
|
||||
response = setup_test_context(
|
||||
app,
|
||||
DailyMessageStatistic,
|
||||
"/apps/app_123/statistics/daily-messages?start=something&end=something",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
[],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_parse.assert_called_once()
|
||||
@ -1,313 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask, request
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from controllers.console.app.error import DraftWorkflowNotExist
|
||||
from controllers.console.app.workflow_draft_variable import (
|
||||
ConversationVariableCollectionApi,
|
||||
EnvironmentVariableCollectionApi,
|
||||
NodeVariableCollectionApi,
|
||||
SystemVariableCollectionApi,
|
||||
VariableApi,
|
||||
VariableResetApi,
|
||||
WorkflowVariableCollectionApi,
|
||||
)
|
||||
from controllers.web.error import InvalidArgumentError, NotFoundError
|
||||
from models import App, AppMode
|
||||
from models.enums import DraftVariableType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config["TESTING"] = True
|
||||
flask_app.config["RESTX_MASK_HEADER"] = "X-Fields"
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account():
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
account = MagicMock(spec=Account)
|
||||
account.id = "user_123"
|
||||
account.timezone = "UTC"
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.is_admin_or_owner = True
|
||||
account.current_tenant.current_role = "owner"
|
||||
account.has_edit_permission = True
|
||||
return account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_model():
|
||||
app_model = MagicMock(spec=App)
|
||||
app_model.id = "app_123"
|
||||
app_model.mode = AppMode.WORKFLOW
|
||||
app_model.tenant_id = "tenant_123"
|
||||
return app_model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_csrf():
|
||||
with patch("libs.login.check_csrf_token") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def setup_test_context(test_app, endpoint_class, route_path, method, mock_account, mock_app_model, payload=None):
|
||||
with (
|
||||
patch("controllers.console.app.wraps.db") as mock_db_wraps,
|
||||
patch("controllers.console.wraps.db", mock_db_wraps),
|
||||
patch("controllers.console.app.workflow_draft_variable.db"),
|
||||
patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
):
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.first.return_value = mock_app_model
|
||||
mock_query.filter.return_value.filter.return_value.first.return_value = mock_app_model
|
||||
mock_query.where.return_value.first.return_value = mock_app_model
|
||||
mock_query.where.return_value.where.return_value.first.return_value = mock_app_model
|
||||
mock_db_wraps.session.query.return_value = mock_query
|
||||
|
||||
proxy_mock = LocalProxy(lambda: mock_account)
|
||||
|
||||
with patch("libs.login.current_user", proxy_mock), patch("flask_login.current_user", proxy_mock):
|
||||
with test_app.test_request_context(route_path, method=method, json=payload):
|
||||
request.view_args = {"app_id": "app_123"}
|
||||
# extract node_id or variable_id from path manually since view_args overrides
|
||||
if "nodes/" in route_path:
|
||||
request.view_args["node_id"] = route_path.split("nodes/")[1].split("/")[0]
|
||||
if "variables/" in route_path:
|
||||
# simplistic extraction
|
||||
parts = route_path.split("variables/")
|
||||
if len(parts) > 1 and parts[1] and parts[1] != "reset":
|
||||
request.view_args["variable_id"] = parts[1].split("/")[0]
|
||||
|
||||
api_instance = endpoint_class()
|
||||
# we just call dispatch_request to avoid manual argument passing
|
||||
if hasattr(api_instance, method.lower()):
|
||||
func = getattr(api_instance, method.lower())
|
||||
return func(**request.view_args)
|
||||
|
||||
|
||||
class TestWorkflowDraftVariableEndpoints:
|
||||
@staticmethod
|
||||
def _mock_workflow_variable(variable_type: DraftVariableType = DraftVariableType.NODE) -> MagicMock:
|
||||
class DummyValueType:
|
||||
def exposed_type(self):
|
||||
return DraftVariableType.NODE
|
||||
|
||||
mock_var = MagicMock()
|
||||
mock_var.app_id = "app_123"
|
||||
mock_var.id = "var_123"
|
||||
mock_var.name = "test_var"
|
||||
mock_var.description = ""
|
||||
mock_var.get_variable_type.return_value = variable_type
|
||||
mock_var.get_selector.return_value = []
|
||||
mock_var.value_type = DummyValueType()
|
||||
mock_var.edited = False
|
||||
mock_var.visible = True
|
||||
mock_var.file_id = None
|
||||
mock_var.variable_file = None
|
||||
mock_var.is_truncated.return_value = False
|
||||
mock_var.get_value.return_value.model_copy.return_value.value = "test_value"
|
||||
return mock_var
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowService")
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_workflow_variable_collection_get_success(
|
||||
self, mock_draft_srv, mock_wf_srv, app, mock_account, mock_app_model
|
||||
):
|
||||
mock_wf_srv.return_value.is_workflow_exist.return_value = True
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableList
|
||||
|
||||
mock_draft_srv.return_value.list_variables_without_values.return_value = WorkflowDraftVariableList(
|
||||
variables=[], total=0
|
||||
)
|
||||
|
||||
resp = setup_test_context(
|
||||
app,
|
||||
WorkflowVariableCollectionApi,
|
||||
"/apps/app_123/workflows/draft/variables?page=1&limit=20",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
assert resp == {"items": [], "total": 0}
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowService")
|
||||
def test_workflow_variable_collection_get_not_exist(self, mock_wf_srv, app, mock_account, mock_app_model):
|
||||
mock_wf_srv.return_value.is_workflow_exist.return_value = False
|
||||
|
||||
with pytest.raises(DraftWorkflowNotExist):
|
||||
setup_test_context(
|
||||
app,
|
||||
WorkflowVariableCollectionApi,
|
||||
"/apps/app_123/workflows/draft/variables",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_workflow_variable_collection_delete(self, mock_draft_srv, app, mock_account, mock_app_model):
|
||||
resp = setup_test_context(
|
||||
app,
|
||||
WorkflowVariableCollectionApi,
|
||||
"/apps/app_123/workflows/draft/variables",
|
||||
"DELETE",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_node_variable_collection_get_success(self, mock_draft_srv, app, mock_account, mock_app_model):
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableList
|
||||
|
||||
mock_draft_srv.return_value.list_node_variables.return_value = WorkflowDraftVariableList(variables=[])
|
||||
resp = setup_test_context(
|
||||
app,
|
||||
NodeVariableCollectionApi,
|
||||
"/apps/app_123/workflows/draft/nodes/node_123/variables",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
assert resp == {"items": []}
|
||||
|
||||
def test_node_variable_collection_get_invalid_node_id(self, app, mock_account, mock_app_model):
|
||||
with pytest.raises(InvalidArgumentError):
|
||||
setup_test_context(
|
||||
app,
|
||||
NodeVariableCollectionApi,
|
||||
"/apps/app_123/workflows/draft/nodes/sys/variables",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_node_variable_collection_delete(self, mock_draft_srv, app, mock_account, mock_app_model):
|
||||
resp = setup_test_context(
|
||||
app,
|
||||
NodeVariableCollectionApi,
|
||||
"/apps/app_123/workflows/draft/nodes/node_123/variables",
|
||||
"DELETE",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_variable_api_get_success(self, mock_draft_srv, app, mock_account, mock_app_model):
|
||||
mock_draft_srv.return_value.get_variable.return_value = self._mock_workflow_variable()
|
||||
|
||||
resp = setup_test_context(
|
||||
app, VariableApi, "/apps/app_123/workflows/draft/variables/var_123", "GET", mock_account, mock_app_model
|
||||
)
|
||||
assert resp["id"] == "var_123"
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_variable_api_get_not_found(self, mock_draft_srv, app, mock_account, mock_app_model):
|
||||
mock_draft_srv.return_value.get_variable.return_value = None
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
setup_test_context(
|
||||
app, VariableApi, "/apps/app_123/workflows/draft/variables/var_123", "GET", mock_account, mock_app_model
|
||||
)
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_variable_api_patch_success(self, mock_draft_srv, app, mock_account, mock_app_model):
|
||||
mock_draft_srv.return_value.get_variable.return_value = self._mock_workflow_variable()
|
||||
|
||||
resp = setup_test_context(
|
||||
app,
|
||||
VariableApi,
|
||||
"/apps/app_123/workflows/draft/variables/var_123",
|
||||
"PATCH",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
payload={"name": "new_name"},
|
||||
)
|
||||
assert resp["id"] == "var_123"
|
||||
mock_draft_srv.return_value.update_variable.assert_called_once()
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_variable_api_delete_success(self, mock_draft_srv, app, mock_account, mock_app_model):
|
||||
mock_draft_srv.return_value.get_variable.return_value = self._mock_workflow_variable()
|
||||
|
||||
resp = setup_test_context(
|
||||
app, VariableApi, "/apps/app_123/workflows/draft/variables/var_123", "DELETE", mock_account, mock_app_model
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
mock_draft_srv.return_value.delete_variable.assert_called_once()
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowService")
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_variable_reset_api_put_success(self, mock_draft_srv, mock_wf_srv, app, mock_account, mock_app_model):
|
||||
mock_wf_srv.return_value.get_draft_workflow.return_value = MagicMock()
|
||||
mock_draft_srv.return_value.get_variable.return_value = self._mock_workflow_variable()
|
||||
mock_draft_srv.return_value.reset_variable.return_value = None # means no content
|
||||
|
||||
resp = setup_test_context(
|
||||
app,
|
||||
VariableResetApi,
|
||||
"/apps/app_123/workflows/draft/variables/var_123/reset",
|
||||
"PUT",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowService")
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_conversation_variable_collection_get(self, mock_draft_srv, mock_wf_srv, app, mock_account, mock_app_model):
|
||||
mock_wf_srv.return_value.get_draft_workflow.return_value = MagicMock()
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableList
|
||||
|
||||
mock_draft_srv.return_value.list_conversation_variables.return_value = WorkflowDraftVariableList(variables=[])
|
||||
|
||||
resp = setup_test_context(
|
||||
app,
|
||||
ConversationVariableCollectionApi,
|
||||
"/apps/app_123/workflows/draft/conversation-variables",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
assert resp == {"items": []}
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowDraftVariableService")
|
||||
def test_system_variable_collection_get(self, mock_draft_srv, app, mock_account, mock_app_model):
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableList
|
||||
|
||||
mock_draft_srv.return_value.list_system_variables.return_value = WorkflowDraftVariableList(variables=[])
|
||||
|
||||
resp = setup_test_context(
|
||||
app,
|
||||
SystemVariableCollectionApi,
|
||||
"/apps/app_123/workflows/draft/system-variables",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
assert resp == {"items": []}
|
||||
|
||||
@patch("controllers.console.app.workflow_draft_variable.WorkflowService")
|
||||
def test_environment_variable_collection_get(self, mock_wf_srv, app, mock_account, mock_app_model):
|
||||
mock_wf = MagicMock()
|
||||
mock_wf.environment_variables = []
|
||||
mock_wf_srv.return_value.get_draft_workflow.return_value = mock_wf
|
||||
|
||||
resp = setup_test_context(
|
||||
app,
|
||||
EnvironmentVariableCollectionApi,
|
||||
"/apps/app_123/workflows/draft/environment-variables",
|
||||
"GET",
|
||||
mock_account,
|
||||
mock_app_model,
|
||||
)
|
||||
assert resp == {"items": []}
|
||||
@ -11,10 +11,8 @@ from models.model import AppMode
|
||||
|
||||
def test_get_app_model_injects_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal", tenant_id="t1")
|
||||
query = SimpleNamespace(where=lambda *_args, **_kwargs: query, first=lambda: app_model)
|
||||
|
||||
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, "t1"))
|
||||
monkeypatch.setattr(wraps_module.db, "session", SimpleNamespace(query=lambda *_args, **_kwargs: query))
|
||||
monkeypatch.setattr(wraps_module.db, "session", SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model))
|
||||
|
||||
@wraps_module.get_app_model
|
||||
def handler(app_model):
|
||||
@ -25,10 +23,8 @@ def test_get_app_model_injects_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
def test_get_app_model_rejects_wrong_mode(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal", tenant_id="t1")
|
||||
query = SimpleNamespace(where=lambda *_args, **_kwargs: query, first=lambda: app_model)
|
||||
|
||||
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, "t1"))
|
||||
monkeypatch.setattr(wraps_module.db, "session", SimpleNamespace(query=lambda *_args, **_kwargs: query))
|
||||
monkeypatch.setattr(wraps_module.db, "session", SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model))
|
||||
|
||||
@wraps_module.get_app_model(mode=[AppMode.COMPLETION])
|
||||
def handler(app_model):
|
||||
|
||||
@ -1,209 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.auth.data_source_bearer_auth import (
|
||||
ApiKeyAuthDataSource,
|
||||
ApiKeyAuthDataSourceBinding,
|
||||
ApiKeyAuthDataSourceBindingDelete,
|
||||
)
|
||||
from controllers.console.auth.error import ApiKeyAuthFailedError
|
||||
|
||||
|
||||
class TestApiKeyAuthDataSource:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.config["WTF_CSRF_ENABLED"] = False
|
||||
return app
|
||||
|
||||
@patch("libs.login.check_csrf_token")
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.get_provider_auth_list")
|
||||
def test_get_api_key_auth_data_source(self, mock_get_list, mock_db, mock_csrf, app):
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
mock_account = MagicMock(spec=Account)
|
||||
mock_account.id = "user_123"
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_account.is_admin_or_owner = True
|
||||
mock_account.current_tenant.current_role = "owner"
|
||||
|
||||
mock_binding = MagicMock()
|
||||
mock_binding.id = "bind_123"
|
||||
mock_binding.category = "api_key"
|
||||
mock_binding.provider = "custom_provider"
|
||||
mock_binding.disabled = False
|
||||
mock_binding.created_at.timestamp.return_value = 1620000000
|
||||
mock_binding.updated_at.timestamp.return_value = 1620000001
|
||||
|
||||
mock_get_list.return_value = [mock_binding]
|
||||
|
||||
with (
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
patch(
|
||||
"controllers.console.auth.data_source_bearer_auth.current_account_with_tenant",
|
||||
return_value=(mock_account, "tenant_123"),
|
||||
),
|
||||
):
|
||||
with app.test_request_context("/console/api/api-key-auth/data-source", method="GET"):
|
||||
proxy_mock = MagicMock()
|
||||
proxy_mock._get_current_object.return_value = mock_account
|
||||
with patch("libs.login.current_user", proxy_mock):
|
||||
api_instance = ApiKeyAuthDataSource()
|
||||
response = api_instance.get()
|
||||
|
||||
assert "sources" in response
|
||||
assert len(response["sources"]) == 1
|
||||
assert response["sources"][0]["provider"] == "custom_provider"
|
||||
|
||||
@patch("libs.login.check_csrf_token")
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.get_provider_auth_list")
|
||||
def test_get_api_key_auth_data_source_empty(self, mock_get_list, mock_db, mock_csrf, app):
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
mock_account = MagicMock(spec=Account)
|
||||
mock_account.id = "user_123"
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_account.is_admin_or_owner = True
|
||||
mock_account.current_tenant.current_role = "owner"
|
||||
|
||||
mock_get_list.return_value = None
|
||||
|
||||
with (
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
patch(
|
||||
"controllers.console.auth.data_source_bearer_auth.current_account_with_tenant",
|
||||
return_value=(mock_account, "tenant_123"),
|
||||
),
|
||||
):
|
||||
with app.test_request_context("/console/api/api-key-auth/data-source", method="GET"):
|
||||
proxy_mock = MagicMock()
|
||||
proxy_mock._get_current_object.return_value = mock_account
|
||||
with patch("libs.login.current_user", proxy_mock):
|
||||
api_instance = ApiKeyAuthDataSource()
|
||||
response = api_instance.get()
|
||||
|
||||
assert "sources" in response
|
||||
assert len(response["sources"]) == 0
|
||||
|
||||
|
||||
class TestApiKeyAuthDataSourceBinding:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.config["WTF_CSRF_ENABLED"] = False
|
||||
return app
|
||||
|
||||
@patch("libs.login.check_csrf_token")
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.create_provider_auth")
|
||||
@patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.validate_api_key_auth_args")
|
||||
def test_create_binding_successful(self, mock_validate, mock_create, mock_db, mock_csrf, app):
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
mock_account = MagicMock(spec=Account)
|
||||
mock_account.id = "user_123"
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_account.is_admin_or_owner = True
|
||||
mock_account.current_tenant.current_role = "owner"
|
||||
|
||||
with (
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
patch(
|
||||
"controllers.console.auth.data_source_bearer_auth.current_account_with_tenant",
|
||||
return_value=(mock_account, "tenant_123"),
|
||||
),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/console/api/api-key-auth/data-source/binding",
|
||||
method="POST",
|
||||
json={"category": "api_key", "provider": "custom", "credentials": {"key": "value"}},
|
||||
):
|
||||
proxy_mock = MagicMock()
|
||||
proxy_mock._get_current_object.return_value = mock_account
|
||||
with patch("libs.login.current_user", proxy_mock), patch("flask_login.current_user", proxy_mock):
|
||||
api_instance = ApiKeyAuthDataSourceBinding()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response[0]["result"] == "success"
|
||||
assert response[1] == 200
|
||||
mock_validate.assert_called_once()
|
||||
mock_create.assert_called_once()
|
||||
|
||||
@patch("libs.login.check_csrf_token")
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.create_provider_auth")
|
||||
@patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.validate_api_key_auth_args")
|
||||
def test_create_binding_failure(self, mock_validate, mock_create, mock_db, mock_csrf, app):
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
mock_account = MagicMock(spec=Account)
|
||||
mock_account.id = "user_123"
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_account.is_admin_or_owner = True
|
||||
mock_account.current_tenant.current_role = "owner"
|
||||
|
||||
mock_create.side_effect = ValueError("Invalid structure")
|
||||
|
||||
with (
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
patch(
|
||||
"controllers.console.auth.data_source_bearer_auth.current_account_with_tenant",
|
||||
return_value=(mock_account, "tenant_123"),
|
||||
),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/console/api/api-key-auth/data-source/binding",
|
||||
method="POST",
|
||||
json={"category": "api_key", "provider": "custom", "credentials": {"key": "value"}},
|
||||
):
|
||||
proxy_mock = MagicMock()
|
||||
proxy_mock._get_current_object.return_value = mock_account
|
||||
with patch("libs.login.current_user", proxy_mock), patch("flask_login.current_user", proxy_mock):
|
||||
api_instance = ApiKeyAuthDataSourceBinding()
|
||||
with pytest.raises(ApiKeyAuthFailedError, match="Invalid structure"):
|
||||
api_instance.post()
|
||||
|
||||
|
||||
class TestApiKeyAuthDataSourceBindingDelete:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.config["WTF_CSRF_ENABLED"] = False
|
||||
return app
|
||||
|
||||
@patch("libs.login.check_csrf_token")
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.delete_provider_auth")
|
||||
def test_delete_binding_successful(self, mock_delete, mock_db, mock_csrf, app):
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
mock_account = MagicMock(spec=Account)
|
||||
mock_account.id = "user_123"
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_account.is_admin_or_owner = True
|
||||
mock_account.current_tenant.current_role = "owner"
|
||||
|
||||
with (
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, "tenant_123")),
|
||||
patch(
|
||||
"controllers.console.auth.data_source_bearer_auth.current_account_with_tenant",
|
||||
return_value=(mock_account, "tenant_123"),
|
||||
),
|
||||
):
|
||||
with app.test_request_context("/console/api/api-key-auth/data-source/binding_123", method="DELETE"):
|
||||
proxy_mock = MagicMock()
|
||||
proxy_mock._get_current_object.return_value = mock_account
|
||||
with patch("libs.login.current_user", proxy_mock), patch("flask_login.current_user", proxy_mock):
|
||||
api_instance = ApiKeyAuthDataSourceBindingDelete()
|
||||
response = api_instance.delete("binding_123")
|
||||
|
||||
assert response[0]["result"] == "success"
|
||||
assert response[1] == 204
|
||||
mock_delete.assert_called_once_with("tenant_123", "binding_123")
|
||||
@ -1,192 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from controllers.console.auth.data_source_oauth import (
|
||||
OAuthDataSource,
|
||||
OAuthDataSourceBinding,
|
||||
OAuthDataSourceCallback,
|
||||
OAuthDataSourceSync,
|
||||
)
|
||||
|
||||
|
||||
class TestOAuthDataSource:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
@patch("controllers.console.auth.data_source_oauth.get_oauth_providers")
|
||||
@patch("flask_login.current_user")
|
||||
@patch("libs.login.current_user")
|
||||
@patch("libs.login.check_csrf_token")
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.data_source_oauth.dify_config.NOTION_INTEGRATION_TYPE", None)
|
||||
def test_get_oauth_url_successful(
|
||||
self, mock_db, mock_csrf, mock_libs_user, mock_flask_user, mock_get_providers, app
|
||||
):
|
||||
mock_oauth_provider = MagicMock()
|
||||
mock_oauth_provider.get_authorization_url.return_value = "http://oauth.provider/auth"
|
||||
mock_get_providers.return_value = {"notion": mock_oauth_provider}
|
||||
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
mock_account = MagicMock(spec=Account)
|
||||
mock_account.id = "user_123"
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_account.is_admin_or_owner = True
|
||||
mock_account.current_tenant.current_role = "owner"
|
||||
mock_libs_user.return_value = mock_account
|
||||
mock_flask_user.return_value = mock_account
|
||||
|
||||
# also patch current_account_with_tenant
|
||||
with patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, MagicMock())):
|
||||
with app.test_request_context("/console/api/oauth/data-source/notion", method="GET"):
|
||||
proxy_mock = LocalProxy(lambda: mock_account)
|
||||
with patch("libs.login.current_user", proxy_mock):
|
||||
api_instance = OAuthDataSource()
|
||||
response = api_instance.get("notion")
|
||||
|
||||
assert response[0]["data"] == "http://oauth.provider/auth"
|
||||
assert response[1] == 200
|
||||
mock_oauth_provider.get_authorization_url.assert_called_once()
|
||||
|
||||
@patch("controllers.console.auth.data_source_oauth.get_oauth_providers")
|
||||
@patch("flask_login.current_user")
|
||||
@patch("libs.login.check_csrf_token")
|
||||
@patch("controllers.console.wraps.db")
|
||||
def test_get_oauth_url_invalid_provider(self, mock_db, mock_csrf, mock_flask_user, mock_get_providers, app):
|
||||
mock_get_providers.return_value = {"notion": MagicMock()}
|
||||
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
mock_account = MagicMock(spec=Account)
|
||||
mock_account.id = "user_123"
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_account.is_admin_or_owner = True
|
||||
mock_account.current_tenant.current_role = "owner"
|
||||
|
||||
with patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, MagicMock())):
|
||||
with app.test_request_context("/console/api/oauth/data-source/unknown_provider", method="GET"):
|
||||
proxy_mock = LocalProxy(lambda: mock_account)
|
||||
with patch("libs.login.current_user", proxy_mock):
|
||||
api_instance = OAuthDataSource()
|
||||
response = api_instance.get("unknown_provider")
|
||||
|
||||
assert response[0]["error"] == "Invalid provider"
|
||||
assert response[1] == 400
|
||||
|
||||
|
||||
class TestOAuthDataSourceCallback:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
@patch("controllers.console.auth.data_source_oauth.get_oauth_providers")
|
||||
def test_oauth_callback_successful(self, mock_get_providers, app):
|
||||
provider_mock = MagicMock()
|
||||
mock_get_providers.return_value = {"notion": provider_mock}
|
||||
|
||||
with app.test_request_context("/console/api/oauth/data-source/notion/callback?code=mock_code", method="GET"):
|
||||
api_instance = OAuthDataSourceCallback()
|
||||
response = api_instance.get("notion")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "code=mock_code" in response.location
|
||||
|
||||
@patch("controllers.console.auth.data_source_oauth.get_oauth_providers")
|
||||
def test_oauth_callback_missing_code(self, mock_get_providers, app):
|
||||
provider_mock = MagicMock()
|
||||
mock_get_providers.return_value = {"notion": provider_mock}
|
||||
|
||||
with app.test_request_context("/console/api/oauth/data-source/notion/callback", method="GET"):
|
||||
api_instance = OAuthDataSourceCallback()
|
||||
response = api_instance.get("notion")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "error=Access denied" in response.location
|
||||
|
||||
@patch("controllers.console.auth.data_source_oauth.get_oauth_providers")
|
||||
def test_oauth_callback_invalid_provider(self, mock_get_providers, app):
|
||||
mock_get_providers.return_value = {"notion": MagicMock()}
|
||||
|
||||
with app.test_request_context("/console/api/oauth/data-source/invalid/callback?code=mock_code", method="GET"):
|
||||
api_instance = OAuthDataSourceCallback()
|
||||
response = api_instance.get("invalid")
|
||||
|
||||
assert response[0]["error"] == "Invalid provider"
|
||||
assert response[1] == 400
|
||||
|
||||
|
||||
class TestOAuthDataSourceBinding:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
@patch("controllers.console.auth.data_source_oauth.get_oauth_providers")
|
||||
def test_get_binding_successful(self, mock_get_providers, app):
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.get_access_token.return_value = None
|
||||
mock_get_providers.return_value = {"notion": mock_provider}
|
||||
|
||||
with app.test_request_context("/console/api/oauth/data-source/notion/binding?code=auth_code_123", method="GET"):
|
||||
api_instance = OAuthDataSourceBinding()
|
||||
response = api_instance.get("notion")
|
||||
|
||||
assert response[0]["result"] == "success"
|
||||
assert response[1] == 200
|
||||
mock_provider.get_access_token.assert_called_once_with("auth_code_123")
|
||||
|
||||
@patch("controllers.console.auth.data_source_oauth.get_oauth_providers")
|
||||
def test_get_binding_missing_code(self, mock_get_providers, app):
|
||||
mock_get_providers.return_value = {"notion": MagicMock()}
|
||||
|
||||
with app.test_request_context("/console/api/oauth/data-source/notion/binding?code=", method="GET"):
|
||||
api_instance = OAuthDataSourceBinding()
|
||||
response = api_instance.get("notion")
|
||||
|
||||
assert response[0]["error"] == "Invalid code"
|
||||
assert response[1] == 400
|
||||
|
||||
|
||||
class TestOAuthDataSourceSync:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
@patch("controllers.console.auth.data_source_oauth.get_oauth_providers")
|
||||
@patch("libs.login.check_csrf_token")
|
||||
@patch("controllers.console.wraps.db")
|
||||
def test_sync_successful(self, mock_db, mock_csrf, mock_get_providers, app):
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.sync_data_source.return_value = None
|
||||
mock_get_providers.return_value = {"notion": mock_provider}
|
||||
|
||||
from models.account import Account, AccountStatus
|
||||
|
||||
mock_account = MagicMock(spec=Account)
|
||||
mock_account.id = "user_123"
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_account.is_admin_or_owner = True
|
||||
mock_account.current_tenant.current_role = "owner"
|
||||
|
||||
with patch("controllers.console.wraps.current_account_with_tenant", return_value=(mock_account, MagicMock())):
|
||||
with app.test_request_context("/console/api/oauth/data-source/notion/binding_123/sync", method="GET"):
|
||||
proxy_mock = LocalProxy(lambda: mock_account)
|
||||
with patch("libs.login.current_user", proxy_mock):
|
||||
api_instance = OAuthDataSourceSync()
|
||||
# The route pattern uses <uuid:binding_id>, so we just pass a string for unit testing
|
||||
response = api_instance.get("notion", "binding_123")
|
||||
|
||||
assert response[0]["result"] == "success"
|
||||
assert response[1] == 200
|
||||
mock_provider.sync_data_source.assert_called_once_with("binding_123")
|
||||
@ -1,417 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.console.auth.oauth_server import (
|
||||
OAuthServerAppApi,
|
||||
OAuthServerUserAccountApi,
|
||||
OAuthServerUserAuthorizeApi,
|
||||
OAuthServerUserTokenApi,
|
||||
)
|
||||
|
||||
|
||||
class TestOAuthServerAppApi:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oauth_provider_app(self):
|
||||
from models.model import OAuthProviderApp
|
||||
|
||||
oauth_app = MagicMock(spec=OAuthProviderApp)
|
||||
oauth_app.client_id = "test_client_id"
|
||||
oauth_app.redirect_uris = ["http://localhost/callback"]
|
||||
oauth_app.app_icon = "icon_url"
|
||||
oauth_app.app_label = "Test App"
|
||||
oauth_app.scope = "read,write"
|
||||
return oauth_app
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_successful_post(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider",
|
||||
method="POST",
|
||||
json={"client_id": "test_client_id", "redirect_uri": "http://localhost/callback"},
|
||||
):
|
||||
api_instance = OAuthServerAppApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response["app_icon"] == "icon_url"
|
||||
assert response["app_label"] == "Test App"
|
||||
assert response["scope"] == "read,write"
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_invalid_redirect_uri(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider",
|
||||
method="POST",
|
||||
json={"client_id": "test_client_id", "redirect_uri": "http://invalid/callback"},
|
||||
):
|
||||
api_instance = OAuthServerAppApi()
|
||||
with pytest.raises(BadRequest, match="redirect_uri is invalid"):
|
||||
api_instance.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_invalid_client_id(self, mock_get_app, mock_db, app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = None
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider",
|
||||
method="POST",
|
||||
json={"client_id": "test_invalid_client_id", "redirect_uri": "http://localhost/callback"},
|
||||
):
|
||||
api_instance = OAuthServerAppApi()
|
||||
with pytest.raises(NotFound, match="client_id is invalid"):
|
||||
api_instance.post()
|
||||
|
||||
|
||||
class TestOAuthServerUserAuthorizeApi:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oauth_provider_app(self):
|
||||
oauth_app = MagicMock()
|
||||
oauth_app.client_id = "test_client_id"
|
||||
return oauth_app
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
@patch("controllers.console.auth.oauth_server.current_account_with_tenant")
|
||||
@patch("controllers.console.wraps.current_account_with_tenant")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.sign_oauth_authorization_code")
|
||||
@patch("libs.login.check_csrf_token")
|
||||
def test_successful_authorize(
|
||||
self, mock_csrf, mock_sign, mock_wrap_current, mock_current, mock_get_app, mock_db, app, mock_oauth_provider_app
|
||||
):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_account.id = "user_123"
|
||||
from models.account import AccountStatus
|
||||
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
|
||||
mock_current.return_value = (mock_account, MagicMock())
|
||||
mock_wrap_current.return_value = (mock_account, MagicMock())
|
||||
|
||||
mock_sign.return_value = "auth_code_123"
|
||||
|
||||
with app.test_request_context("/oauth/provider/authorize", method="POST", json={"client_id": "test_client_id"}):
|
||||
with patch("libs.login.current_user", mock_account):
|
||||
api_instance = OAuthServerUserAuthorizeApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response["code"] == "auth_code_123"
|
||||
mock_sign.assert_called_once_with("test_client_id", "user_123")
|
||||
|
||||
|
||||
class TestOAuthServerUserTokenApi:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oauth_provider_app(self):
|
||||
from models.model import OAuthProviderApp
|
||||
|
||||
oauth_app = MagicMock(spec=OAuthProviderApp)
|
||||
oauth_app.client_id = "test_client_id"
|
||||
oauth_app.client_secret = "test_secret"
|
||||
oauth_app.redirect_uris = ["http://localhost/callback"]
|
||||
return oauth_app
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.sign_oauth_access_token")
|
||||
def test_authorization_code_grant(self, mock_sign, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
mock_sign.return_value = ("access_123", "refresh_123")
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/token",
|
||||
method="POST",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "authorization_code",
|
||||
"code": "auth_code",
|
||||
"client_secret": "test_secret",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
):
|
||||
api_instance = OAuthServerUserTokenApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response["access_token"] == "access_123"
|
||||
assert response["refresh_token"] == "refresh_123"
|
||||
assert response["token_type"] == "Bearer"
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_authorization_code_grant_missing_code(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/token",
|
||||
method="POST",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "authorization_code",
|
||||
"client_secret": "test_secret",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
):
|
||||
api_instance = OAuthServerUserTokenApi()
|
||||
with pytest.raises(BadRequest, match="code is required"):
|
||||
api_instance.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_authorization_code_grant_invalid_secret(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/token",
|
||||
method="POST",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "authorization_code",
|
||||
"code": "auth_code",
|
||||
"client_secret": "invalid_secret",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
):
|
||||
api_instance = OAuthServerUserTokenApi()
|
||||
with pytest.raises(BadRequest, match="client_secret is invalid"):
|
||||
api_instance.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_authorization_code_grant_invalid_redirect_uri(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/token",
|
||||
method="POST",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "authorization_code",
|
||||
"code": "auth_code",
|
||||
"client_secret": "test_secret",
|
||||
"redirect_uri": "http://invalid/callback",
|
||||
},
|
||||
):
|
||||
api_instance = OAuthServerUserTokenApi()
|
||||
with pytest.raises(BadRequest, match="redirect_uri is invalid"):
|
||||
api_instance.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.sign_oauth_access_token")
|
||||
def test_refresh_token_grant(self, mock_sign, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
mock_sign.return_value = ("new_access", "new_refresh")
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/token",
|
||||
method="POST",
|
||||
json={"client_id": "test_client_id", "grant_type": "refresh_token", "refresh_token": "refresh_123"},
|
||||
):
|
||||
api_instance = OAuthServerUserTokenApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response["access_token"] == "new_access"
|
||||
assert response["refresh_token"] == "new_refresh"
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_refresh_token_grant_missing_token(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/token",
|
||||
method="POST",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "refresh_token",
|
||||
},
|
||||
):
|
||||
api_instance = OAuthServerUserTokenApi()
|
||||
with pytest.raises(BadRequest, match="refresh_token is required"):
|
||||
api_instance.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_invalid_grant_type(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/token",
|
||||
method="POST",
|
||||
json={
|
||||
"client_id": "test_client_id",
|
||||
"grant_type": "invalid_grant",
|
||||
},
|
||||
):
|
||||
api_instance = OAuthServerUserTokenApi()
|
||||
with pytest.raises(BadRequest, match="invalid grant_type"):
|
||||
api_instance.post()
|
||||
|
||||
|
||||
class TestOAuthServerUserAccountApi:
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oauth_provider_app(self):
|
||||
from models.model import OAuthProviderApp
|
||||
|
||||
oauth_app = MagicMock(spec=OAuthProviderApp)
|
||||
oauth_app.client_id = "test_client_id"
|
||||
return oauth_app
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.validate_oauth_access_token")
|
||||
def test_successful_account_retrieval(self, mock_validate, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_account.name = "Test User"
|
||||
mock_account.email = "test@example.com"
|
||||
mock_account.avatar = "avatar_url"
|
||||
mock_account.interface_language = "en-US"
|
||||
mock_account.timezone = "UTC"
|
||||
mock_validate.return_value = mock_account
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/account",
|
||||
method="POST",
|
||||
json={"client_id": "test_client_id"},
|
||||
headers={"Authorization": "Bearer valid_access_token"},
|
||||
):
|
||||
api_instance = OAuthServerUserAccountApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response["name"] == "Test User"
|
||||
assert response["email"] == "test@example.com"
|
||||
assert response["avatar"] == "avatar_url"
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_missing_authorization_header(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context("/oauth/provider/account", method="POST", json={"client_id": "test_client_id"}):
|
||||
api_instance = OAuthServerUserAccountApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json["error"] == "Authorization header is required"
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_invalid_authorization_header_format(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/account",
|
||||
method="POST",
|
||||
json={"client_id": "test_client_id"},
|
||||
headers={"Authorization": "InvalidFormat"},
|
||||
):
|
||||
api_instance = OAuthServerUserAccountApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json["error"] == "Invalid Authorization header format"
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_invalid_token_type(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/account",
|
||||
method="POST",
|
||||
json={"client_id": "test_client_id"},
|
||||
headers={"Authorization": "Basic something"},
|
||||
):
|
||||
api_instance = OAuthServerUserAccountApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json["error"] == "token_type is invalid"
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
def test_missing_access_token(self, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/account",
|
||||
method="POST",
|
||||
json={"client_id": "test_client_id"},
|
||||
headers={"Authorization": "Bearer "},
|
||||
):
|
||||
api_instance = OAuthServerUserAccountApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json["error"] == "Invalid Authorization header format"
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.get_oauth_provider_app")
|
||||
@patch("controllers.console.auth.oauth_server.OAuthServerService.validate_oauth_access_token")
|
||||
def test_invalid_access_token(self, mock_validate, mock_get_app, mock_db, app, mock_oauth_provider_app):
|
||||
mock_db.session.query.return_value.first.return_value = MagicMock()
|
||||
mock_get_app.return_value = mock_oauth_provider_app
|
||||
mock_validate.return_value = None
|
||||
|
||||
with app.test_request_context(
|
||||
"/oauth/provider/account",
|
||||
method="POST",
|
||||
json={"client_id": "test_client_id"},
|
||||
headers={"Authorization": "Bearer invalid_token"},
|
||||
):
|
||||
api_instance = OAuthServerUserAccountApi()
|
||||
response = api_instance.post()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json["error"] == "access_token or client_id is invalid"
|
||||
@ -8,6 +8,7 @@ from controllers.console.apikey import (
|
||||
BaseApiKeyResource,
|
||||
_get_resource,
|
||||
)
|
||||
from models.enums import ApiTokenType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -45,14 +46,14 @@ def bypass_permissions():
|
||||
|
||||
|
||||
class DummyApiKeyListResource(BaseApiKeyListResource):
|
||||
resource_type = "app"
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = MagicMock()
|
||||
resource_id_field = "app_id"
|
||||
token_prefix = "app-"
|
||||
|
||||
|
||||
class DummyApiKeyResource(BaseApiKeyResource):
|
||||
resource_type = "app"
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = MagicMock()
|
||||
resource_id_field = "app_id"
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.entities.task_entities import MessageEndStreamResponse
|
||||
from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline
|
||||
from dify_graph.file.enums import FileTransferMethod
|
||||
from dify_graph.file.enums import FileTransferMethod, FileType
|
||||
from models.model import MessageFile, UploadFile
|
||||
|
||||
|
||||
@ -51,7 +51,7 @@ class TestMessageEndStreamResponseFiles:
|
||||
message_file.transfer_method = FileTransferMethod.LOCAL_FILE
|
||||
message_file.upload_file_id = str(uuid.uuid4())
|
||||
message_file.url = None
|
||||
message_file.type = "image"
|
||||
message_file.type = FileType.IMAGE
|
||||
return message_file
|
||||
|
||||
@pytest.fixture
|
||||
@ -63,7 +63,7 @@ class TestMessageEndStreamResponseFiles:
|
||||
message_file.transfer_method = FileTransferMethod.REMOTE_URL
|
||||
message_file.upload_file_id = None
|
||||
message_file.url = "https://example.com/image.jpg"
|
||||
message_file.type = "image"
|
||||
message_file.type = FileType.IMAGE
|
||||
return message_file
|
||||
|
||||
@pytest.fixture
|
||||
@ -75,7 +75,7 @@ class TestMessageEndStreamResponseFiles:
|
||||
message_file.transfer_method = FileTransferMethod.TOOL_FILE
|
||||
message_file.upload_file_id = None
|
||||
message_file.url = "tool_file_123.png"
|
||||
message_file.type = "image"
|
||||
message_file.type = FileType.IMAGE
|
||||
return message_file
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -164,6 +164,13 @@ class TestFirecrawlApp:
|
||||
with pytest.raises(Exception, match="No page found"):
|
||||
app.check_crawl_status("job-1")
|
||||
|
||||
def test_check_crawl_status_completed_with_null_total_raises(self, mocker: MockerFixture):
|
||||
app = FirecrawlApp(api_key="fc-key", base_url="https://custom.firecrawl.dev")
|
||||
mocker.patch("httpx.get", return_value=_response(200, {"status": "completed", "total": None, "data": []}))
|
||||
|
||||
with pytest.raises(Exception, match="No page found"):
|
||||
app.check_crawl_status("job-1")
|
||||
|
||||
def test_check_crawl_status_non_completed(self, mocker: MockerFixture):
|
||||
app = FirecrawlApp(api_key="fc-key", base_url="https://custom.firecrawl.dev")
|
||||
payload = {"status": "processing", "total": 5, "completed": 1, "data": []}
|
||||
@ -203,6 +210,77 @@ class TestFirecrawlApp:
|
||||
with pytest.raises(Exception, match="Error saving crawl data"):
|
||||
app.check_crawl_status("job-err")
|
||||
|
||||
def test_check_crawl_status_follows_pagination(self, mocker: MockerFixture):
|
||||
"""When status is completed and next is present, follow pagination to collect all pages."""
|
||||
app = FirecrawlApp(api_key="fc-key", base_url="https://custom.firecrawl.dev")
|
||||
page1 = {
|
||||
"status": "completed",
|
||||
"total": 3,
|
||||
"completed": 3,
|
||||
"next": "https://custom.firecrawl.dev/v2/crawl/job-42?skip=1",
|
||||
"data": [{"metadata": {"title": "p1", "description": "", "sourceURL": "https://p1"}, "markdown": "m1"}],
|
||||
}
|
||||
page2 = {
|
||||
"status": "completed",
|
||||
"total": 3,
|
||||
"completed": 3,
|
||||
"next": "https://custom.firecrawl.dev/v2/crawl/job-42?skip=2",
|
||||
"data": [{"metadata": {"title": "p2", "description": "", "sourceURL": "https://p2"}, "markdown": "m2"}],
|
||||
}
|
||||
page3 = {
|
||||
"status": "completed",
|
||||
"total": 3,
|
||||
"completed": 3,
|
||||
"data": [{"metadata": {"title": "p3", "description": "", "sourceURL": "https://p3"}, "markdown": "m3"}],
|
||||
}
|
||||
mocker.patch("httpx.get", side_effect=[_response(200, page1), _response(200, page2), _response(200, page3)])
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.exists.return_value = False
|
||||
mocker.patch.object(firecrawl_module, "storage", mock_storage)
|
||||
|
||||
result = app.check_crawl_status("job-42")
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["total"] == 3
|
||||
assert len(result["data"]) == 3
|
||||
assert [d["title"] for d in result["data"]] == ["p1", "p2", "p3"]
|
||||
|
||||
def test_check_crawl_status_pagination_error_raises(self, mocker: MockerFixture):
|
||||
"""An error while fetching a paginated page raises an exception; no partial data is returned."""
|
||||
app = FirecrawlApp(api_key="fc-key", base_url="https://custom.firecrawl.dev")
|
||||
page1 = {
|
||||
"status": "completed",
|
||||
"total": 2,
|
||||
"completed": 2,
|
||||
"next": "https://custom.firecrawl.dev/v2/crawl/job-99?skip=1",
|
||||
"data": [{"metadata": {"title": "p1", "description": "", "sourceURL": "https://p1"}, "markdown": "m1"}],
|
||||
}
|
||||
mocker.patch("httpx.get", side_effect=[_response(200, page1), _response(500, {"error": "server error"})])
|
||||
|
||||
with pytest.raises(Exception, match="fetch next crawl page"):
|
||||
app.check_crawl_status("job-99")
|
||||
|
||||
def test_check_crawl_status_pagination_capped_at_total(self, mocker: MockerFixture):
|
||||
"""Pagination stops once pages_processed reaches total, even if next is present."""
|
||||
app = FirecrawlApp(api_key="fc-key", base_url="https://custom.firecrawl.dev")
|
||||
# total=1: only the first page should be processed; next must not be followed
|
||||
page1 = {
|
||||
"status": "completed",
|
||||
"total": 1,
|
||||
"completed": 1,
|
||||
"next": "https://custom.firecrawl.dev/v2/crawl/job-cap?skip=1",
|
||||
"data": [{"metadata": {"title": "p1", "description": "", "sourceURL": "https://p1"}, "markdown": "m1"}],
|
||||
}
|
||||
mock_get = mocker.patch("httpx.get", return_value=_response(200, page1))
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.exists.return_value = False
|
||||
mocker.patch.object(firecrawl_module, "storage", mock_storage)
|
||||
|
||||
result = app.check_crawl_status("job-cap")
|
||||
|
||||
assert len(result["data"]) == 1
|
||||
mock_get.assert_called_once() # initial fetch only; next URL is not followed due to cap
|
||||
|
||||
def test_extract_common_fields_and_status_formatter(self):
|
||||
app = FirecrawlApp(api_key="fc-key", base_url="https://custom.firecrawl.dev")
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from models.account import Account
|
||||
from models.dataset import ChildChunk, Dataset, Document, DocumentSegment
|
||||
from models.enums import SegmentType
|
||||
from services.dataset_service import SegmentService
|
||||
from services.entities.knowledge_entities.knowledge_entities import SegmentUpdateArgs
|
||||
from services.errors.chunk import ChildChunkDeleteIndexError, ChildChunkIndexingError
|
||||
@ -77,7 +78,7 @@ class SegmentTestDataFactory:
|
||||
chunk.word_count = word_count
|
||||
chunk.index_node_id = f"node-{chunk_id}"
|
||||
chunk.index_node_hash = "hash-123"
|
||||
chunk.type = "automatic"
|
||||
chunk.type = SegmentType.AUTOMATIC
|
||||
chunk.created_by = "user-123"
|
||||
chunk.updated_by = None
|
||||
chunk.updated_at = None
|
||||
|
||||
@ -1,421 +0,0 @@
|
||||
"""
|
||||
Comprehensive unit tests for services/api_based_extension_service.py
|
||||
|
||||
Covers:
|
||||
- APIBasedExtensionService.get_all_by_tenant_id
|
||||
- APIBasedExtensionService.save
|
||||
- APIBasedExtensionService.delete
|
||||
- APIBasedExtensionService.get_with_tenant_id
|
||||
- APIBasedExtensionService._validation (new record & existing record branches)
|
||||
- APIBasedExtensionService._ping_connection (pong success, wrong response, exception)
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.api_based_extension_service import APIBasedExtensionService
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_extension(
|
||||
*,
|
||||
id_: str | None = None,
|
||||
tenant_id: str = "tenant-001",
|
||||
name: str = "my-ext",
|
||||
api_endpoint: str = "https://example.com/hook",
|
||||
api_key: str = "secret-key-123",
|
||||
) -> MagicMock:
|
||||
"""Return a lightweight mock that mimics APIBasedExtension."""
|
||||
ext = MagicMock()
|
||||
ext.id = id_
|
||||
ext.tenant_id = tenant_id
|
||||
ext.name = name
|
||||
ext.api_endpoint = api_endpoint
|
||||
ext.api_key = api_key
|
||||
return ext
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: get_all_by_tenant_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetAllByTenantId:
|
||||
"""Tests for APIBasedExtensionService.get_all_by_tenant_id."""
|
||||
|
||||
@patch("services.api_based_extension_service.decrypt_token", return_value="decrypted-key")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_returns_extensions_with_decrypted_keys(self, mock_db, mock_decrypt):
|
||||
"""Each api_key is decrypted and the list is returned."""
|
||||
ext1 = _make_extension(id_="id-1", api_key="enc-key-1")
|
||||
ext2 = _make_extension(id_="id-2", api_key="enc-key-2")
|
||||
|
||||
mock_db.session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = [
|
||||
ext1,
|
||||
ext2,
|
||||
]
|
||||
|
||||
result = APIBasedExtensionService.get_all_by_tenant_id("tenant-001")
|
||||
|
||||
assert result == [ext1, ext2]
|
||||
assert ext1.api_key == "decrypted-key"
|
||||
assert ext2.api_key == "decrypted-key"
|
||||
assert mock_decrypt.call_count == 2
|
||||
|
||||
@patch("services.api_based_extension_service.decrypt_token", return_value="decrypted-key")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_returns_empty_list_when_no_extensions(self, mock_db, mock_decrypt):
|
||||
"""Returns an empty list gracefully when no records exist."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = []
|
||||
|
||||
result = APIBasedExtensionService.get_all_by_tenant_id("tenant-001")
|
||||
|
||||
assert result == []
|
||||
mock_decrypt.assert_not_called()
|
||||
|
||||
@patch("services.api_based_extension_service.decrypt_token", return_value="decrypted-key")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_calls_query_with_correct_tenant_id(self, mock_db, mock_decrypt):
|
||||
"""Verifies the DB is queried with the supplied tenant_id."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = []
|
||||
|
||||
APIBasedExtensionService.get_all_by_tenant_id("tenant-xyz")
|
||||
|
||||
mock_db.session.query.return_value.filter_by.assert_called_once_with(tenant_id="tenant-xyz")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: save
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSave:
|
||||
"""Tests for APIBasedExtensionService.save."""
|
||||
|
||||
@patch("services.api_based_extension_service.encrypt_token", return_value="encrypted-key")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
@patch.object(APIBasedExtensionService, "_validation")
|
||||
def test_save_new_record_encrypts_key_and_commits(self, mock_validation, mock_db, mock_encrypt):
|
||||
"""Happy path: validation passes, key is encrypted, record is added and committed."""
|
||||
ext = _make_extension(id_=None, api_key="plain-key-123")
|
||||
|
||||
result = APIBasedExtensionService.save(ext)
|
||||
|
||||
mock_validation.assert_called_once_with(ext)
|
||||
mock_encrypt.assert_called_once_with(ext.tenant_id, "plain-key-123")
|
||||
assert ext.api_key == "encrypted-key"
|
||||
mock_db.session.add.assert_called_once_with(ext)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
assert result is ext
|
||||
|
||||
@patch("services.api_based_extension_service.encrypt_token", return_value="encrypted-key")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
@patch.object(APIBasedExtensionService, "_validation", side_effect=ValueError("name must not be empty"))
|
||||
def test_save_raises_when_validation_fails(self, mock_validation, mock_db, mock_encrypt):
|
||||
"""If _validation raises, save should propagate the error without touching the DB."""
|
||||
ext = _make_extension(name="")
|
||||
|
||||
with pytest.raises(ValueError, match="name must not be empty"):
|
||||
APIBasedExtensionService.save(ext)
|
||||
|
||||
mock_db.session.add.assert_not_called()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDelete:
|
||||
"""Tests for APIBasedExtensionService.delete."""
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_delete_removes_record_and_commits(self, mock_db):
|
||||
"""delete() must call session.delete with the extension and then commit."""
|
||||
ext = _make_extension(id_="delete-me")
|
||||
|
||||
APIBasedExtensionService.delete(ext)
|
||||
|
||||
mock_db.session.delete.assert_called_once_with(ext)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: get_with_tenant_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetWithTenantId:
|
||||
"""Tests for APIBasedExtensionService.get_with_tenant_id."""
|
||||
|
||||
@patch("services.api_based_extension_service.decrypt_token", return_value="decrypted-key")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_returns_extension_with_decrypted_key(self, mock_db, mock_decrypt):
|
||||
"""Found extension has its api_key decrypted before being returned."""
|
||||
ext = _make_extension(id_="ext-123", api_key="enc-key")
|
||||
|
||||
(mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value) = ext
|
||||
|
||||
result = APIBasedExtensionService.get_with_tenant_id("tenant-001", "ext-123")
|
||||
|
||||
assert result is ext
|
||||
assert ext.api_key == "decrypted-key"
|
||||
mock_decrypt.assert_called_once_with(ext.tenant_id, "enc-key")
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_value_error_when_not_found(self, mock_db):
|
||||
"""Raises ValueError when no matching extension exists."""
|
||||
(mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value) = None
|
||||
|
||||
with pytest.raises(ValueError, match="API based extension is not found"):
|
||||
APIBasedExtensionService.get_with_tenant_id("tenant-001", "non-existent")
|
||||
|
||||
@patch("services.api_based_extension_service.decrypt_token", return_value="decrypted-key")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_queries_with_correct_tenant_and_extension_id(self, mock_db, mock_decrypt):
|
||||
"""Verifies both tenant_id and extension id are used in the query."""
|
||||
ext = _make_extension(id_="ext-abc")
|
||||
chain = mock_db.session.query.return_value
|
||||
chain.filter_by.return_value.filter_by.return_value.first.return_value = ext
|
||||
|
||||
APIBasedExtensionService.get_with_tenant_id("tenant-002", "ext-abc")
|
||||
|
||||
# First filter_by call uses tenant_id
|
||||
chain.filter_by.assert_called_once_with(tenant_id="tenant-002")
|
||||
# Second filter_by call uses id
|
||||
chain.filter_by.return_value.filter_by.assert_called_once_with(id="ext-abc")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _validation (new record — id is falsy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidationNewRecord:
|
||||
"""Tests for _validation() with a brand-new record (no id)."""
|
||||
|
||||
def _build_mock_db(self, name_exists: bool = False):
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = (
|
||||
MagicMock() if name_exists else None
|
||||
)
|
||||
return mock_db
|
||||
|
||||
@patch.object(APIBasedExtensionService, "_ping_connection")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_valid_new_extension_passes(self, mock_db, mock_ping):
|
||||
"""A new record with all valid fields should pass without exceptions."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = None
|
||||
ext = _make_extension(id_=None, name="valid-ext", api_key="longenoughkey")
|
||||
|
||||
# Should not raise
|
||||
APIBasedExtensionService._validation(ext)
|
||||
mock_ping.assert_called_once_with(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_name_is_empty(self, mock_db):
|
||||
"""Empty name raises ValueError."""
|
||||
ext = _make_extension(id_=None, name="")
|
||||
with pytest.raises(ValueError, match="name must not be empty"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_name_is_none(self, mock_db):
|
||||
"""None name raises ValueError."""
|
||||
ext = _make_extension(id_=None, name=None)
|
||||
with pytest.raises(ValueError, match="name must not be empty"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_name_already_exists_for_new_record(self, mock_db):
|
||||
"""A new record whose name already exists raises ValueError."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = (
|
||||
MagicMock()
|
||||
)
|
||||
ext = _make_extension(id_=None, name="duplicate-name")
|
||||
|
||||
with pytest.raises(ValueError, match="name must be unique, it is already existed"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_api_endpoint_is_empty(self, mock_db):
|
||||
"""Empty api_endpoint raises ValueError."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = None
|
||||
ext = _make_extension(id_=None, api_endpoint="")
|
||||
|
||||
with pytest.raises(ValueError, match="api_endpoint must not be empty"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_api_endpoint_is_none(self, mock_db):
|
||||
"""None api_endpoint raises ValueError."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = None
|
||||
ext = _make_extension(id_=None, api_endpoint=None)
|
||||
|
||||
with pytest.raises(ValueError, match="api_endpoint must not be empty"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_api_key_is_empty(self, mock_db):
|
||||
"""Empty api_key raises ValueError."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = None
|
||||
ext = _make_extension(id_=None, api_key="")
|
||||
|
||||
with pytest.raises(ValueError, match="api_key must not be empty"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_api_key_is_none(self, mock_db):
|
||||
"""None api_key raises ValueError."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = None
|
||||
ext = _make_extension(id_=None, api_key=None)
|
||||
|
||||
with pytest.raises(ValueError, match="api_key must not be empty"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_api_key_too_short(self, mock_db):
|
||||
"""api_key shorter than 5 characters raises ValueError."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = None
|
||||
ext = _make_extension(id_=None, api_key="abc")
|
||||
|
||||
with pytest.raises(ValueError, match="api_key must be at least 5 characters"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_api_key_exactly_four_chars(self, mock_db):
|
||||
"""api_key with exactly 4 characters raises ValueError (boundary condition)."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = None
|
||||
ext = _make_extension(id_=None, api_key="1234")
|
||||
|
||||
with pytest.raises(ValueError, match="api_key must be at least 5 characters"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
@patch.object(APIBasedExtensionService, "_ping_connection")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_api_key_exactly_five_chars_is_accepted(self, mock_db, mock_ping):
|
||||
"""api_key with exactly 5 characters should pass (boundary condition)."""
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.first.return_value = None
|
||||
ext = _make_extension(id_=None, api_key="12345")
|
||||
|
||||
# Should not raise
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _validation (existing record — id is truthy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidationExistingRecord:
|
||||
"""Tests for _validation() with an existing record (id is set)."""
|
||||
|
||||
@patch.object(APIBasedExtensionService, "_ping_connection")
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_valid_existing_extension_passes(self, mock_db, mock_ping):
|
||||
"""An existing record whose name is unique (excluding self) should pass."""
|
||||
# .where(...).first() → None means no *other* record has that name
|
||||
(
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.where.return_value.first.return_value
|
||||
) = None
|
||||
ext = _make_extension(id_="existing-id", name="unique-name", api_key="longenoughkey")
|
||||
|
||||
# Should not raise
|
||||
APIBasedExtensionService._validation(ext)
|
||||
mock_ping.assert_called_once_with(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.db")
|
||||
def test_raises_if_existing_record_name_conflicts_with_another(self, mock_db):
|
||||
"""Existing record cannot use a name already owned by a different record."""
|
||||
(
|
||||
mock_db.session.query.return_value.filter_by.return_value.filter_by.return_value.where.return_value.first.return_value
|
||||
) = MagicMock()
|
||||
ext = _make_extension(id_="existing-id", name="taken-name")
|
||||
|
||||
with pytest.raises(ValueError, match="name must be unique, it is already existed"):
|
||||
APIBasedExtensionService._validation(ext)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _ping_connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPingConnection:
|
||||
"""Tests for APIBasedExtensionService._ping_connection."""
|
||||
|
||||
@patch("services.api_based_extension_service.APIBasedExtensionRequestor")
|
||||
def test_successful_ping_returns_pong(self, mock_requestor_class):
|
||||
"""When the endpoint returns {"result": "pong"}, no exception is raised."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.request.return_value = {"result": "pong"}
|
||||
mock_requestor_class.return_value = mock_client
|
||||
|
||||
ext = _make_extension(api_endpoint="https://ok.example.com", api_key="secret-key")
|
||||
# Should not raise
|
||||
APIBasedExtensionService._ping_connection(ext)
|
||||
|
||||
mock_requestor_class.assert_called_once_with(ext.api_endpoint, ext.api_key)
|
||||
|
||||
@patch("services.api_based_extension_service.APIBasedExtensionRequestor")
|
||||
def test_wrong_ping_response_raises_value_error(self, mock_requestor_class):
|
||||
"""When the response is not {"result": "pong"}, a ValueError is raised."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.request.return_value = {"result": "error"}
|
||||
mock_requestor_class.return_value = mock_client
|
||||
|
||||
ext = _make_extension()
|
||||
with pytest.raises(ValueError, match="connection error"):
|
||||
APIBasedExtensionService._ping_connection(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.APIBasedExtensionRequestor")
|
||||
def test_network_exception_wraps_in_value_error(self, mock_requestor_class):
|
||||
"""Any exception raised during request is wrapped in a ValueError."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.request.side_effect = ConnectionError("network failure")
|
||||
mock_requestor_class.return_value = mock_client
|
||||
|
||||
ext = _make_extension()
|
||||
with pytest.raises(ValueError, match="connection error: network failure"):
|
||||
APIBasedExtensionService._ping_connection(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.APIBasedExtensionRequestor")
|
||||
def test_requestor_constructor_exception_wraps_in_value_error(self, mock_requestor_class):
|
||||
"""Exception raised by the requestor constructor itself is wrapped."""
|
||||
mock_requestor_class.side_effect = RuntimeError("bad config")
|
||||
|
||||
ext = _make_extension()
|
||||
with pytest.raises(ValueError, match="connection error: bad config"):
|
||||
APIBasedExtensionService._ping_connection(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.APIBasedExtensionRequestor")
|
||||
def test_missing_result_key_raises_value_error(self, mock_requestor_class):
|
||||
"""A response dict without a 'result' key does not equal 'pong' → raises."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.request.return_value = {} # no 'result' key
|
||||
mock_requestor_class.return_value = mock_client
|
||||
|
||||
ext = _make_extension()
|
||||
with pytest.raises(ValueError, match="connection error"):
|
||||
APIBasedExtensionService._ping_connection(ext)
|
||||
|
||||
@patch("services.api_based_extension_service.APIBasedExtensionRequestor")
|
||||
def test_uses_ping_extension_point(self, mock_requestor_class):
|
||||
"""The PING extension point is passed to the client.request call."""
|
||||
from models.api_based_extension import APIBasedExtensionPoint
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.request.return_value = {"result": "pong"}
|
||||
mock_requestor_class.return_value = mock_client
|
||||
|
||||
ext = _make_extension()
|
||||
APIBasedExtensionService._ping_connection(ext)
|
||||
|
||||
call_kwargs = mock_client.request.call_args
|
||||
assert call_kwargs.kwargs["point"] == APIBasedExtensionPoint.PING
|
||||
assert call_kwargs.kwargs["params"] == {}
|
||||
@ -1,100 +0,0 @@
|
||||
import datetime
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.dataset import Dataset, Document
|
||||
from services.dataset_service import DocumentService
|
||||
from tests.unit_tests.conftest import redis_mock
|
||||
|
||||
|
||||
class DocumentBatchUpdateTestDataFactory:
|
||||
"""Factory class for creating test data and mock objects for document batch update tests."""
|
||||
|
||||
@staticmethod
|
||||
def create_dataset_mock(dataset_id: str = "dataset-123", tenant_id: str = "tenant-456") -> Mock:
|
||||
"""Create a mock dataset with specified attributes."""
|
||||
dataset = Mock(spec=Dataset)
|
||||
dataset.id = dataset_id
|
||||
dataset.tenant_id = tenant_id
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def create_user_mock(user_id: str = "user-789") -> Mock:
|
||||
"""Create a mock user."""
|
||||
user = Mock()
|
||||
user.id = user_id
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_document_mock(
|
||||
document_id: str = "doc-1",
|
||||
name: str = "test_document.pdf",
|
||||
enabled: bool = True,
|
||||
archived: bool = False,
|
||||
indexing_status: str = "completed",
|
||||
completed_at: datetime.datetime | None = None,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""Create a mock document with specified attributes."""
|
||||
document = Mock(spec=Document)
|
||||
document.id = document_id
|
||||
document.name = name
|
||||
document.enabled = enabled
|
||||
document.archived = archived
|
||||
document.indexing_status = indexing_status
|
||||
document.completed_at = completed_at or datetime.datetime.now()
|
||||
|
||||
document.disabled_at = None
|
||||
document.disabled_by = None
|
||||
document.archived_at = None
|
||||
document.archived_by = None
|
||||
document.updated_at = None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(document, key, value)
|
||||
return document
|
||||
|
||||
|
||||
class TestDatasetServiceBatchUpdateDocumentStatus:
|
||||
"""Unit tests for non-SQL path in DocumentService.batch_update_document_status."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_document_service_dependencies(self):
|
||||
"""Common mock setup for document service dependencies."""
|
||||
with (
|
||||
patch("services.dataset_service.DocumentService.get_document") as mock_get_doc,
|
||||
patch("extensions.ext_database.db.session") as mock_db,
|
||||
patch("services.dataset_service.naive_utc_now") as mock_naive_utc_now,
|
||||
):
|
||||
current_time = datetime.datetime(2023, 1, 1, 12, 0, 0)
|
||||
mock_naive_utc_now.return_value = current_time
|
||||
|
||||
yield {
|
||||
"get_document": mock_get_doc,
|
||||
"db_session": mock_db,
|
||||
"naive_utc_now": mock_naive_utc_now,
|
||||
"current_time": current_time,
|
||||
}
|
||||
|
||||
def test_batch_update_invalid_action_error(self, mock_document_service_dependencies):
|
||||
"""Test that ValueError is raised when an invalid action is provided."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
|
||||
mock_document_service_dependencies["get_document"].return_value = doc
|
||||
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
invalid_action = "invalid_action"
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action=invalid_action, user=user
|
||||
)
|
||||
|
||||
assert invalid_action in str(exc_info.value)
|
||||
assert "Invalid action" in str(exc_info.value)
|
||||
|
||||
redis_mock.setex.assert_not_called()
|
||||
@ -1,50 +0,0 @@
|
||||
"""Unit tests for non-SQL validation paths in DatasetService dataset creation."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from services.dataset_service import DatasetService
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, RagPipelineDatasetCreateEntity
|
||||
|
||||
|
||||
class TestDatasetServiceCreateRagPipelineDatasetNonSQL:
|
||||
"""Unit coverage for non-SQL validation in create_empty_rag_pipeline_dataset."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rag_pipeline_dependencies(self):
|
||||
"""Patch database session and current_user for validation-only unit coverage."""
|
||||
with (
|
||||
patch("services.dataset_service.db.session") as mock_db,
|
||||
patch("services.dataset_service.current_user") as mock_current_user,
|
||||
):
|
||||
yield {
|
||||
"db_session": mock_db,
|
||||
"current_user_mock": mock_current_user,
|
||||
}
|
||||
|
||||
def test_create_rag_pipeline_dataset_missing_current_user_error(self, mock_rag_pipeline_dependencies):
|
||||
"""Raise ValueError when current_user.id is unavailable before SQL persistence."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid4())
|
||||
mock_rag_pipeline_dependencies["current_user_mock"].id = None
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.filter_by.return_value.first.return_value = None
|
||||
mock_rag_pipeline_dependencies["db_session"].query.return_value = mock_query
|
||||
|
||||
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
|
||||
entity = RagPipelineDatasetCreateEntity(
|
||||
name="Test Dataset",
|
||||
description="",
|
||||
icon_info=icon_info,
|
||||
permission="only_me",
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="Current user or current user id not found"):
|
||||
DatasetService.create_empty_rag_pipeline_dataset(
|
||||
tenant_id=tenant_id,
|
||||
rag_pipeline_dataset_create_entity=entity,
|
||||
)
|
||||
@ -1,57 +0,0 @@
|
||||
"""
|
||||
Unit tests for archived workflow run deletion service.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestArchivedWorkflowRunDeletion:
|
||||
def test_delete_by_run_id_calls_delete_run(self):
|
||||
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
|
||||
|
||||
deleter = ArchivedWorkflowRunDeletion()
|
||||
repo = MagicMock()
|
||||
repo.get_archived_run_ids.return_value = {"run-1"}
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.tenant_id = "tenant-1"
|
||||
|
||||
session = MagicMock()
|
||||
session.get.return_value = run
|
||||
|
||||
session_maker = MagicMock()
|
||||
session_maker.return_value.__enter__.return_value = session
|
||||
session_maker.return_value.__exit__.return_value = None
|
||||
mock_db = MagicMock()
|
||||
mock_db.engine = MagicMock()
|
||||
|
||||
with (
|
||||
patch("services.retention.workflow_run.delete_archived_workflow_run.db", mock_db),
|
||||
patch(
|
||||
"services.retention.workflow_run.delete_archived_workflow_run.sessionmaker",
|
||||
return_value=session_maker,
|
||||
autospec=True,
|
||||
),
|
||||
patch.object(deleter, "_get_workflow_run_repo", return_value=repo, autospec=True),
|
||||
patch.object(
|
||||
deleter, "_delete_run", return_value=MagicMock(success=True), autospec=True
|
||||
) as mock_delete_run,
|
||||
):
|
||||
result = deleter.delete_by_run_id("run-1")
|
||||
|
||||
assert result.success is True
|
||||
mock_delete_run.assert_called_once_with(run)
|
||||
|
||||
def test_delete_run_dry_run(self):
|
||||
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
|
||||
|
||||
deleter = ArchivedWorkflowRunDeletion(dry_run=True)
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.tenant_id = "tenant-1"
|
||||
|
||||
with patch.object(deleter, "_get_workflow_run_repo", autospec=True) as mock_get_repo:
|
||||
result = deleter._delete_run(run)
|
||||
|
||||
assert result.success is True
|
||||
mock_get_repo.assert_not_called()
|
||||
@ -1,8 +0,0 @@
|
||||
from services.dataset_service import DocumentService
|
||||
|
||||
|
||||
def test_normalize_display_status_alias_mapping():
|
||||
assert DocumentService.normalize_display_status("ACTIVE") == "available"
|
||||
assert DocumentService.normalize_display_status("enabled") == "available"
|
||||
assert DocumentService.normalize_display_status("archived") == "archived"
|
||||
assert DocumentService.normalize_display_status("unknown") is None
|
||||
@ -1,841 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.model import App, DefaultEndUserSessionID, EndUser
|
||||
from services.end_user_service import EndUserService
|
||||
|
||||
|
||||
class TestEndUserServiceFactory:
|
||||
"""Factory class for creating test data and mock objects for end user service tests."""
|
||||
|
||||
@staticmethod
|
||||
def create_app_mock(
|
||||
app_id: str = "app-123",
|
||||
tenant_id: str = "tenant-456",
|
||||
name: str = "Test App",
|
||||
) -> MagicMock:
|
||||
"""Create a mock App object."""
|
||||
app = MagicMock(spec=App)
|
||||
app.id = app_id
|
||||
app.tenant_id = tenant_id
|
||||
app.name = name
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def create_end_user_mock(
|
||||
user_id: str = "user-789",
|
||||
tenant_id: str = "tenant-456",
|
||||
app_id: str = "app-123",
|
||||
session_id: str = "session-001",
|
||||
type: InvokeFrom = InvokeFrom.SERVICE_API,
|
||||
is_anonymous: bool = False,
|
||||
) -> MagicMock:
|
||||
"""Create a mock EndUser object."""
|
||||
end_user = MagicMock(spec=EndUser)
|
||||
end_user.id = user_id
|
||||
end_user.tenant_id = tenant_id
|
||||
end_user.app_id = app_id
|
||||
end_user.session_id = session_id
|
||||
end_user.type = type
|
||||
end_user.is_anonymous = is_anonymous
|
||||
end_user.external_user_id = session_id
|
||||
return end_user
|
||||
|
||||
|
||||
class TestEndUserServiceGetEndUserById:
|
||||
"""Unit tests for EndUserService.get_end_user_by_id method."""
|
||||
|
||||
@pytest.fixture
|
||||
def factory(self):
|
||||
"""Provide test data factory."""
|
||||
return TestEndUserServiceFactory()
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_get_end_user_by_id_success(self, mock_db, mock_session_class, factory):
|
||||
"""Test successful retrieval of end user by ID."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
end_user_id = "user-789"
|
||||
|
||||
mock_end_user = factory.create_end_user_mock(user_id=end_user_id, tenant_id=tenant_id, app_id=app_id)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = mock_end_user
|
||||
|
||||
# Act
|
||||
result = EndUserService.get_end_user_by_id(tenant_id=tenant_id, app_id=app_id, end_user_id=end_user_id)
|
||||
|
||||
# Assert
|
||||
assert result == mock_end_user
|
||||
mock_session.query.assert_called_once_with(EndUser)
|
||||
mock_query.where.assert_called_once()
|
||||
mock_query.first.assert_called_once()
|
||||
mock_context.__enter__.assert_called_once()
|
||||
mock_context.__exit__.assert_called_once()
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_get_end_user_by_id_not_found(self, mock_db, mock_session_class):
|
||||
"""Test retrieval of non-existent end user returns None."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
end_user_id = "user-789"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Act
|
||||
result = EndUserService.get_end_user_by_id(tenant_id=tenant_id, app_id=app_id, end_user_id=end_user_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_get_end_user_by_id_query_parameters(self, mock_db, mock_session_class):
|
||||
"""Test that query parameters are correctly applied."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
end_user_id = "user-789"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Act
|
||||
EndUserService.get_end_user_by_id(tenant_id=tenant_id, app_id=app_id, end_user_id=end_user_id)
|
||||
|
||||
# Assert
|
||||
# Verify the where clause was called with the correct conditions
|
||||
call_args = mock_query.where.call_args[0]
|
||||
assert len(call_args) == 3
|
||||
# Check that the conditions match the expected filters
|
||||
# (We can't easily test the exact conditions without importing SQLAlchemy)
|
||||
|
||||
|
||||
class TestEndUserServiceGetOrCreateEndUser:
|
||||
"""Unit tests for EndUserService.get_or_create_end_user method."""
|
||||
|
||||
@pytest.fixture
|
||||
def factory(self):
|
||||
"""Provide test data factory."""
|
||||
return TestEndUserServiceFactory()
|
||||
|
||||
@patch("services.end_user_service.EndUserService.get_or_create_end_user_by_type")
|
||||
def test_get_or_create_end_user_with_user_id(self, mock_get_or_create_by_type, factory):
|
||||
"""Test get_or_create_end_user with specific user_id."""
|
||||
# Arrange
|
||||
app_mock = factory.create_app_mock()
|
||||
user_id = "user-123"
|
||||
expected_end_user = factory.create_end_user_mock()
|
||||
mock_get_or_create_by_type.return_value = expected_end_user
|
||||
|
||||
# Act
|
||||
result = EndUserService.get_or_create_end_user(app_mock, user_id)
|
||||
|
||||
# Assert
|
||||
assert result == expected_end_user
|
||||
mock_get_or_create_by_type.assert_called_once_with(
|
||||
InvokeFrom.SERVICE_API, app_mock.tenant_id, app_mock.id, user_id
|
||||
)
|
||||
|
||||
@patch("services.end_user_service.EndUserService.get_or_create_end_user_by_type")
|
||||
def test_get_or_create_end_user_without_user_id(self, mock_get_or_create_by_type, factory):
|
||||
"""Test get_or_create_end_user without user_id (None)."""
|
||||
# Arrange
|
||||
app_mock = factory.create_app_mock()
|
||||
expected_end_user = factory.create_end_user_mock()
|
||||
mock_get_or_create_by_type.return_value = expected_end_user
|
||||
|
||||
# Act
|
||||
result = EndUserService.get_or_create_end_user(app_mock, None)
|
||||
|
||||
# Assert
|
||||
assert result == expected_end_user
|
||||
mock_get_or_create_by_type.assert_called_once_with(
|
||||
InvokeFrom.SERVICE_API, app_mock.tenant_id, app_mock.id, None
|
||||
)
|
||||
|
||||
|
||||
class TestEndUserServiceGetOrCreateEndUserByType:
|
||||
"""
|
||||
Unit tests for EndUserService.get_or_create_end_user_by_type method.
|
||||
|
||||
This test suite covers:
|
||||
- Creating end users with different InvokeFrom types
|
||||
- Type migration for legacy users
|
||||
- Query ordering and prioritization
|
||||
- Session management
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def factory(self):
|
||||
"""Provide test data factory."""
|
||||
return TestEndUserServiceFactory()
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_new_end_user_with_user_id(self, mock_db, mock_session_class, factory):
|
||||
"""Test creating a new end user with specific user_id."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.first.return_value = None # No existing user
|
||||
|
||||
# Act
|
||||
result = EndUserService.get_or_create_end_user_by_type(
|
||||
type=type_enum, tenant_id=tenant_id, app_id=app_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Verify new EndUser was created with correct parameters
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
added_user = mock_session.add.call_args[0][0]
|
||||
assert added_user.tenant_id == tenant_id
|
||||
assert added_user.app_id == app_id
|
||||
assert added_user.type == type_enum
|
||||
assert added_user.session_id == user_id
|
||||
assert added_user.external_user_id == user_id
|
||||
assert added_user._is_anonymous is False
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_new_end_user_default_session(self, mock_db, mock_session_class, factory):
|
||||
"""Test creating a new end user with default session ID."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
user_id = None
|
||||
type_enum = InvokeFrom.WEB_APP
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.first.return_value = None # No existing user
|
||||
|
||||
# Act
|
||||
result = EndUserService.get_or_create_end_user_by_type(
|
||||
type=type_enum, tenant_id=tenant_id, app_id=app_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
added_user = mock_session.add.call_args[0][0]
|
||||
assert added_user.session_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID
|
||||
assert added_user.external_user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID
|
||||
assert added_user._is_anonymous is True
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
@patch("services.end_user_service.logger")
|
||||
def test_existing_user_same_type(self, mock_logger, mock_db, mock_session_class, factory):
|
||||
"""Test retrieving existing user with same type."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
existing_user = factory.create_end_user_mock(
|
||||
tenant_id=tenant_id, app_id=app_id, session_id=user_id, type=type_enum
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.first.return_value = existing_user
|
||||
|
||||
# Act
|
||||
result = EndUserService.get_or_create_end_user_by_type(
|
||||
type=type_enum, tenant_id=tenant_id, app_id=app_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == existing_user
|
||||
mock_session.add.assert_not_called()
|
||||
mock_session.commit.assert_not_called()
|
||||
mock_logger.info.assert_not_called()
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
@patch("services.end_user_service.logger")
|
||||
def test_existing_user_different_type_upgrade(self, mock_logger, mock_db, mock_session_class, factory):
|
||||
"""Test upgrading existing user with different type."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
user_id = "user-789"
|
||||
old_type = InvokeFrom.WEB_APP
|
||||
new_type = InvokeFrom.SERVICE_API
|
||||
|
||||
existing_user = factory.create_end_user_mock(
|
||||
tenant_id=tenant_id, app_id=app_id, session_id=user_id, type=old_type
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.first.return_value = existing_user
|
||||
|
||||
# Act
|
||||
result = EndUserService.get_or_create_end_user_by_type(
|
||||
type=new_type, tenant_id=tenant_id, app_id=app_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == existing_user
|
||||
assert existing_user.type == new_type
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_logger.info.assert_called_once()
|
||||
logger_call_args = mock_logger.info.call_args[0]
|
||||
assert "Upgrading legacy EndUser" in logger_call_args[0]
|
||||
# The old and new types are passed as separate arguments
|
||||
assert mock_logger.info.call_args[0][1] == existing_user.id
|
||||
assert mock_logger.info.call_args[0][2] == old_type
|
||||
assert mock_logger.info.call_args[0][3] == new_type
|
||||
assert mock_logger.info.call_args[0][4] == user_id
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_query_ordering_prioritizes_exact_type_match(self, mock_db, mock_session_class, factory):
|
||||
"""Test that query ordering prioritizes exact type matches."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
user_id = "user-789"
|
||||
target_type = InvokeFrom.SERVICE_API
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Act
|
||||
EndUserService.get_or_create_end_user_by_type(
|
||||
type=target_type, tenant_id=tenant_id, app_id=app_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_query.order_by.assert_called_once()
|
||||
# Verify that case statement is used for ordering
|
||||
order_by_call = mock_query.order_by.call_args[0][0]
|
||||
# The exact structure depends on SQLAlchemy's case implementation
|
||||
# but we can verify it was called
|
||||
|
||||
# Test 10: Session context manager properly closes
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_session_context_manager_closes(self, mock_db, mock_session_class, factory):
|
||||
"""Test that Session context manager is properly used."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
user_id = "user-789"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Act
|
||||
EndUserService.get_or_create_end_user_by_type(
|
||||
type=InvokeFrom.SERVICE_API,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Verify context manager was entered and exited
|
||||
mock_context.__enter__.assert_called_once()
|
||||
mock_context.__exit__.assert_called_once()
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_all_invokefrom_types_supported(self, mock_db, mock_session_class):
|
||||
"""Test that all InvokeFrom enum values are supported."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_id = "app-456"
|
||||
user_id = "user-789"
|
||||
|
||||
for invoke_type in InvokeFrom:
|
||||
with patch("services.end_user_service.Session") as mock_session_class:
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Act
|
||||
result = EndUserService.get_or_create_end_user_by_type(
|
||||
type=invoke_type, tenant_id=tenant_id, app_id=app_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
added_user = mock_session.add.call_args[0][0]
|
||||
assert added_user.type == invoke_type
|
||||
|
||||
|
||||
class TestEndUserServiceCreateEndUserBatch:
|
||||
"""Unit tests for EndUserService.create_end_user_batch method."""
|
||||
|
||||
@pytest.fixture
|
||||
def factory(self):
|
||||
"""Provide test data factory."""
|
||||
return TestEndUserServiceFactory()
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_empty_app_ids(self, mock_db, mock_session_class):
|
||||
"""Test batch creation with empty app_ids list."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids: list[str] = []
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
# Act
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=type_enum, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
mock_session_class.assert_not_called()
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_default_session_id(self, mock_db, mock_session_class):
|
||||
"""Test batch creation with empty user_id (uses default session)."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456", "app-789"]
|
||||
user_id = ""
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [] # No existing users
|
||||
|
||||
# Act
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=type_enum, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
for app_id, end_user in result.items():
|
||||
assert end_user.session_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID
|
||||
assert end_user.external_user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID
|
||||
assert end_user._is_anonymous is True
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_deduplicate_app_ids(self, mock_db, mock_session_class):
|
||||
"""Test that duplicate app_ids are deduplicated while preserving order."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456", "app-789", "app-456", "app-123", "app-789"]
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [] # No existing users
|
||||
|
||||
# Act
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=type_enum, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Should have 3 unique app_ids in original order
|
||||
assert len(result) == 3
|
||||
assert "app-456" in result
|
||||
assert "app-789" in result
|
||||
assert "app-123" in result
|
||||
|
||||
# Verify the order is preserved
|
||||
added_users = mock_session.add_all.call_args[0][0]
|
||||
assert len(added_users) == 3
|
||||
assert added_users[0].app_id == "app-456"
|
||||
assert added_users[1].app_id == "app-789"
|
||||
assert added_users[2].app_id == "app-123"
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_all_existing_users(self, mock_db, mock_session_class, factory):
|
||||
"""Test batch creation when all users already exist."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456", "app-789"]
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
existing_user1 = factory.create_end_user_mock(
|
||||
tenant_id=tenant_id, app_id="app-456", session_id=user_id, type=type_enum
|
||||
)
|
||||
existing_user2 = factory.create_end_user_mock(
|
||||
tenant_id=tenant_id, app_id="app-789", session_id=user_id, type=type_enum
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [existing_user1, existing_user2]
|
||||
|
||||
# Act
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=type_enum, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
assert result["app-456"] == existing_user1
|
||||
assert result["app-789"] == existing_user2
|
||||
mock_session.add_all.assert_not_called()
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_partial_existing_users(self, mock_db, mock_session_class, factory):
|
||||
"""Test batch creation with some existing and some new users."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456", "app-789", "app-123"]
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
existing_user1 = factory.create_end_user_mock(
|
||||
tenant_id=tenant_id, app_id="app-456", session_id=user_id, type=type_enum
|
||||
)
|
||||
# app-789 and app-123 don't exist
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [existing_user1]
|
||||
|
||||
# Act
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=type_enum, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 3
|
||||
assert result["app-456"] == existing_user1
|
||||
assert "app-789" in result
|
||||
assert "app-123" in result
|
||||
|
||||
# Should create 2 new users
|
||||
mock_session.add_all.assert_called_once()
|
||||
added_users = mock_session.add_all.call_args[0][0]
|
||||
assert len(added_users) == 2
|
||||
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_handles_duplicates_in_existing(self, mock_db, mock_session_class, factory):
|
||||
"""Test batch creation handles duplicates in existing users gracefully."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456"]
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
# Simulate duplicate records in database
|
||||
existing_user1 = factory.create_end_user_mock(
|
||||
user_id="user-1", tenant_id=tenant_id, app_id="app-456", session_id=user_id, type=type_enum
|
||||
)
|
||||
existing_user2 = factory.create_end_user_mock(
|
||||
user_id="user-2", tenant_id=tenant_id, app_id="app-456", session_id=user_id, type=type_enum
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [existing_user1, existing_user2]
|
||||
|
||||
# Act
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=type_enum, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
# Should prefer the first one found
|
||||
assert result["app-456"] == existing_user1
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_all_invokefrom_types(self, mock_db, mock_session_class):
|
||||
"""Test batch creation with all InvokeFrom types."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456"]
|
||||
user_id = "user-789"
|
||||
|
||||
for invoke_type in InvokeFrom:
|
||||
with patch("services.end_user_service.Session") as mock_session_class:
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [] # No existing users
|
||||
|
||||
# Act
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=invoke_type, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
added_user = mock_session.add_all.call_args[0][0][0]
|
||||
assert added_user.type == invoke_type
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_single_app_id(self, mock_db, mock_session_class, factory):
|
||||
"""Test batch creation with single app_id."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456"]
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [] # No existing users
|
||||
|
||||
# Act
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=type_enum, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
assert "app-456" in result
|
||||
mock_session.add_all.assert_called_once()
|
||||
added_users = mock_session.add_all.call_args[0][0]
|
||||
assert len(added_users) == 1
|
||||
assert added_users[0].app_id == "app-456"
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_anonymous_vs_authenticated(self, mock_db, mock_session_class):
|
||||
"""Test batch creation correctly sets anonymous flag."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456", "app-789"]
|
||||
|
||||
# Test with regular user ID
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [] # No existing users
|
||||
|
||||
# Act - authenticated user
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id="user-789"
|
||||
)
|
||||
|
||||
# Assert
|
||||
added_users = mock_session.add_all.call_args[0][0]
|
||||
for user in added_users:
|
||||
assert user._is_anonymous is False
|
||||
|
||||
# Test with default session ID
|
||||
mock_session.reset_mock()
|
||||
mock_query.reset_mock()
|
||||
mock_query.all.return_value = []
|
||||
|
||||
# Act - anonymous user
|
||||
result = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.SERVICE_API,
|
||||
tenant_id=tenant_id,
|
||||
app_ids=app_ids,
|
||||
user_id=DefaultEndUserSessionID.DEFAULT_SESSION_ID,
|
||||
)
|
||||
|
||||
# Assert
|
||||
added_users = mock_session.add_all.call_args[0][0]
|
||||
for user in added_users:
|
||||
assert user._is_anonymous is True
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_efficient_single_query(self, mock_db, mock_session_class):
|
||||
"""Test that batch creation uses efficient single query for existing users."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456", "app-789", "app-123"]
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [] # No existing users
|
||||
|
||||
# Act
|
||||
EndUserService.create_end_user_batch(type=type_enum, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
# Should make exactly one query to check for existing users
|
||||
mock_session.query.assert_called_once_with(EndUser)
|
||||
mock_query.where.assert_called_once()
|
||||
mock_query.all.assert_called_once()
|
||||
|
||||
# Verify the where clause uses .in_() for app_ids
|
||||
where_call = mock_query.where.call_args[0]
|
||||
# The exact structure depends on SQLAlchemy implementation
|
||||
# but we can verify it was called with the right parameters
|
||||
|
||||
@patch("services.end_user_service.Session")
|
||||
@patch("services.end_user_service.db")
|
||||
def test_create_batch_session_context_manager(self, mock_db, mock_session_class):
|
||||
"""Test that batch creation properly uses session context manager."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
app_ids = ["app-456"]
|
||||
user_id = "user-789"
|
||||
type_enum = InvokeFrom.SERVICE_API
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_context.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value = mock_context
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.all.return_value = [] # No existing users
|
||||
|
||||
# Act
|
||||
EndUserService.create_end_user_batch(type=type_enum, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
mock_context.__enter__.assert_called_once()
|
||||
mock_context.__exit__.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
@ -1,99 +0,0 @@
|
||||
"""
|
||||
Unit tests for `services.file_service.FileService` helpers.
|
||||
|
||||
We keep these tests focused on:
|
||||
- ZIP tempfile building (sanitization + deduplication + content writes)
|
||||
- tenant-scoped batch lookup behavior (`get_upload_files_by_ids`)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
|
||||
import services.file_service as file_service_module
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure ZIP entry names are safe and unique while preserving extensions."""
|
||||
|
||||
# Arrange: three upload files that all sanitize down to the same basename ("b.txt").
|
||||
upload_files: list[Any] = [
|
||||
SimpleNamespace(name="a/b.txt", key="k1"),
|
||||
SimpleNamespace(name="c/b.txt", key="k2"),
|
||||
SimpleNamespace(name="../b.txt", key="k3"),
|
||||
]
|
||||
|
||||
# Stream distinct bytes per key so we can verify content is written to the right entry.
|
||||
data_by_key: dict[str, list[bytes]] = {"k1": [b"one"], "k2": [b"two"], "k3": [b"three"]}
|
||||
|
||||
def _load(key: str, stream: bool = True) -> list[bytes]:
|
||||
# Return the corresponding chunks for this key (the production code iterates chunks).
|
||||
assert stream is True
|
||||
return data_by_key[key]
|
||||
|
||||
monkeypatch.setattr(file_service_module.storage, "load", _load)
|
||||
|
||||
# Act: build zip in a tempfile.
|
||||
with FileService.build_upload_files_zip_tempfile(upload_files=upload_files) as tmp:
|
||||
with ZipFile(tmp, mode="r") as zf:
|
||||
# Assert: names are sanitized (no directory components) and deduped with suffixes.
|
||||
assert zf.namelist() == ["b.txt", "b (1).txt", "b (2).txt"]
|
||||
|
||||
# Assert: each entry contains the correct bytes from storage.
|
||||
assert zf.read("b.txt") == b"one"
|
||||
assert zf.read("b (1).txt") == b"two"
|
||||
assert zf.read("b (2).txt") == b"three"
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_returns_empty_when_no_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure empty input returns an empty mapping without hitting the database."""
|
||||
|
||||
class _Session:
|
||||
def scalars(self, _stmt): # type: ignore[no-untyped-def]
|
||||
raise AssertionError("db.session.scalars should not be called for empty id lists")
|
||||
|
||||
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=_Session()))
|
||||
|
||||
assert FileService.get_upload_files_by_ids("tenant-1", []) == {}
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_returns_id_keyed_mapping(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure batch lookup returns a dict keyed by stringified UploadFile ids."""
|
||||
|
||||
upload_files: list[Any] = [
|
||||
SimpleNamespace(id="file-1", tenant_id="tenant-1"),
|
||||
SimpleNamespace(id="file-2", tenant_id="tenant-1"),
|
||||
]
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, items: list[Any]) -> None:
|
||||
self._items = items
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return self._items
|
||||
|
||||
class _Session:
|
||||
def __init__(self, items: list[Any]) -> None:
|
||||
self._items = items
|
||||
self.calls: list[object] = []
|
||||
|
||||
def scalars(self, stmt): # type: ignore[no-untyped-def]
|
||||
# Capture the statement so we can at least assert the query path is taken.
|
||||
self.calls.append(stmt)
|
||||
return _ScalarResult(self._items)
|
||||
|
||||
session = _Session(upload_files)
|
||||
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=session))
|
||||
|
||||
# Provide duplicates to ensure callers can safely pass repeated ids.
|
||||
result = FileService.get_upload_files_by_ids("tenant-1", ["file-1", "file-1", "file-2"])
|
||||
|
||||
assert set(result.keys()) == {"file-1", "file-2"}
|
||||
assert result["file-1"].id == "file-1"
|
||||
assert result["file-2"].id == "file-2"
|
||||
assert len(session.calls) == 1
|
||||
@ -1,224 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from services.oauth_server import (
|
||||
OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
OAUTH_ACCESS_TOKEN_REDIS_KEY,
|
||||
OAUTH_AUTHORIZATION_CODE_REDIS_KEY,
|
||||
OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
OAUTH_REFRESH_TOKEN_REDIS_KEY,
|
||||
OAuthGrantType,
|
||||
OAuthServerService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(mocker: MockerFixture) -> MagicMock:
|
||||
return mocker.patch("services.oauth_server.redis_client")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session(mocker: MockerFixture) -> MagicMock:
|
||||
"""Mock the OAuth server Session context manager."""
|
||||
mocker.patch("services.oauth_server.db", SimpleNamespace(engine=object()))
|
||||
session = MagicMock()
|
||||
session_cm = MagicMock()
|
||||
session_cm.__enter__.return_value = session
|
||||
mocker.patch("services.oauth_server.Session", return_value=session_cm)
|
||||
return session
|
||||
|
||||
|
||||
def test_get_oauth_provider_app_should_return_app_when_record_exists(mock_session: MagicMock) -> None:
|
||||
# Arrange
|
||||
mock_execute_result = MagicMock()
|
||||
expected_app = MagicMock()
|
||||
mock_execute_result.scalar_one_or_none.return_value = expected_app
|
||||
mock_session.execute.return_value = mock_execute_result
|
||||
|
||||
# Act
|
||||
result = OAuthServerService.get_oauth_provider_app("client-1")
|
||||
|
||||
# Assert
|
||||
assert result is expected_app
|
||||
mock_session.execute.assert_called_once()
|
||||
mock_execute_result.scalar_one_or_none.assert_called_once()
|
||||
|
||||
|
||||
def test_sign_oauth_authorization_code_should_store_code_and_return_value(
|
||||
mocker: MockerFixture, mock_redis_client: MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000111")
|
||||
mocker.patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid)
|
||||
|
||||
# Act
|
||||
code = OAuthServerService.sign_oauth_authorization_code("client-1", "user-1")
|
||||
|
||||
# Assert
|
||||
expected_code = str(deterministic_uuid)
|
||||
assert code == expected_code
|
||||
mock_redis_client.set.assert_called_once_with(
|
||||
OAUTH_AUTHORIZATION_CODE_REDIS_KEY.format(client_id="client-1", code=expected_code),
|
||||
"user-1",
|
||||
ex=600,
|
||||
)
|
||||
|
||||
|
||||
def test_sign_oauth_access_token_should_raise_bad_request_when_authorization_code_is_invalid(
|
||||
mock_redis_client: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(BadRequest, match="invalid code"):
|
||||
OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.AUTHORIZATION_CODE,
|
||||
code="bad-code",
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
|
||||
def test_sign_oauth_access_token_should_issue_access_and_refresh_token_when_authorization_code_is_valid(
|
||||
mocker: MockerFixture, mock_redis_client: MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
token_uuids = [
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000201"),
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000202"),
|
||||
]
|
||||
mocker.patch("services.oauth_server.uuid.uuid4", side_effect=token_uuids)
|
||||
mock_redis_client.get.return_value = b"user-1"
|
||||
code_key = OAUTH_AUTHORIZATION_CODE_REDIS_KEY.format(client_id="client-1", code="code-1")
|
||||
|
||||
# Act
|
||||
access_token, refresh_token = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.AUTHORIZATION_CODE,
|
||||
code="code-1",
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert access_token == str(token_uuids[0])
|
||||
assert refresh_token == str(token_uuids[1])
|
||||
mock_redis_client.delete.assert_called_once_with(code_key)
|
||||
mock_redis_client.set.assert_any_call(
|
||||
OAUTH_ACCESS_TOKEN_REDIS_KEY.format(client_id="client-1", token=access_token),
|
||||
b"user-1",
|
||||
ex=OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
mock_redis_client.set.assert_any_call(
|
||||
OAUTH_REFRESH_TOKEN_REDIS_KEY.format(client_id="client-1", token=refresh_token),
|
||||
b"user-1",
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
|
||||
def test_sign_oauth_access_token_should_raise_bad_request_when_refresh_token_is_invalid(
|
||||
mock_redis_client: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(BadRequest, match="invalid refresh token"):
|
||||
OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.REFRESH_TOKEN,
|
||||
refresh_token="stale-token",
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
|
||||
def test_sign_oauth_access_token_should_issue_new_access_token_when_refresh_token_is_valid(
|
||||
mocker: MockerFixture, mock_redis_client: MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000301")
|
||||
mocker.patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid)
|
||||
mock_redis_client.get.return_value = b"user-1"
|
||||
|
||||
# Act
|
||||
access_token, returned_refresh_token = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.REFRESH_TOKEN,
|
||||
refresh_token="refresh-1",
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert access_token == str(deterministic_uuid)
|
||||
assert returned_refresh_token == "refresh-1"
|
||||
mock_redis_client.set.assert_called_once_with(
|
||||
OAUTH_ACCESS_TOKEN_REDIS_KEY.format(client_id="client-1", token=access_token),
|
||||
b"user-1",
|
||||
ex=OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
|
||||
def test_sign_oauth_access_token_with_unknown_grant_type_should_return_none() -> None:
|
||||
# Arrange
|
||||
grant_type = cast(OAuthGrantType, "invalid-grant-type")
|
||||
|
||||
# Act
|
||||
result = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=grant_type,
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_sign_oauth_refresh_token_should_store_token_with_expected_expiry(
|
||||
mocker: MockerFixture, mock_redis_client: MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000401")
|
||||
mocker.patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid)
|
||||
|
||||
# Act
|
||||
refresh_token = OAuthServerService._sign_oauth_refresh_token("client-2", "user-2")
|
||||
|
||||
# Assert
|
||||
assert refresh_token == str(deterministic_uuid)
|
||||
mock_redis_client.set.assert_called_once_with(
|
||||
OAUTH_REFRESH_TOKEN_REDIS_KEY.format(client_id="client-2", token=refresh_token),
|
||||
"user-2",
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_oauth_access_token_should_return_none_when_token_not_found(
|
||||
mock_redis_client: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
|
||||
# Act
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_validate_oauth_access_token_should_load_user_when_token_exists(
|
||||
mocker: MockerFixture, mock_redis_client: MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = b"user-88"
|
||||
expected_user = MagicMock()
|
||||
mock_load_user = mocker.patch("services.oauth_server.AccountService.load_user", return_value=expected_user)
|
||||
|
||||
# Act
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "access-token")
|
||||
|
||||
# Assert
|
||||
assert result is expected_user
|
||||
mock_load_user.assert_called_once_with("user-88")
|
||||
@ -1,626 +0,0 @@
|
||||
"""
|
||||
Comprehensive unit tests for SavedMessageService.
|
||||
|
||||
This test suite provides complete coverage of saved message operations in Dify,
|
||||
following TDD principles with the Arrange-Act-Assert pattern.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### 1. Pagination (TestSavedMessageServicePagination)
|
||||
Tests saved message listing and pagination:
|
||||
- Pagination with valid user (Account and EndUser)
|
||||
- Pagination without user raises ValueError
|
||||
- Pagination with last_id parameter
|
||||
- Empty results when no saved messages exist
|
||||
- Integration with MessageService pagination
|
||||
|
||||
### 2. Save Operations (TestSavedMessageServiceSave)
|
||||
Tests saving messages:
|
||||
- Save message for Account user
|
||||
- Save message for EndUser
|
||||
- Save without user (no-op)
|
||||
- Prevent duplicate saves (idempotent)
|
||||
- Message validation through MessageService
|
||||
|
||||
### 3. Delete Operations (TestSavedMessageServiceDelete)
|
||||
Tests deleting saved messages:
|
||||
- Delete saved message for Account user
|
||||
- Delete saved message for EndUser
|
||||
- Delete without user (no-op)
|
||||
- Delete non-existent saved message (no-op)
|
||||
- Proper database cleanup
|
||||
|
||||
## Testing Approach
|
||||
|
||||
- **Mocking Strategy**: All external dependencies (database, MessageService) are mocked
|
||||
for fast, isolated unit tests
|
||||
- **Factory Pattern**: SavedMessageServiceTestDataFactory provides consistent test data
|
||||
- **Fixtures**: Mock objects are configured per test method
|
||||
- **Assertions**: Each test verifies return values and side effects
|
||||
(database operations, method calls)
|
||||
|
||||
## Key Concepts
|
||||
|
||||
**User Types:**
|
||||
- Account: Workspace members (console users)
|
||||
- EndUser: API users (end users)
|
||||
|
||||
**Saved Messages:**
|
||||
- Users can save messages for later reference
|
||||
- Each user has their own saved message list
|
||||
- Saving is idempotent (duplicate saves ignored)
|
||||
- Deletion is safe (non-existent deletes ignored)
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, Mock, create_autospec, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models import Account
|
||||
from models.model import App, EndUser, Message
|
||||
from models.web import SavedMessage
|
||||
from services.saved_message_service import SavedMessageService
|
||||
|
||||
|
||||
class SavedMessageServiceTestDataFactory:
|
||||
"""
|
||||
Factory for creating test data and mock objects.
|
||||
|
||||
Provides reusable methods to create consistent mock objects for testing
|
||||
saved message operations.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_account_mock(account_id: str = "account-123", **kwargs) -> Mock:
|
||||
"""
|
||||
Create a mock Account object.
|
||||
|
||||
Args:
|
||||
account_id: Unique identifier for the account
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock Account object with specified attributes
|
||||
"""
|
||||
account = create_autospec(Account, instance=True)
|
||||
account.id = account_id
|
||||
for key, value in kwargs.items():
|
||||
setattr(account, key, value)
|
||||
return account
|
||||
|
||||
@staticmethod
|
||||
def create_end_user_mock(user_id: str = "user-123", **kwargs) -> Mock:
|
||||
"""
|
||||
Create a mock EndUser object.
|
||||
|
||||
Args:
|
||||
user_id: Unique identifier for the end user
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock EndUser object with specified attributes
|
||||
"""
|
||||
user = create_autospec(EndUser, instance=True)
|
||||
user.id = user_id
|
||||
for key, value in kwargs.items():
|
||||
setattr(user, key, value)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_app_mock(app_id: str = "app-123", tenant_id: str = "tenant-123", **kwargs) -> Mock:
|
||||
"""
|
||||
Create a mock App object.
|
||||
|
||||
Args:
|
||||
app_id: Unique identifier for the app
|
||||
tenant_id: Tenant/workspace identifier
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock App object with specified attributes
|
||||
"""
|
||||
app = create_autospec(App, instance=True)
|
||||
app.id = app_id
|
||||
app.tenant_id = tenant_id
|
||||
app.name = kwargs.get("name", "Test App")
|
||||
app.mode = kwargs.get("mode", "chat")
|
||||
for key, value in kwargs.items():
|
||||
setattr(app, key, value)
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def create_message_mock(
|
||||
message_id: str = "msg-123",
|
||||
app_id: str = "app-123",
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""
|
||||
Create a mock Message object.
|
||||
|
||||
Args:
|
||||
message_id: Unique identifier for the message
|
||||
app_id: Associated app identifier
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock Message object with specified attributes
|
||||
"""
|
||||
message = create_autospec(Message, instance=True)
|
||||
message.id = message_id
|
||||
message.app_id = app_id
|
||||
message.query = kwargs.get("query", "Test query")
|
||||
message.answer = kwargs.get("answer", "Test answer")
|
||||
message.created_at = kwargs.get("created_at", datetime.now(UTC))
|
||||
for key, value in kwargs.items():
|
||||
setattr(message, key, value)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def create_saved_message_mock(
|
||||
saved_message_id: str = "saved-123",
|
||||
app_id: str = "app-123",
|
||||
message_id: str = "msg-123",
|
||||
created_by: str = "user-123",
|
||||
created_by_role: str = "account",
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""
|
||||
Create a mock SavedMessage object.
|
||||
|
||||
Args:
|
||||
saved_message_id: Unique identifier for the saved message
|
||||
app_id: Associated app identifier
|
||||
message_id: Associated message identifier
|
||||
created_by: User who saved the message
|
||||
created_by_role: Role of the user ('account' or 'end_user')
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock SavedMessage object with specified attributes
|
||||
"""
|
||||
saved_message = create_autospec(SavedMessage, instance=True)
|
||||
saved_message.id = saved_message_id
|
||||
saved_message.app_id = app_id
|
||||
saved_message.message_id = message_id
|
||||
saved_message.created_by = created_by
|
||||
saved_message.created_by_role = created_by_role
|
||||
saved_message.created_at = kwargs.get("created_at", datetime.now(UTC))
|
||||
for key, value in kwargs.items():
|
||||
setattr(saved_message, key, value)
|
||||
return saved_message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def factory():
|
||||
"""Provide the test data factory to all tests."""
|
||||
return SavedMessageServiceTestDataFactory
|
||||
|
||||
|
||||
class TestSavedMessageServicePagination:
|
||||
"""Test saved message pagination operations."""
|
||||
|
||||
@patch("services.saved_message_service.MessageService.pagination_by_last_id", autospec=True)
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_pagination_with_account_user(self, mock_db_session, mock_message_pagination, factory):
|
||||
"""Test pagination with an Account user."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_account_mock()
|
||||
|
||||
# Create saved messages for this user
|
||||
saved_messages = [
|
||||
factory.create_saved_message_mock(
|
||||
saved_message_id=f"saved-{i}",
|
||||
app_id=app.id,
|
||||
message_id=f"msg-{i}",
|
||||
created_by=user.id,
|
||||
created_by_role="account",
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.all.return_value = saved_messages
|
||||
|
||||
# Mock MessageService pagination response
|
||||
expected_pagination = InfiniteScrollPagination(data=[], limit=20, has_more=False)
|
||||
mock_message_pagination.return_value = expected_pagination
|
||||
|
||||
# Act
|
||||
result = SavedMessageService.pagination_by_last_id(app_model=app, user=user, last_id=None, limit=20)
|
||||
|
||||
# Assert
|
||||
assert result == expected_pagination
|
||||
mock_db_session.query.assert_called_once_with(SavedMessage)
|
||||
# Verify MessageService was called with correct message IDs
|
||||
mock_message_pagination.assert_called_once_with(
|
||||
app_model=app,
|
||||
user=user,
|
||||
last_id=None,
|
||||
limit=20,
|
||||
include_ids=["msg-0", "msg-1", "msg-2"],
|
||||
)
|
||||
|
||||
@patch("services.saved_message_service.MessageService.pagination_by_last_id", autospec=True)
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_pagination_with_end_user(self, mock_db_session, mock_message_pagination, factory):
|
||||
"""Test pagination with an EndUser."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_end_user_mock()
|
||||
|
||||
# Create saved messages for this end user
|
||||
saved_messages = [
|
||||
factory.create_saved_message_mock(
|
||||
saved_message_id=f"saved-{i}",
|
||||
app_id=app.id,
|
||||
message_id=f"msg-{i}",
|
||||
created_by=user.id,
|
||||
created_by_role="end_user",
|
||||
)
|
||||
for i in range(2)
|
||||
]
|
||||
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.all.return_value = saved_messages
|
||||
|
||||
# Mock MessageService pagination response
|
||||
expected_pagination = InfiniteScrollPagination(data=[], limit=10, has_more=False)
|
||||
mock_message_pagination.return_value = expected_pagination
|
||||
|
||||
# Act
|
||||
result = SavedMessageService.pagination_by_last_id(app_model=app, user=user, last_id=None, limit=10)
|
||||
|
||||
# Assert
|
||||
assert result == expected_pagination
|
||||
# Verify correct role was used in query
|
||||
mock_message_pagination.assert_called_once_with(
|
||||
app_model=app,
|
||||
user=user,
|
||||
last_id=None,
|
||||
limit=10,
|
||||
include_ids=["msg-0", "msg-1"],
|
||||
)
|
||||
|
||||
def test_pagination_without_user_raises_error(self, factory):
|
||||
"""Test that pagination without user raises ValueError."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match="User is required"):
|
||||
SavedMessageService.pagination_by_last_id(app_model=app, user=None, last_id=None, limit=20)
|
||||
|
||||
@patch("services.saved_message_service.MessageService.pagination_by_last_id", autospec=True)
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_pagination_with_last_id(self, mock_db_session, mock_message_pagination, factory):
|
||||
"""Test pagination with last_id parameter."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_account_mock()
|
||||
last_id = "msg-last"
|
||||
|
||||
saved_messages = [
|
||||
factory.create_saved_message_mock(
|
||||
message_id=f"msg-{i}",
|
||||
app_id=app.id,
|
||||
created_by=user.id,
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.all.return_value = saved_messages
|
||||
|
||||
# Mock MessageService pagination response
|
||||
expected_pagination = InfiniteScrollPagination(data=[], limit=10, has_more=True)
|
||||
mock_message_pagination.return_value = expected_pagination
|
||||
|
||||
# Act
|
||||
result = SavedMessageService.pagination_by_last_id(app_model=app, user=user, last_id=last_id, limit=10)
|
||||
|
||||
# Assert
|
||||
assert result == expected_pagination
|
||||
# Verify last_id was passed to MessageService
|
||||
mock_message_pagination.assert_called_once()
|
||||
call_args = mock_message_pagination.call_args
|
||||
assert call_args.kwargs["last_id"] == last_id
|
||||
|
||||
@patch("services.saved_message_service.MessageService.pagination_by_last_id", autospec=True)
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_pagination_with_empty_saved_messages(self, mock_db_session, mock_message_pagination, factory):
|
||||
"""Test pagination when user has no saved messages."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_account_mock()
|
||||
|
||||
# Mock database query returning empty list
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.all.return_value = []
|
||||
|
||||
# Mock MessageService pagination response
|
||||
expected_pagination = InfiniteScrollPagination(data=[], limit=20, has_more=False)
|
||||
mock_message_pagination.return_value = expected_pagination
|
||||
|
||||
# Act
|
||||
result = SavedMessageService.pagination_by_last_id(app_model=app, user=user, last_id=None, limit=20)
|
||||
|
||||
# Assert
|
||||
assert result == expected_pagination
|
||||
# Verify MessageService was called with empty include_ids
|
||||
mock_message_pagination.assert_called_once_with(
|
||||
app_model=app,
|
||||
user=user,
|
||||
last_id=None,
|
||||
limit=20,
|
||||
include_ids=[],
|
||||
)
|
||||
|
||||
|
||||
class TestSavedMessageServiceSave:
|
||||
"""Test save message operations."""
|
||||
|
||||
@patch("services.saved_message_service.MessageService.get_message", autospec=True)
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_save_message_for_account(self, mock_db_session, mock_get_message, factory):
|
||||
"""Test saving a message for an Account user."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_account_mock()
|
||||
message = factory.create_message_mock(message_id="msg-123", app_id=app.id)
|
||||
|
||||
# Mock database query - no existing saved message
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Mock MessageService.get_message
|
||||
mock_get_message.return_value = message
|
||||
|
||||
# Act
|
||||
SavedMessageService.save(app_model=app, user=user, message_id=message.id)
|
||||
|
||||
# Assert
|
||||
mock_db_session.add.assert_called_once()
|
||||
saved_message = mock_db_session.add.call_args[0][0]
|
||||
assert saved_message.app_id == app.id
|
||||
assert saved_message.message_id == message.id
|
||||
assert saved_message.created_by == user.id
|
||||
assert saved_message.created_by_role == "account"
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
@patch("services.saved_message_service.MessageService.get_message", autospec=True)
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_save_message_for_end_user(self, mock_db_session, mock_get_message, factory):
|
||||
"""Test saving a message for an EndUser."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_end_user_mock()
|
||||
message = factory.create_message_mock(message_id="msg-456", app_id=app.id)
|
||||
|
||||
# Mock database query - no existing saved message
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Mock MessageService.get_message
|
||||
mock_get_message.return_value = message
|
||||
|
||||
# Act
|
||||
SavedMessageService.save(app_model=app, user=user, message_id=message.id)
|
||||
|
||||
# Assert
|
||||
mock_db_session.add.assert_called_once()
|
||||
saved_message = mock_db_session.add.call_args[0][0]
|
||||
assert saved_message.app_id == app.id
|
||||
assert saved_message.message_id == message.id
|
||||
assert saved_message.created_by == user.id
|
||||
assert saved_message.created_by_role == "end_user"
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_save_without_user_does_nothing(self, mock_db_session, factory):
|
||||
"""Test that saving without user is a no-op."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
|
||||
# Act
|
||||
SavedMessageService.save(app_model=app, user=None, message_id="msg-123")
|
||||
|
||||
# Assert
|
||||
mock_db_session.query.assert_not_called()
|
||||
mock_db_session.add.assert_not_called()
|
||||
mock_db_session.commit.assert_not_called()
|
||||
|
||||
@patch("services.saved_message_service.MessageService.get_message", autospec=True)
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_save_duplicate_message_is_idempotent(self, mock_db_session, mock_get_message, factory):
|
||||
"""Test that saving an already saved message is idempotent."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_account_mock()
|
||||
message_id = "msg-789"
|
||||
|
||||
# Mock database query - existing saved message found
|
||||
existing_saved = factory.create_saved_message_mock(
|
||||
app_id=app.id,
|
||||
message_id=message_id,
|
||||
created_by=user.id,
|
||||
created_by_role="account",
|
||||
)
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = existing_saved
|
||||
|
||||
# Act
|
||||
SavedMessageService.save(app_model=app, user=user, message_id=message_id)
|
||||
|
||||
# Assert - no new saved message created
|
||||
mock_db_session.add.assert_not_called()
|
||||
mock_db_session.commit.assert_not_called()
|
||||
mock_get_message.assert_not_called()
|
||||
|
||||
@patch("services.saved_message_service.MessageService.get_message", autospec=True)
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_save_validates_message_exists(self, mock_db_session, mock_get_message, factory):
|
||||
"""Test that save validates message exists through MessageService."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_account_mock()
|
||||
message = factory.create_message_mock()
|
||||
|
||||
# Mock database query - no existing saved message
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Mock MessageService.get_message
|
||||
mock_get_message.return_value = message
|
||||
|
||||
# Act
|
||||
SavedMessageService.save(app_model=app, user=user, message_id=message.id)
|
||||
|
||||
# Assert - MessageService.get_message was called for validation
|
||||
mock_get_message.assert_called_once_with(app_model=app, user=user, message_id=message.id)
|
||||
|
||||
|
||||
class TestSavedMessageServiceDelete:
|
||||
"""Test delete saved message operations."""
|
||||
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_delete_saved_message_for_account(self, mock_db_session, factory):
|
||||
"""Test deleting a saved message for an Account user."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_account_mock()
|
||||
message_id = "msg-123"
|
||||
|
||||
# Mock database query - existing saved message found
|
||||
saved_message = factory.create_saved_message_mock(
|
||||
app_id=app.id,
|
||||
message_id=message_id,
|
||||
created_by=user.id,
|
||||
created_by_role="account",
|
||||
)
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = saved_message
|
||||
|
||||
# Act
|
||||
SavedMessageService.delete(app_model=app, user=user, message_id=message_id)
|
||||
|
||||
# Assert
|
||||
mock_db_session.delete.assert_called_once_with(saved_message)
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_delete_saved_message_for_end_user(self, mock_db_session, factory):
|
||||
"""Test deleting a saved message for an EndUser."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_end_user_mock()
|
||||
message_id = "msg-456"
|
||||
|
||||
# Mock database query - existing saved message found
|
||||
saved_message = factory.create_saved_message_mock(
|
||||
app_id=app.id,
|
||||
message_id=message_id,
|
||||
created_by=user.id,
|
||||
created_by_role="end_user",
|
||||
)
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = saved_message
|
||||
|
||||
# Act
|
||||
SavedMessageService.delete(app_model=app, user=user, message_id=message_id)
|
||||
|
||||
# Assert
|
||||
mock_db_session.delete.assert_called_once_with(saved_message)
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_delete_without_user_does_nothing(self, mock_db_session, factory):
|
||||
"""Test that deleting without user is a no-op."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
|
||||
# Act
|
||||
SavedMessageService.delete(app_model=app, user=None, message_id="msg-123")
|
||||
|
||||
# Assert
|
||||
mock_db_session.query.assert_not_called()
|
||||
mock_db_session.delete.assert_not_called()
|
||||
mock_db_session.commit.assert_not_called()
|
||||
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_delete_non_existent_saved_message_does_nothing(self, mock_db_session, factory):
|
||||
"""Test that deleting a non-existent saved message is a no-op."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_account_mock()
|
||||
message_id = "msg-nonexistent"
|
||||
|
||||
# Mock database query - no saved message found
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Act
|
||||
SavedMessageService.delete(app_model=app, user=user, message_id=message_id)
|
||||
|
||||
# Assert - no deletion occurred
|
||||
mock_db_session.delete.assert_not_called()
|
||||
mock_db_session.commit.assert_not_called()
|
||||
|
||||
@patch("services.saved_message_service.db.session", autospec=True)
|
||||
def test_delete_only_affects_user_own_saved_messages(self, mock_db_session, factory):
|
||||
"""Test that delete only removes the user's own saved message."""
|
||||
# Arrange
|
||||
app = factory.create_app_mock()
|
||||
user1 = factory.create_account_mock(account_id="user-1")
|
||||
message_id = "msg-shared"
|
||||
|
||||
# Mock database query - finds user1's saved message
|
||||
saved_message = factory.create_saved_message_mock(
|
||||
app_id=app.id,
|
||||
message_id=message_id,
|
||||
created_by=user1.id,
|
||||
created_by_role="account",
|
||||
)
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = saved_message
|
||||
|
||||
# Act
|
||||
SavedMessageService.delete(app_model=app, user=user1, message_id=message_id)
|
||||
|
||||
# Assert - only user1's saved message is deleted
|
||||
mock_db_session.delete.assert_called_once_with(saved_message)
|
||||
# Verify the query filters by user
|
||||
assert mock_query.where.called
|
||||
@ -1,259 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models import Account
|
||||
from models.model import App, EndUser
|
||||
from services.web_conversation_service import WebConversationService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_model() -> App:
|
||||
return cast(App, SimpleNamespace(id="app-1"))
|
||||
|
||||
|
||||
def _account(**kwargs: Any) -> Account:
|
||||
return cast(Account, SimpleNamespace(**kwargs))
|
||||
|
||||
|
||||
def _end_user(**kwargs: Any) -> EndUser:
|
||||
return cast(EndUser, SimpleNamespace(**kwargs))
|
||||
|
||||
|
||||
def test_pagination_by_last_id_should_raise_error_when_user_is_none(
|
||||
app_model: App,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
session = MagicMock()
|
||||
mocker.patch("services.web_conversation_service.ConversationService.pagination_by_last_id")
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="User is required"):
|
||||
WebConversationService.pagination_by_last_id(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
user=None,
|
||||
last_id=None,
|
||||
limit=20,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
|
||||
def test_pagination_by_last_id_should_forward_without_pin_filter_when_pinned_is_none(
|
||||
app_model: App,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
session = MagicMock()
|
||||
fake_user = _account(id="user-1")
|
||||
mock_pagination = mocker.patch("services.web_conversation_service.ConversationService.pagination_by_last_id")
|
||||
mock_pagination.return_value = MagicMock()
|
||||
|
||||
# Act
|
||||
WebConversationService.pagination_by_last_id(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
user=fake_user,
|
||||
last_id="conv-9",
|
||||
limit=10,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
pinned=None,
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_kwargs = mock_pagination.call_args.kwargs
|
||||
assert call_kwargs["include_ids"] is None
|
||||
assert call_kwargs["exclude_ids"] is None
|
||||
assert call_kwargs["last_id"] == "conv-9"
|
||||
assert call_kwargs["sort_by"] == "-updated_at"
|
||||
|
||||
|
||||
def test_pagination_by_last_id_should_include_only_pinned_ids_when_pinned_true(
|
||||
app_model: App,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
session = MagicMock()
|
||||
fake_account_cls = type("FakeAccount", (), {})
|
||||
fake_user = cast(Account, fake_account_cls())
|
||||
fake_user.id = "account-1"
|
||||
mocker.patch("services.web_conversation_service.Account", fake_account_cls)
|
||||
mocker.patch("services.web_conversation_service.EndUser", type("FakeEndUser", (), {}))
|
||||
session.scalars.return_value.all.return_value = ["conv-1", "conv-2"]
|
||||
mock_pagination = mocker.patch("services.web_conversation_service.ConversationService.pagination_by_last_id")
|
||||
mock_pagination.return_value = MagicMock()
|
||||
|
||||
# Act
|
||||
WebConversationService.pagination_by_last_id(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
user=fake_user,
|
||||
last_id=None,
|
||||
limit=20,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
pinned=True,
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_kwargs = mock_pagination.call_args.kwargs
|
||||
assert call_kwargs["include_ids"] == ["conv-1", "conv-2"]
|
||||
assert call_kwargs["exclude_ids"] is None
|
||||
|
||||
|
||||
def test_pagination_by_last_id_should_exclude_pinned_ids_when_pinned_false(
|
||||
app_model: App,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
session = MagicMock()
|
||||
fake_end_user_cls = type("FakeEndUser", (), {})
|
||||
fake_user = cast(EndUser, fake_end_user_cls())
|
||||
fake_user.id = "end-user-1"
|
||||
mocker.patch("services.web_conversation_service.Account", type("FakeAccount", (), {}))
|
||||
mocker.patch("services.web_conversation_service.EndUser", fake_end_user_cls)
|
||||
session.scalars.return_value.all.return_value = ["conv-3"]
|
||||
mock_pagination = mocker.patch("services.web_conversation_service.ConversationService.pagination_by_last_id")
|
||||
mock_pagination.return_value = MagicMock()
|
||||
|
||||
# Act
|
||||
WebConversationService.pagination_by_last_id(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
user=fake_user,
|
||||
last_id=None,
|
||||
limit=20,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
pinned=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_kwargs = mock_pagination.call_args.kwargs
|
||||
assert call_kwargs["include_ids"] is None
|
||||
assert call_kwargs["exclude_ids"] == ["conv-3"]
|
||||
|
||||
|
||||
def test_pin_should_return_early_when_user_is_none(app_model: App, mocker: MockerFixture) -> None:
|
||||
# Arrange
|
||||
mock_db = mocker.patch("services.web_conversation_service.db")
|
||||
mocker.patch("services.web_conversation_service.ConversationService.get_conversation")
|
||||
|
||||
# Act
|
||||
WebConversationService.pin(app_model, "conv-1", None)
|
||||
|
||||
# Assert
|
||||
mock_db.session.add.assert_not_called()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_pin_should_return_early_when_conversation_is_already_pinned(
|
||||
app_model: App,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
fake_account_cls = type("FakeAccount", (), {})
|
||||
fake_user = cast(Account, fake_account_cls())
|
||||
fake_user.id = "account-1"
|
||||
mocker.patch("services.web_conversation_service.Account", fake_account_cls)
|
||||
mock_db = mocker.patch("services.web_conversation_service.db")
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = object()
|
||||
mock_get_conversation = mocker.patch("services.web_conversation_service.ConversationService.get_conversation")
|
||||
|
||||
# Act
|
||||
WebConversationService.pin(app_model, "conv-1", fake_user)
|
||||
|
||||
# Assert
|
||||
mock_get_conversation.assert_not_called()
|
||||
mock_db.session.add.assert_not_called()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_pin_should_create_pinned_conversation_when_not_already_pinned(
|
||||
app_model: App,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
fake_account_cls = type("FakeAccount", (), {})
|
||||
fake_user = cast(Account, fake_account_cls())
|
||||
fake_user.id = "account-2"
|
||||
mocker.patch("services.web_conversation_service.Account", fake_account_cls)
|
||||
mock_db = mocker.patch("services.web_conversation_service.db")
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
mock_conversation = SimpleNamespace(id="conv-2")
|
||||
mock_get_conversation = mocker.patch(
|
||||
"services.web_conversation_service.ConversationService.get_conversation",
|
||||
return_value=mock_conversation,
|
||||
)
|
||||
|
||||
# Act
|
||||
WebConversationService.pin(app_model, "conv-2", fake_user)
|
||||
|
||||
# Assert
|
||||
mock_get_conversation.assert_called_once_with(app_model=app_model, conversation_id="conv-2", user=fake_user)
|
||||
added_obj = mock_db.session.add.call_args.args[0]
|
||||
assert added_obj.app_id == "app-1"
|
||||
assert added_obj.conversation_id == "conv-2"
|
||||
assert added_obj.created_by_role == "account"
|
||||
assert added_obj.created_by == "account-2"
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_unpin_should_return_early_when_user_is_none(app_model: App, mocker: MockerFixture) -> None:
|
||||
# Arrange
|
||||
mock_db = mocker.patch("services.web_conversation_service.db")
|
||||
|
||||
# Act
|
||||
WebConversationService.unpin(app_model, "conv-1", None)
|
||||
|
||||
# Assert
|
||||
mock_db.session.delete.assert_not_called()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_unpin_should_return_early_when_conversation_is_not_pinned(
|
||||
app_model: App,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
fake_end_user_cls = type("FakeEndUser", (), {})
|
||||
fake_user = cast(EndUser, fake_end_user_cls())
|
||||
fake_user.id = "end-user-3"
|
||||
mocker.patch("services.web_conversation_service.Account", type("FakeAccount", (), {}))
|
||||
mocker.patch("services.web_conversation_service.EndUser", fake_end_user_cls)
|
||||
mock_db = mocker.patch("services.web_conversation_service.db")
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
|
||||
# Act
|
||||
WebConversationService.unpin(app_model, "conv-7", fake_user)
|
||||
|
||||
# Assert
|
||||
mock_db.session.delete.assert_not_called()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_unpin_should_delete_pinned_conversation_when_exists(
|
||||
app_model: App,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
fake_end_user_cls = type("FakeEndUser", (), {})
|
||||
fake_user = cast(EndUser, fake_end_user_cls())
|
||||
fake_user.id = "end-user-4"
|
||||
mocker.patch("services.web_conversation_service.Account", type("FakeAccount", (), {}))
|
||||
mocker.patch("services.web_conversation_service.EndUser", fake_end_user_cls)
|
||||
mock_db = mocker.patch("services.web_conversation_service.db")
|
||||
pinned_obj = SimpleNamespace(id="pin-1")
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = pinned_obj
|
||||
|
||||
# Act
|
||||
WebConversationService.unpin(app_model, "conv-8", fake_user)
|
||||
|
||||
# Assert
|
||||
mock_db.session.delete.assert_called_once_with(pinned_obj)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
@ -1,643 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType
|
||||
from services.tools.api_tools_manage_service import ApiToolManageService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db(mocker: MockerFixture) -> MagicMock:
|
||||
# Arrange
|
||||
mocked_db = mocker.patch("services.tools.api_tools_manage_service.db")
|
||||
mocked_db.session = MagicMock()
|
||||
return mocked_db
|
||||
|
||||
|
||||
def _tool_bundle(operation_id: str = "tool-1") -> SimpleNamespace:
|
||||
return SimpleNamespace(operation_id=operation_id)
|
||||
|
||||
|
||||
def test_parser_api_schema_should_return_schema_payload_when_schema_is_valid(mocker: MockerFixture) -> None:
|
||||
# Arrange
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiBasedToolSchemaParser.auto_parse_to_tool_bundle",
|
||||
return_value=([_tool_bundle()], ApiProviderSchemaType.OPENAPI.value),
|
||||
)
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.parser_api_schema("valid-schema")
|
||||
|
||||
# Assert
|
||||
assert result["schema_type"] == ApiProviderSchemaType.OPENAPI.value
|
||||
assert len(result["credentials_schema"]) == 3
|
||||
assert "warning" in result
|
||||
|
||||
|
||||
def test_parser_api_schema_should_raise_value_error_when_parser_raises(mocker: MockerFixture) -> None:
|
||||
# Arrange
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiBasedToolSchemaParser.auto_parse_to_tool_bundle",
|
||||
side_effect=RuntimeError("bad schema"),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="invalid schema: invalid schema: bad schema"):
|
||||
ApiToolManageService.parser_api_schema("invalid")
|
||||
|
||||
|
||||
def test_convert_schema_to_tool_bundles_should_return_tool_bundles_when_valid(mocker: MockerFixture) -> None:
|
||||
# Arrange
|
||||
expected = ([_tool_bundle("a"), _tool_bundle("b")], ApiProviderSchemaType.SWAGGER)
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiBasedToolSchemaParser.auto_parse_to_tool_bundle",
|
||||
return_value=expected,
|
||||
)
|
||||
extra_info: dict[str, str] = {}
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.convert_schema_to_tool_bundles("schema", extra_info=extra_info)
|
||||
|
||||
# Assert
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_convert_schema_to_tool_bundles_should_raise_value_error_when_parser_fails(mocker: MockerFixture) -> None:
|
||||
# Arrange
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiBasedToolSchemaParser.auto_parse_to_tool_bundle",
|
||||
side_effect=ValueError("parse failed"),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="invalid schema: parse failed"):
|
||||
ApiToolManageService.convert_schema_to_tool_bundles("schema")
|
||||
|
||||
|
||||
def test_create_api_tool_provider_should_raise_error_when_provider_already_exists(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = object()
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="provider provider-a already exists"):
|
||||
ApiToolManageService.create_api_tool_provider(
|
||||
user_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_name=" provider-a ",
|
||||
icon={"emoji": "X"},
|
||||
credentials={"auth_type": "none"},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
privacy_policy="privacy",
|
||||
custom_disclaimer="custom",
|
||||
labels=[],
|
||||
)
|
||||
|
||||
|
||||
def test_create_api_tool_provider_should_raise_error_when_tool_count_exceeds_limit(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
many_tools = [_tool_bundle(str(i)) for i in range(101)]
|
||||
mocker.patch.object(
|
||||
ApiToolManageService,
|
||||
"convert_schema_to_tool_bundles",
|
||||
return_value=(many_tools, ApiProviderSchemaType.OPENAPI),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="the number of apis should be less than 100"):
|
||||
ApiToolManageService.create_api_tool_provider(
|
||||
user_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
icon={"emoji": "X"},
|
||||
credentials={"auth_type": "none"},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
privacy_policy="privacy",
|
||||
custom_disclaimer="custom",
|
||||
labels=[],
|
||||
)
|
||||
|
||||
|
||||
def test_create_api_tool_provider_should_raise_error_when_auth_type_is_missing(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
mocker.patch.object(
|
||||
ApiToolManageService,
|
||||
"convert_schema_to_tool_bundles",
|
||||
return_value=([_tool_bundle()], ApiProviderSchemaType.OPENAPI),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="auth_type is required"):
|
||||
ApiToolManageService.create_api_tool_provider(
|
||||
user_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
icon={"emoji": "X"},
|
||||
credentials={},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
privacy_policy="privacy",
|
||||
custom_disclaimer="custom",
|
||||
labels=[],
|
||||
)
|
||||
|
||||
|
||||
def test_create_api_tool_provider_should_create_provider_when_input_is_valid(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
mocker.patch.object(
|
||||
ApiToolManageService,
|
||||
"convert_schema_to_tool_bundles",
|
||||
return_value=([_tool_bundle()], ApiProviderSchemaType.OPENAPI),
|
||||
)
|
||||
mock_controller = MagicMock()
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiToolProviderController.from_db",
|
||||
return_value=mock_controller,
|
||||
)
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.encrypt.return_value = {"auth_type": "none"}
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.create_tool_provider_encrypter",
|
||||
return_value=(mock_encrypter, MagicMock()),
|
||||
)
|
||||
mocker.patch("services.tools.api_tools_manage_service.ToolLabelManager.update_tool_labels")
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.create_api_tool_provider(
|
||||
user_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
icon={"emoji": "X"},
|
||||
credentials={"auth_type": "none"},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
privacy_policy="privacy",
|
||||
custom_disclaimer="custom",
|
||||
labels=["news"],
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {"result": "success"}
|
||||
mock_controller.load_bundled_tools.assert_called_once()
|
||||
mock_db.session.add.assert_called_once()
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_get_api_tool_provider_remote_schema_should_return_schema_when_response_is_valid(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.get",
|
||||
return_value=SimpleNamespace(status_code=200, text="schema-content"),
|
||||
)
|
||||
mocker.patch.object(ApiToolManageService, "parser_api_schema", return_value={"ok": True})
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.get_api_tool_provider_remote_schema("user-1", "tenant-1", "https://schema")
|
||||
|
||||
# Assert
|
||||
assert result == {"schema": "schema-content"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code", [400, 404, 500])
|
||||
def test_get_api_tool_provider_remote_schema_should_raise_error_when_remote_fetch_is_invalid(
|
||||
status_code: int,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.get",
|
||||
return_value=SimpleNamespace(status_code=status_code, text="schema-content"),
|
||||
)
|
||||
mock_logger = mocker.patch("services.tools.api_tools_manage_service.logger")
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="invalid schema, please check the url you provided"):
|
||||
ApiToolManageService.get_api_tool_provider_remote_schema("user-1", "tenant-1", "https://schema")
|
||||
mock_logger.exception.assert_called_once()
|
||||
|
||||
|
||||
def test_list_api_tool_provider_tools_should_raise_error_when_provider_not_found(
|
||||
mock_db: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="you have not added provider provider-a"):
|
||||
ApiToolManageService.list_api_tool_provider_tools("user-1", "tenant-1", "provider-a")
|
||||
|
||||
|
||||
def test_list_api_tool_provider_tools_should_return_converted_tools_when_provider_exists(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
provider = SimpleNamespace(tools=[_tool_bundle("tool-a"), _tool_bundle("tool-b")])
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = provider
|
||||
controller = MagicMock()
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ToolTransformService.api_provider_to_controller",
|
||||
return_value=controller,
|
||||
)
|
||||
mocker.patch("services.tools.api_tools_manage_service.ToolLabelManager.get_tool_labels", return_value=["search"])
|
||||
mock_convert = mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ToolTransformService.convert_tool_entity_to_api_entity",
|
||||
side_effect=[{"name": "tool-a"}, {"name": "tool-b"}],
|
||||
)
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.list_api_tool_provider_tools("user-1", "tenant-1", "provider-a")
|
||||
|
||||
# Assert
|
||||
assert result == [{"name": "tool-a"}, {"name": "tool-b"}]
|
||||
assert mock_convert.call_count == 2
|
||||
|
||||
|
||||
def test_update_api_tool_provider_should_raise_error_when_original_provider_not_found(
|
||||
mock_db: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="api provider provider-a does not exists"):
|
||||
ApiToolManageService.update_api_tool_provider(
|
||||
user_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
original_provider="provider-a",
|
||||
icon={},
|
||||
credentials={"auth_type": "none"},
|
||||
_schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
privacy_policy=None,
|
||||
custom_disclaimer="custom",
|
||||
labels=[],
|
||||
)
|
||||
|
||||
|
||||
def test_update_api_tool_provider_should_raise_error_when_auth_type_missing(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
provider = SimpleNamespace(credentials={}, name="old")
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = provider
|
||||
mocker.patch.object(
|
||||
ApiToolManageService,
|
||||
"convert_schema_to_tool_bundles",
|
||||
return_value=([_tool_bundle()], ApiProviderSchemaType.OPENAPI),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="auth_type is required"):
|
||||
ApiToolManageService.update_api_tool_provider(
|
||||
user_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
original_provider="provider-a",
|
||||
icon={},
|
||||
credentials={},
|
||||
_schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
privacy_policy=None,
|
||||
custom_disclaimer="custom",
|
||||
labels=[],
|
||||
)
|
||||
|
||||
|
||||
def test_update_api_tool_provider_should_update_provider_and_preserve_masked_credentials(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
provider = SimpleNamespace(
|
||||
credentials={"auth_type": "none", "api_key_value": "encrypted-old"},
|
||||
name="old",
|
||||
icon="",
|
||||
schema="",
|
||||
description="",
|
||||
schema_type_str="",
|
||||
tools_str="",
|
||||
privacy_policy="",
|
||||
custom_disclaimer="",
|
||||
credentials_str="",
|
||||
)
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = provider
|
||||
mocker.patch.object(
|
||||
ApiToolManageService,
|
||||
"convert_schema_to_tool_bundles",
|
||||
return_value=([_tool_bundle()], ApiProviderSchemaType.OPENAPI),
|
||||
)
|
||||
controller = MagicMock()
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiToolProviderController.from_db",
|
||||
return_value=controller,
|
||||
)
|
||||
cache = MagicMock()
|
||||
encrypter = MagicMock()
|
||||
encrypter.decrypt.return_value = {"auth_type": "none", "api_key_value": "plain-old"}
|
||||
encrypter.mask_plugin_credentials.return_value = {"api_key_value": "***"}
|
||||
encrypter.encrypt.return_value = {"auth_type": "none", "api_key_value": "encrypted-new"}
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.create_tool_provider_encrypter",
|
||||
return_value=(encrypter, cache),
|
||||
)
|
||||
mocker.patch("services.tools.api_tools_manage_service.ToolLabelManager.update_tool_labels")
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.update_api_tool_provider(
|
||||
user_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-new",
|
||||
original_provider="provider-old",
|
||||
icon={"emoji": "E"},
|
||||
credentials={"auth_type": "none", "api_key_value": "***"},
|
||||
_schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
privacy_policy="privacy",
|
||||
custom_disclaimer="custom",
|
||||
labels=["news"],
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {"result": "success"}
|
||||
assert provider.name == "provider-new"
|
||||
assert provider.privacy_policy == "privacy"
|
||||
assert provider.credentials_str != ""
|
||||
cache.delete.assert_called_once()
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_api_tool_provider_should_raise_error_when_provider_missing(mock_db: MagicMock) -> None:
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="you have not added provider provider-a"):
|
||||
ApiToolManageService.delete_api_tool_provider("user-1", "tenant-1", "provider-a")
|
||||
|
||||
|
||||
def test_delete_api_tool_provider_should_delete_provider_when_exists(mock_db: MagicMock) -> None:
|
||||
# Arrange
|
||||
provider = object()
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = provider
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.delete_api_tool_provider("user-1", "tenant-1", "provider-a")
|
||||
|
||||
# Assert
|
||||
assert result == {"result": "success"}
|
||||
mock_db.session.delete.assert_called_once_with(provider)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_get_api_tool_provider_should_delegate_to_tool_manager(mocker: MockerFixture) -> None:
|
||||
# Arrange
|
||||
expected = {"provider": "value"}
|
||||
mock_get = mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ToolManager.user_get_api_provider",
|
||||
return_value=expected,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.get_api_tool_provider("user-1", "tenant-1", "provider-a")
|
||||
|
||||
# Assert
|
||||
assert result == expected
|
||||
mock_get.assert_called_once_with(provider="provider-a", tenant_id="tenant-1")
|
||||
|
||||
|
||||
def test_test_api_tool_preview_should_raise_error_for_invalid_schema_type() -> None:
|
||||
# Arrange
|
||||
schema_type = "bad-schema-type"
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="invalid schema type"):
|
||||
ApiToolManageService.test_api_tool_preview(
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
tool_name="tool-a",
|
||||
credentials={"auth_type": "none"},
|
||||
parameters={},
|
||||
schema_type=schema_type, # type: ignore[arg-type]
|
||||
schema="schema",
|
||||
)
|
||||
|
||||
|
||||
def test_test_api_tool_preview_should_raise_error_when_schema_parser_fails(mocker: MockerFixture) -> None:
|
||||
# Arrange
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiBasedToolSchemaParser.auto_parse_to_tool_bundle",
|
||||
side_effect=RuntimeError("invalid"),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="invalid schema"):
|
||||
ApiToolManageService.test_api_tool_preview(
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
tool_name="tool-a",
|
||||
credentials={"auth_type": "none"},
|
||||
parameters={},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
)
|
||||
|
||||
|
||||
def test_test_api_tool_preview_should_raise_error_when_tool_name_is_invalid(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiBasedToolSchemaParser.auto_parse_to_tool_bundle",
|
||||
return_value=([_tool_bundle("tool-a")], ApiProviderSchemaType.OPENAPI),
|
||||
)
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = SimpleNamespace(id="provider-id")
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="invalid tool name tool-b"):
|
||||
ApiToolManageService.test_api_tool_preview(
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
tool_name="tool-b",
|
||||
credentials={"auth_type": "none"},
|
||||
parameters={},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
)
|
||||
|
||||
|
||||
def test_test_api_tool_preview_should_raise_error_when_auth_type_missing(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiBasedToolSchemaParser.auto_parse_to_tool_bundle",
|
||||
return_value=([_tool_bundle("tool-a")], ApiProviderSchemaType.OPENAPI),
|
||||
)
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = SimpleNamespace(id="provider-id")
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="auth_type is required"):
|
||||
ApiToolManageService.test_api_tool_preview(
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
tool_name="tool-a",
|
||||
credentials={},
|
||||
parameters={},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
)
|
||||
|
||||
|
||||
def test_test_api_tool_preview_should_return_error_payload_when_tool_validation_raises(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
db_provider = SimpleNamespace(id="provider-id", credentials={"auth_type": "none"})
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = db_provider
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiBasedToolSchemaParser.auto_parse_to_tool_bundle",
|
||||
return_value=([_tool_bundle("tool-a")], ApiProviderSchemaType.OPENAPI),
|
||||
)
|
||||
provider_controller = MagicMock()
|
||||
tool_obj = MagicMock()
|
||||
tool_obj.fork_tool_runtime.return_value = tool_obj
|
||||
tool_obj.validate_credentials.side_effect = ValueError("validation failed")
|
||||
provider_controller.get_tool.return_value = tool_obj
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiToolProviderController.from_db",
|
||||
return_value=provider_controller,
|
||||
)
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"auth_type": "none"}
|
||||
mock_encrypter.mask_plugin_credentials.return_value = {}
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.create_tool_provider_encrypter",
|
||||
return_value=(mock_encrypter, MagicMock()),
|
||||
)
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.test_api_tool_preview(
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
tool_name="tool-a",
|
||||
credentials={"auth_type": "none"},
|
||||
parameters={},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {"error": "validation failed"}
|
||||
|
||||
|
||||
def test_test_api_tool_preview_should_return_result_payload_when_validation_succeeds(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
db_provider = SimpleNamespace(id="provider-id", credentials={"auth_type": "none"})
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = db_provider
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiBasedToolSchemaParser.auto_parse_to_tool_bundle",
|
||||
return_value=([_tool_bundle("tool-a")], ApiProviderSchemaType.OPENAPI),
|
||||
)
|
||||
provider_controller = MagicMock()
|
||||
tool_obj = MagicMock()
|
||||
tool_obj.fork_tool_runtime.return_value = tool_obj
|
||||
tool_obj.validate_credentials.return_value = {"ok": True}
|
||||
provider_controller.get_tool.return_value = tool_obj
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ApiToolProviderController.from_db",
|
||||
return_value=provider_controller,
|
||||
)
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"auth_type": "none"}
|
||||
mock_encrypter.mask_plugin_credentials.return_value = {}
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.create_tool_provider_encrypter",
|
||||
return_value=(mock_encrypter, MagicMock()),
|
||||
)
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.test_api_tool_preview(
|
||||
tenant_id="tenant-1",
|
||||
provider_name="provider-a",
|
||||
tool_name="tool-a",
|
||||
credentials={"auth_type": "none"},
|
||||
parameters={"x": "1"},
|
||||
schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema="schema",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {"result": {"ok": True}}
|
||||
|
||||
|
||||
def test_list_api_tools_should_return_all_user_providers_with_converted_tools(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
provider_one = SimpleNamespace(name="p1")
|
||||
provider_two = SimpleNamespace(name="p2")
|
||||
mock_db.session.scalars.return_value.all.return_value = [provider_one, provider_two]
|
||||
|
||||
controller_one = MagicMock()
|
||||
controller_one.get_tools.return_value = ["tool-a"]
|
||||
controller_two = MagicMock()
|
||||
controller_two.get_tools.return_value = ["tool-b", "tool-c"]
|
||||
|
||||
user_provider_one = SimpleNamespace(labels=[], tools=[])
|
||||
user_provider_two = SimpleNamespace(labels=[], tools=[])
|
||||
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ToolTransformService.api_provider_to_controller",
|
||||
side_effect=[controller_one, controller_two],
|
||||
)
|
||||
mocker.patch("services.tools.api_tools_manage_service.ToolLabelManager.get_tool_labels", return_value=["news"])
|
||||
mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ToolTransformService.api_provider_to_user_provider",
|
||||
side_effect=[user_provider_one, user_provider_two],
|
||||
)
|
||||
mocker.patch("services.tools.api_tools_manage_service.ToolTransformService.repack_provider")
|
||||
mock_convert = mocker.patch(
|
||||
"services.tools.api_tools_manage_service.ToolTransformService.convert_tool_entity_to_api_entity",
|
||||
side_effect=[{"name": "tool-a"}, {"name": "tool-b"}, {"name": "tool-c"}],
|
||||
)
|
||||
|
||||
# Act
|
||||
result = ApiToolManageService.list_api_tools("tenant-1")
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
assert user_provider_one.tools == [{"name": "tool-a"}]
|
||||
assert user_provider_two.tools == [{"name": "tool-b"}, {"name": "tool-c"}]
|
||||
assert mock_convert.call_count == 3
|
||||
@ -1,955 +0,0 @@
|
||||
"""
|
||||
Unit tests for services.tools.workflow_tools_manage_service
|
||||
|
||||
Covers WorkflowToolManageService: create, update, list, delete, get, list_single.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tools.entities.tool_entities import ToolParameter, WorkflowToolParameterConfiguration
|
||||
from core.tools.errors import WorkflowToolHumanInputNotSupportedError
|
||||
from models.model import App
|
||||
from models.tools import WorkflowToolProvider
|
||||
from services.tools import workflow_tools_manage_service
|
||||
from services.tools.workflow_tools_manage_service import WorkflowToolManageService
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers / fake infrastructure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DummyWorkflow:
|
||||
"""Minimal in-memory Workflow substitute."""
|
||||
|
||||
def __init__(self, graph_dict: dict, version: str = "1.0.0") -> None:
|
||||
self._graph_dict = graph_dict
|
||||
self.version = version
|
||||
|
||||
@property
|
||||
def graph_dict(self) -> dict:
|
||||
return self._graph_dict
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
"""Chainable query object that always returns a fixed result."""
|
||||
|
||||
def __init__(self, result: object) -> None:
|
||||
self._result = result
|
||||
|
||||
def where(self, *args: object, **kwargs: object) -> "FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> object:
|
||||
return self._result
|
||||
|
||||
def delete(self) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
class DummySession:
|
||||
"""Minimal SQLAlchemy session substitute."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.added: list[WorkflowToolProvider] = []
|
||||
self.committed: bool = False
|
||||
|
||||
def __enter__(self) -> "DummySession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
return False
|
||||
|
||||
def add(self, obj: WorkflowToolProvider) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
def begin(self) -> "DummySession":
|
||||
return self
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
|
||||
def _build_parameters() -> list[WorkflowToolParameterConfiguration]:
|
||||
return [
|
||||
WorkflowToolParameterConfiguration(name="input", description="input", form=ToolParameter.ToolParameterForm.LLM),
|
||||
]
|
||||
|
||||
|
||||
def _build_fake_db(
|
||||
*,
|
||||
existing_tool: WorkflowToolProvider | None = None,
|
||||
app: object | None = None,
|
||||
tool_by_id: WorkflowToolProvider | None = None,
|
||||
) -> tuple[MagicMock, DummySession]:
|
||||
"""
|
||||
Build a fake db object plus a DummySession for Session context-manager.
|
||||
|
||||
query(WorkflowToolProvider) returns existing_tool on first call,
|
||||
then tool_by_id on subsequent calls (or None if not provided).
|
||||
query(App) returns app.
|
||||
"""
|
||||
call_counts: dict[str, int] = {"wftp": 0}
|
||||
|
||||
def query(model: type) -> FakeQuery:
|
||||
if model is WorkflowToolProvider:
|
||||
call_counts["wftp"] += 1
|
||||
if call_counts["wftp"] == 1:
|
||||
return FakeQuery(existing_tool)
|
||||
return FakeQuery(tool_by_id)
|
||||
if model is App:
|
||||
return FakeQuery(app)
|
||||
return FakeQuery(None)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_db.session = SimpleNamespace(query=query, commit=MagicMock())
|
||||
dummy_session = DummySession()
|
||||
return fake_db, dummy_session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCreateWorkflowTool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateWorkflowTool:
|
||||
"""Tests for WorkflowToolManageService.create_workflow_tool."""
|
||||
|
||||
def test_should_raise_when_human_input_nodes_present(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Human-input nodes must be rejected before any provider is created."""
|
||||
# Arrange
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": [{"id": "n1", "data": {"type": "human-input"}}]})
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
fake_session = SimpleNamespace(query=lambda m: FakeQuery(None) if m is WorkflowToolProvider else FakeQuery(app))
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", fake_session)
|
||||
mock_from_db = MagicMock()
|
||||
monkeypatch.setattr(workflow_tools_manage_service.WorkflowToolProviderController, "from_db", mock_from_db)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info:
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id="user-id",
|
||||
tenant_id="tenant-id",
|
||||
workflow_app_id="app-id",
|
||||
name="tool_name",
|
||||
label="Tool",
|
||||
icon={"type": "emoji", "emoji": "🔧"},
|
||||
description="desc",
|
||||
parameters=_build_parameters(),
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "workflow_tool_human_input_not_supported"
|
||||
mock_from_db.assert_not_called()
|
||||
|
||||
def test_should_raise_when_duplicate_name_or_app_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Existing provider with same name or app_id raises ValueError."""
|
||||
# Arrange
|
||||
existing = MagicMock(spec=WorkflowToolProvider)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(existing)),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_app_id="app-1",
|
||||
name="dup",
|
||||
label="Dup",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
def test_should_raise_when_app_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when the referenced App does not exist."""
|
||||
# Arrange
|
||||
call_count = {"n": 0}
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
call_count["n"] += 1
|
||||
if m is WorkflowToolProvider:
|
||||
return FakeQuery(None)
|
||||
return FakeQuery(None) # App returns None
|
||||
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", SimpleNamespace(query=query))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_app_id="missing-app",
|
||||
name="n",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
def test_should_raise_when_workflow_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when the App has no attached Workflow."""
|
||||
# Arrange
|
||||
app_no_workflow = SimpleNamespace(workflow=None)
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
if m is WorkflowToolProvider:
|
||||
return FakeQuery(None)
|
||||
return FakeQuery(app_no_workflow)
|
||||
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", SimpleNamespace(query=query))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_app_id="app-id",
|
||||
name="n",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
def test_should_raise_when_from_db_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Exceptions from WorkflowToolProviderController.from_db are wrapped as ValueError."""
|
||||
# Arrange
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": []})
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
if m is WorkflowToolProvider:
|
||||
return FakeQuery(None)
|
||||
return FakeQuery(app)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_db.session = SimpleNamespace(query=query)
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "db", fake_db)
|
||||
dummy_session = DummySession()
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "Session", lambda *_, **__: dummy_session)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.WorkflowToolProviderController,
|
||||
"from_db",
|
||||
MagicMock(side_effect=RuntimeError("bad config")),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="bad config"):
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_app_id="app-id",
|
||||
name="n",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
def test_should_succeed_and_persist_provider(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Happy path: provider is added to session and success dict is returned."""
|
||||
# Arrange
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": []}, version="2.0.0")
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
if m is WorkflowToolProvider:
|
||||
return FakeQuery(None)
|
||||
return FakeQuery(app)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_db.session = SimpleNamespace(query=query)
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "db", fake_db)
|
||||
dummy_session = DummySession()
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "Session", lambda *_, **__: dummy_session)
|
||||
monkeypatch.setattr(workflow_tools_manage_service.WorkflowToolProviderController, "from_db", MagicMock())
|
||||
|
||||
icon = {"type": "emoji", "emoji": "🔧"}
|
||||
|
||||
# Act
|
||||
result = WorkflowToolManageService.create_workflow_tool(
|
||||
user_id="user-id",
|
||||
tenant_id="tenant-id",
|
||||
workflow_app_id="app-id",
|
||||
name="tool_name",
|
||||
label="Tool",
|
||||
icon=icon,
|
||||
description="desc",
|
||||
parameters=_build_parameters(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {"result": "success"}
|
||||
assert len(dummy_session.added) == 1
|
||||
created: WorkflowToolProvider = dummy_session.added[0]
|
||||
assert created.name == "tool_name"
|
||||
assert created.label == "Tool"
|
||||
assert created.icon == json.dumps(icon)
|
||||
assert created.version == "2.0.0"
|
||||
|
||||
def test_should_call_label_manager_when_labels_provided(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Labels are forwarded to ToolLabelManager when provided."""
|
||||
# Arrange
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": []})
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
if m is WorkflowToolProvider:
|
||||
return FakeQuery(None)
|
||||
return FakeQuery(app)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_db.session = SimpleNamespace(query=query)
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "db", fake_db)
|
||||
dummy_session = DummySession()
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "Session", lambda *_, **__: dummy_session)
|
||||
monkeypatch.setattr(workflow_tools_manage_service.WorkflowToolProviderController, "from_db", MagicMock())
|
||||
mock_label_mgr = MagicMock()
|
||||
monkeypatch.setattr(workflow_tools_manage_service.ToolLabelManager, "update_tool_labels", mock_label_mgr)
|
||||
mock_to_ctrl = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService, "workflow_provider_to_controller", mock_to_ctrl
|
||||
)
|
||||
|
||||
# Act
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_app_id="app-id",
|
||||
name="n",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
labels=["tag1", "tag2"],
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_label_mgr.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestUpdateWorkflowTool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateWorkflowTool:
|
||||
"""Tests for WorkflowToolManageService.update_workflow_tool."""
|
||||
|
||||
def _make_provider(self) -> WorkflowToolProvider:
|
||||
p = MagicMock(spec=WorkflowToolProvider)
|
||||
p.app_id = "app-id"
|
||||
p.tenant_id = "tenant-id"
|
||||
return p
|
||||
|
||||
def test_should_raise_when_name_duplicated(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""If another tool with the given name already exists, raise ValueError."""
|
||||
# Arrange
|
||||
existing = MagicMock(spec=WorkflowToolProvider)
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
return FakeQuery(existing)
|
||||
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", SimpleNamespace(query=query))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
WorkflowToolManageService.update_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_tool_id="tool-1",
|
||||
name="dup",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
def test_should_raise_when_tool_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when the workflow tool to update does not exist."""
|
||||
# Arrange
|
||||
call_count = {"n": 0}
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
call_count["n"] += 1
|
||||
# 1st call: name uniqueness check → None (no duplicate)
|
||||
# 2nd call: fetch tool by id → None (not found)
|
||||
return FakeQuery(None)
|
||||
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", SimpleNamespace(query=query))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
WorkflowToolManageService.update_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_tool_id="missing",
|
||||
name="n",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
def test_should_raise_when_app_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when the tool's referenced App has been removed."""
|
||||
# Arrange
|
||||
provider = self._make_provider()
|
||||
call_count = {"n": 0}
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
call_count["n"] += 1
|
||||
if m is WorkflowToolProvider:
|
||||
# 1st: duplicate name check (None), 2nd: fetch provider
|
||||
return FakeQuery(None) if call_count["n"] == 1 else FakeQuery(provider)
|
||||
return FakeQuery(None) # App not found
|
||||
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", SimpleNamespace(query=query))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
WorkflowToolManageService.update_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_tool_id="tool-1",
|
||||
name="n",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
def test_should_raise_when_workflow_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when the App exists but has no Workflow."""
|
||||
# Arrange
|
||||
provider = self._make_provider()
|
||||
app_no_wf = SimpleNamespace(workflow=None)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
call_count["n"] += 1
|
||||
if m is WorkflowToolProvider:
|
||||
return FakeQuery(None) if call_count["n"] == 1 else FakeQuery(provider)
|
||||
return FakeQuery(app_no_wf)
|
||||
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", SimpleNamespace(query=query))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
WorkflowToolManageService.update_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_tool_id="tool-1",
|
||||
name="n",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
def test_should_raise_when_from_db_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Exceptions from from_db are re-raised as ValueError."""
|
||||
# Arrange
|
||||
provider = self._make_provider()
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": []})
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
call_count["n"] += 1
|
||||
if m is WorkflowToolProvider:
|
||||
return FakeQuery(None) if call_count["n"] == 1 else FakeQuery(provider)
|
||||
return FakeQuery(app)
|
||||
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=query, commit=MagicMock()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.WorkflowToolProviderController,
|
||||
"from_db",
|
||||
MagicMock(side_effect=RuntimeError("from_db error")),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="from_db error"):
|
||||
WorkflowToolManageService.update_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_tool_id="tool-1",
|
||||
name="n",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
def test_should_succeed_and_call_commit(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Happy path: provider fields are updated and session committed."""
|
||||
# Arrange
|
||||
provider = self._make_provider()
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": []}, version="3.0.0")
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
call_count["n"] += 1
|
||||
if m is WorkflowToolProvider:
|
||||
return FakeQuery(None) if call_count["n"] == 1 else FakeQuery(provider)
|
||||
return FakeQuery(app)
|
||||
|
||||
mock_commit = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=query, commit=mock_commit),
|
||||
)
|
||||
monkeypatch.setattr(workflow_tools_manage_service.WorkflowToolProviderController, "from_db", MagicMock())
|
||||
|
||||
icon = {"type": "emoji", "emoji": "🛠"}
|
||||
|
||||
# Act
|
||||
result = WorkflowToolManageService.update_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_tool_id="tool-1",
|
||||
name="new_name",
|
||||
label="New Label",
|
||||
icon=icon,
|
||||
description="new desc",
|
||||
parameters=_build_parameters(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {"result": "success"}
|
||||
mock_commit.assert_called_once()
|
||||
assert provider.name == "new_name"
|
||||
assert provider.label == "New Label"
|
||||
assert provider.icon == json.dumps(icon)
|
||||
assert provider.version == "3.0.0"
|
||||
|
||||
def test_should_call_label_manager_when_labels_provided(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Labels are forwarded to ToolLabelManager during update."""
|
||||
# Arrange
|
||||
provider = self._make_provider()
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": []})
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def query(m: type) -> FakeQuery:
|
||||
call_count["n"] += 1
|
||||
if m is WorkflowToolProvider:
|
||||
return FakeQuery(None) if call_count["n"] == 1 else FakeQuery(provider)
|
||||
return FakeQuery(app)
|
||||
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=query, commit=MagicMock()),
|
||||
)
|
||||
monkeypatch.setattr(workflow_tools_manage_service.WorkflowToolProviderController, "from_db", MagicMock())
|
||||
mock_label_mgr = MagicMock()
|
||||
monkeypatch.setattr(workflow_tools_manage_service.ToolLabelManager, "update_tool_labels", mock_label_mgr)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService, "workflow_provider_to_controller", MagicMock()
|
||||
)
|
||||
|
||||
# Act
|
||||
WorkflowToolManageService.update_workflow_tool(
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
workflow_tool_id="tool-1",
|
||||
name="n",
|
||||
label="L",
|
||||
icon={},
|
||||
description="",
|
||||
parameters=[],
|
||||
labels=["a"],
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_label_mgr.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestListTenantWorkflowTools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListTenantWorkflowTools:
|
||||
"""Tests for WorkflowToolManageService.list_tenant_workflow_tools."""
|
||||
|
||||
def test_should_return_empty_list_when_no_tools(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An empty database yields an empty result list."""
|
||||
# Arrange
|
||||
fake_scalars = MagicMock()
|
||||
fake_scalars.all.return_value = []
|
||||
fake_db = MagicMock()
|
||||
fake_db.session.scalars.return_value = fake_scalars
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "db", fake_db)
|
||||
|
||||
# Act
|
||||
result = WorkflowToolManageService.list_tenant_workflow_tools("u", "t")
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
|
||||
def test_should_skip_broken_providers_and_log(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Providers that fail to load are logged and skipped."""
|
||||
# Arrange
|
||||
good_provider = MagicMock(spec=WorkflowToolProvider)
|
||||
good_provider.id = "good-id"
|
||||
good_provider.app_id = "app-good"
|
||||
bad_provider = MagicMock(spec=WorkflowToolProvider)
|
||||
bad_provider.id = "bad-id"
|
||||
bad_provider.app_id = "app-bad"
|
||||
|
||||
fake_scalars = MagicMock()
|
||||
fake_scalars.all.return_value = [good_provider, bad_provider]
|
||||
fake_db = MagicMock()
|
||||
fake_db.session.scalars.return_value = fake_scalars
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "db", fake_db)
|
||||
|
||||
good_ctrl = MagicMock()
|
||||
good_ctrl.provider_id = "good-id"
|
||||
|
||||
def to_controller(provider: WorkflowToolProvider) -> MagicMock:
|
||||
if provider is bad_provider:
|
||||
raise RuntimeError("broken provider")
|
||||
return good_ctrl
|
||||
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService, "workflow_provider_to_controller", to_controller
|
||||
)
|
||||
mock_get_labels = MagicMock(return_value={})
|
||||
monkeypatch.setattr(workflow_tools_manage_service.ToolLabelManager, "get_tools_labels", mock_get_labels)
|
||||
mock_to_user = MagicMock()
|
||||
mock_to_user.return_value.tools = []
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService, "workflow_provider_to_user_provider", mock_to_user
|
||||
)
|
||||
monkeypatch.setattr(workflow_tools_manage_service.ToolTransformService, "repack_provider", MagicMock())
|
||||
mock_get_tools = MagicMock(return_value=[MagicMock()])
|
||||
good_ctrl.get_tools = mock_get_tools
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService, "convert_tool_entity_to_api_entity", MagicMock()
|
||||
)
|
||||
|
||||
# Act
|
||||
result = WorkflowToolManageService.list_tenant_workflow_tools("u", "t")
|
||||
|
||||
# Assert - only good provider contributed
|
||||
assert len(result) == 1
|
||||
|
||||
def test_should_return_tools_for_all_providers(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""All successfully loaded providers appear in the result."""
|
||||
# Arrange
|
||||
provider = MagicMock(spec=WorkflowToolProvider)
|
||||
provider.id = "p-1"
|
||||
provider.app_id = "app-1"
|
||||
|
||||
fake_scalars = MagicMock()
|
||||
fake_scalars.all.return_value = [provider]
|
||||
fake_db = MagicMock()
|
||||
fake_db.session.scalars.return_value = fake_scalars
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "db", fake_db)
|
||||
|
||||
ctrl = MagicMock()
|
||||
ctrl.provider_id = "p-1"
|
||||
ctrl.get_tools.return_value = [MagicMock()]
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService,
|
||||
"workflow_provider_to_controller",
|
||||
MagicMock(return_value=ctrl),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolLabelManager, "get_tools_labels", MagicMock(return_value={"p-1": []})
|
||||
)
|
||||
user_provider = MagicMock()
|
||||
user_provider.tools = []
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService,
|
||||
"workflow_provider_to_user_provider",
|
||||
MagicMock(return_value=user_provider),
|
||||
)
|
||||
monkeypatch.setattr(workflow_tools_manage_service.ToolTransformService, "repack_provider", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService, "convert_tool_entity_to_api_entity", MagicMock()
|
||||
)
|
||||
|
||||
# Act
|
||||
result = WorkflowToolManageService.list_tenant_workflow_tools("u", "t")
|
||||
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
assert result[0] is user_provider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestDeleteWorkflowTool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteWorkflowTool:
|
||||
"""Tests for WorkflowToolManageService.delete_workflow_tool."""
|
||||
|
||||
def test_should_delete_and_commit(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""delete_workflow_tool queries, deletes, commits, and returns success."""
|
||||
# Arrange
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.delete.return_value = 1
|
||||
mock_commit = MagicMock()
|
||||
fake_session = SimpleNamespace(query=lambda m: mock_query, commit=mock_commit)
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", fake_session)
|
||||
|
||||
# Act
|
||||
result = WorkflowToolManageService.delete_workflow_tool("u", "t", "tool-1")
|
||||
|
||||
# Assert
|
||||
assert result == {"result": "success"}
|
||||
mock_commit.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetWorkflowToolByToolId / ByAppId
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetWorkflowToolByToolIdAndAppId:
|
||||
"""Tests for get_workflow_tool_by_tool_id and get_workflow_tool_by_app_id."""
|
||||
|
||||
def test_get_by_tool_id_should_raise_when_db_tool_is_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Raises ValueError when no WorkflowToolProvider found by tool id."""
|
||||
# Arrange
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(None)),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="Tool not found"):
|
||||
WorkflowToolManageService.get_workflow_tool_by_tool_id("u", "t", "missing")
|
||||
|
||||
def test_get_by_app_id_should_raise_when_db_tool_is_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Raises ValueError when no WorkflowToolProvider found by app id."""
|
||||
# Arrange
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(None)),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="Tool not found"):
|
||||
WorkflowToolManageService.get_workflow_tool_by_app_id("u", "t", "missing-app")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetWorkflowTool (private _get_workflow_tool)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetWorkflowTool:
|
||||
"""Tests for the internal _get_workflow_tool helper."""
|
||||
|
||||
def test_should_raise_when_db_tool_none(self) -> None:
|
||||
"""_get_workflow_tool raises ValueError when db_tool is None."""
|
||||
with pytest.raises(ValueError, match="Tool not found"):
|
||||
WorkflowToolManageService._get_workflow_tool("t", None)
|
||||
|
||||
def test_should_raise_when_app_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when the corresponding App row is missing."""
|
||||
# Arrange
|
||||
db_tool = MagicMock(spec=WorkflowToolProvider)
|
||||
db_tool.app_id = "app-1"
|
||||
db_tool.tenant_id = "t"
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(None)),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
WorkflowToolManageService._get_workflow_tool("t", db_tool)
|
||||
|
||||
def test_should_raise_when_workflow_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when App has no attached Workflow."""
|
||||
# Arrange
|
||||
db_tool = MagicMock(spec=WorkflowToolProvider)
|
||||
db_tool.app_id = "app-1"
|
||||
db_tool.tenant_id = "t"
|
||||
app = SimpleNamespace(workflow=None)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(app)),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
WorkflowToolManageService._get_workflow_tool("t", db_tool)
|
||||
|
||||
def test_should_raise_when_no_workflow_tools(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when the controller returns no WorkflowTool instances."""
|
||||
# Arrange
|
||||
db_tool = MagicMock(spec=WorkflowToolProvider)
|
||||
db_tool.app_id = "app-1"
|
||||
db_tool.tenant_id = "t"
|
||||
db_tool.id = "tool-1"
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": []})
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(app)),
|
||||
)
|
||||
ctrl = MagicMock()
|
||||
ctrl.get_tools.return_value = []
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService,
|
||||
"workflow_provider_to_controller",
|
||||
MagicMock(return_value=ctrl),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
WorkflowToolManageService._get_workflow_tool("t", db_tool)
|
||||
|
||||
def test_should_return_dict_on_success(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Happy path: returns a dict with name, label, icon, synced, etc."""
|
||||
# Arrange
|
||||
db_tool = MagicMock(spec=WorkflowToolProvider)
|
||||
db_tool.app_id = "app-1"
|
||||
db_tool.tenant_id = "t"
|
||||
db_tool.id = "tool-1"
|
||||
db_tool.name = "my_tool"
|
||||
db_tool.label = "My Tool"
|
||||
db_tool.icon = json.dumps({"emoji": "🔧"})
|
||||
db_tool.description = "some desc"
|
||||
db_tool.privacy_policy = ""
|
||||
db_tool.version = "1.0"
|
||||
db_tool.parameter_configurations = []
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": []}, version="1.0")
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(app)),
|
||||
)
|
||||
|
||||
workflow_tool = MagicMock()
|
||||
workflow_tool.entity.output_schema = {"type": "object"}
|
||||
ctrl = MagicMock()
|
||||
ctrl.get_tools.return_value = [workflow_tool]
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService,
|
||||
"workflow_provider_to_controller",
|
||||
MagicMock(return_value=ctrl),
|
||||
)
|
||||
mock_convert = MagicMock(return_value={"tool": "api_entity"})
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService, "convert_tool_entity_to_api_entity", mock_convert
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolLabelManager, "get_tool_labels", MagicMock(return_value=[])
|
||||
)
|
||||
|
||||
# Act
|
||||
result = WorkflowToolManageService._get_workflow_tool("t", db_tool)
|
||||
|
||||
# Assert
|
||||
assert result["name"] == "my_tool"
|
||||
assert result["label"] == "My Tool"
|
||||
assert result["synced"] is True
|
||||
assert "icon" in result
|
||||
assert "output_schema" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestListSingleWorkflowTools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListSingleWorkflowTools:
|
||||
"""Tests for WorkflowToolManageService.list_single_workflow_tools."""
|
||||
|
||||
def test_should_raise_when_tool_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when the specified tool does not exist in DB."""
|
||||
# Arrange
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(None)),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
WorkflowToolManageService.list_single_workflow_tools("u", "t", "tool-1")
|
||||
|
||||
def test_should_raise_when_no_workflow_tools(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ValueError when the controller yields no tools for the provider."""
|
||||
# Arrange
|
||||
db_tool = MagicMock(spec=WorkflowToolProvider)
|
||||
db_tool.id = "tool-1"
|
||||
db_tool.tenant_id = "t"
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(db_tool)),
|
||||
)
|
||||
ctrl = MagicMock()
|
||||
ctrl.get_tools.return_value = []
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService,
|
||||
"workflow_provider_to_controller",
|
||||
MagicMock(return_value=ctrl),
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
WorkflowToolManageService.list_single_workflow_tools("u", "t", "tool-1")
|
||||
|
||||
def test_should_return_api_entity_list(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Happy path: returns list with one ToolApiEntity."""
|
||||
# Arrange
|
||||
db_tool = MagicMock(spec=WorkflowToolProvider)
|
||||
db_tool.id = "tool-1"
|
||||
db_tool.tenant_id = "t"
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.db,
|
||||
"session",
|
||||
SimpleNamespace(query=lambda m: FakeQuery(db_tool)),
|
||||
)
|
||||
workflow_tool = MagicMock()
|
||||
ctrl = MagicMock()
|
||||
ctrl.get_tools.return_value = [workflow_tool]
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService,
|
||||
"workflow_provider_to_controller",
|
||||
MagicMock(return_value=ctrl),
|
||||
)
|
||||
api_entity = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolTransformService,
|
||||
"convert_tool_entity_to_api_entity",
|
||||
MagicMock(return_value=api_entity),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_tools_manage_service.ToolLabelManager, "get_tool_labels", MagicMock(return_value=[])
|
||||
)
|
||||
|
||||
# Act
|
||||
result = WorkflowToolManageService.list_single_workflow_tools("u", "t", "tool-1")
|
||||
|
||||
# Assert
|
||||
assert result == [api_entity]
|
||||
@ -1,30 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from repositories.sqlalchemy_api_workflow_node_execution_repository import (
|
||||
DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
|
||||
)
|
||||
|
||||
|
||||
class TestSQLAlchemyWorkflowNodeExecutionServiceRepository:
|
||||
@pytest.fixture
|
||||
def repository(self):
|
||||
mock_session_maker = MagicMock()
|
||||
return DifyAPISQLAlchemyWorkflowNodeExecutionRepository(session_maker=mock_session_maker)
|
||||
|
||||
def test_repository_implements_protocol(self, repository):
|
||||
"""Test that the repository implements the required protocol methods."""
|
||||
# Verify all protocol methods are implemented
|
||||
assert hasattr(repository, "get_node_last_execution")
|
||||
assert hasattr(repository, "get_executions_by_workflow_run")
|
||||
assert hasattr(repository, "get_execution_by_id")
|
||||
|
||||
# Verify methods are callable
|
||||
assert callable(repository.get_node_last_execution)
|
||||
assert callable(repository.get_executions_by_workflow_run)
|
||||
assert callable(repository.get_execution_by_id)
|
||||
assert callable(repository.delete_expired_executions)
|
||||
assert callable(repository.delete_executions_by_app)
|
||||
assert callable(repository.get_expired_executions_batch)
|
||||
assert callable(repository.delete_executions_by_ids)
|
||||
@ -771,6 +771,9 @@ BAIDU_VECTOR_DB_SHARD=1
|
||||
BAIDU_VECTOR_DB_REPLICAS=3
|
||||
BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER=DEFAULT_ANALYZER
|
||||
BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE=COARSE_MODE
|
||||
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT=500
|
||||
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO=0.05
|
||||
BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS=300
|
||||
|
||||
# VikingDB configurations, only available when VECTOR_STORE is `vikingdb`
|
||||
VIKINGDB_ACCESS_KEY=your-ak
|
||||
|
||||
@ -345,6 +345,9 @@ x-shared-env: &shared-api-worker-env
|
||||
BAIDU_VECTOR_DB_REPLICAS: ${BAIDU_VECTOR_DB_REPLICAS:-3}
|
||||
BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER: ${BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER:-DEFAULT_ANALYZER}
|
||||
BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE: ${BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE:-COARSE_MODE}
|
||||
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT: ${BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT:-500}
|
||||
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO: ${BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO:-0.05}
|
||||
BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS: ${BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS:-300}
|
||||
VIKINGDB_ACCESS_KEY: ${VIKINGDB_ACCESS_KEY:-your-ak}
|
||||
VIKINGDB_SECRET_KEY: ${VIKINGDB_SECRET_KEY:-your-sk}
|
||||
VIKINGDB_REGION: ${VIKINGDB_REGION:-cn-shanghai}
|
||||
|
||||
@ -28,6 +28,7 @@ http_access deny manager
|
||||
http_access allow localhost
|
||||
include /etc/squid/conf.d/*.conf
|
||||
http_access deny all
|
||||
tcp_outgoing_address 0.0.0.0
|
||||
|
||||
################################## Proxy Server ################################
|
||||
http_port ${HTTP_PORT}
|
||||
|
||||
@ -21,6 +21,8 @@ let clientWidthSpy: { mockRestore: () => void } | null = null
|
||||
let clientHeightSpy: { mockRestore: () => void } | null = null
|
||||
let offsetWidthSpy: { mockRestore: () => void } | null = null
|
||||
let offsetHeightSpy: { mockRestore: () => void } | null = null
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn> | null = null
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn> | null = null
|
||||
|
||||
type AudioContextCtor = new () => unknown
|
||||
type WindowWithLegacyAudio = Window & {
|
||||
@ -83,6 +85,8 @@ describe('CodeBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light })
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900)
|
||||
clientHeightSpy = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(400)
|
||||
offsetWidthSpy = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(900)
|
||||
@ -98,6 +102,10 @@ describe('CodeBlock', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
consoleErrorSpy?.mockRestore()
|
||||
consoleWarnSpy?.mockRestore()
|
||||
consoleErrorSpy = null
|
||||
consoleWarnSpy = null
|
||||
clientWidthSpy?.mockRestore()
|
||||
clientHeightSpy?.mockRestore()
|
||||
offsetWidthSpy?.mockRestore()
|
||||
|
||||
@ -85,13 +85,30 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
const processedRef = useRef<boolean>(false) // Track if content was successfully processed
|
||||
const isInitialRenderRef = useRef<boolean>(true) // Track if this is initial render
|
||||
const chartInstanceRef = useRef<any>(null) // Direct reference to ECharts instance
|
||||
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null) // For debounce handling
|
||||
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) // For debounce handling
|
||||
const chartReadyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const finishedEventCountRef = useRef<number>(0) // Track finished event trigger count
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match?.[1]
|
||||
const languageShowName = getCorrectCapitalizationLanguageName(language || '')
|
||||
const isDarkMode = theme === Theme.dark
|
||||
|
||||
const clearResizeTimer = useCallback(() => {
|
||||
if (!resizeTimerRef.current)
|
||||
return
|
||||
|
||||
clearTimeout(resizeTimerRef.current)
|
||||
resizeTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const clearChartReadyTimer = useCallback(() => {
|
||||
if (!chartReadyTimerRef.current)
|
||||
return
|
||||
|
||||
clearTimeout(chartReadyTimerRef.current)
|
||||
chartReadyTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const echartsStyle = useMemo(() => ({
|
||||
height: '350px',
|
||||
width: '100%',
|
||||
@ -104,26 +121,27 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
|
||||
// Debounce resize operations
|
||||
const debouncedResize = useCallback(() => {
|
||||
if (resizeTimerRef.current)
|
||||
clearTimeout(resizeTimerRef.current)
|
||||
clearResizeTimer()
|
||||
|
||||
resizeTimerRef.current = setTimeout(() => {
|
||||
if (chartInstanceRef.current)
|
||||
chartInstanceRef.current.resize()
|
||||
resizeTimerRef.current = null
|
||||
}, 200)
|
||||
}, [])
|
||||
}, [clearResizeTimer])
|
||||
|
||||
// Handle ECharts instance initialization
|
||||
const handleChartReady = useCallback((instance: any) => {
|
||||
chartInstanceRef.current = instance
|
||||
|
||||
// Force resize to ensure timeline displays correctly
|
||||
setTimeout(() => {
|
||||
clearChartReadyTimer()
|
||||
chartReadyTimerRef.current = setTimeout(() => {
|
||||
if (chartInstanceRef.current)
|
||||
chartInstanceRef.current.resize()
|
||||
chartReadyTimerRef.current = null
|
||||
}, 200)
|
||||
}, [])
|
||||
}, [clearChartReadyTimer])
|
||||
|
||||
// Store event handlers in useMemo to avoid recreating them
|
||||
const echartsEvents = useMemo(() => ({
|
||||
@ -157,10 +175,20 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (resizeTimerRef.current)
|
||||
clearTimeout(resizeTimerRef.current)
|
||||
clearResizeTimer()
|
||||
clearChartReadyTimer()
|
||||
chartInstanceRef.current = null
|
||||
}
|
||||
}, [language, debouncedResize])
|
||||
}, [language, debouncedResize, clearResizeTimer, clearChartReadyTimer])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearResizeTimer()
|
||||
clearChartReadyTimer()
|
||||
chartInstanceRef.current = null
|
||||
echartsRef.current = null
|
||||
}
|
||||
}, [clearResizeTimer, clearChartReadyTimer])
|
||||
// Process chart data when content changes
|
||||
useEffect(() => {
|
||||
// Only process echarts content
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Banner } from '@/models/app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -5,6 +6,11 @@ import { BannerItem } from '../banner-item'
|
||||
|
||||
const mockScrollTo = vi.fn()
|
||||
const mockSlideNodes = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/carousel', () => ({
|
||||
useCarousel: () => ({
|
||||
@ -48,19 +54,34 @@ class MockResizeObserver {
|
||||
}
|
||||
}
|
||||
|
||||
const renderBannerItem = (
|
||||
banner: Banner = createMockBanner(),
|
||||
props: Partial<ComponentProps<typeof BannerItem>> = {},
|
||||
) => {
|
||||
return render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
sort={1}
|
||||
language="en-US"
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BannerItem', () => {
|
||||
let mockWindowOpen: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}]) // 3 slides
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1400, // Above RESPONSIVE_BREAKPOINT (1200)
|
||||
value: 1400,
|
||||
})
|
||||
})
|
||||
|
||||
@ -73,81 +94,51 @@ describe('BannerItem', () => {
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('renders banner category', () => {
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(screen.getByText('Featured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders banner title', () => {
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders banner description', () => {
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(screen.getByText('Test banner description text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders banner image with correct src and alt', () => {
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
const image = screen.getByRole('img')
|
||||
expect(image).toHaveAttribute('src', 'https://example.com/image.png')
|
||||
expect(image).toHaveAttribute('alt', 'Test Banner Title')
|
||||
})
|
||||
|
||||
it('renders view more text', () => {
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('click handling', () => {
|
||||
it('opens banner link in new tab when clicked', () => {
|
||||
it('opens banner link in new tab and tracks click when clicked', () => {
|
||||
const banner = createMockBanner({ link: 'https://test-link.com' })
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
renderBannerItem(banner, { sort: 2, language: 'zh-Hans', accountId: 'account-123' })
|
||||
|
||||
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(bannerElement!)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
|
||||
banner_id: 'banner-1',
|
||||
title: 'Test Banner Title',
|
||||
sort: 2,
|
||||
link: 'https://test-link.com',
|
||||
page: 'explore',
|
||||
language: 'zh-Hans',
|
||||
account_id: 'account-123',
|
||||
event_time: expect.any(Number),
|
||||
}))
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://test-link.com',
|
||||
'_blank',
|
||||
@ -155,18 +146,16 @@ describe('BannerItem', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open window when banner has no link', () => {
|
||||
it('tracks click even when banner has no link', () => {
|
||||
const banner = createMockBanner({ link: '' })
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
renderBannerItem(banner)
|
||||
|
||||
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(bannerElement!)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
|
||||
link: '',
|
||||
}))
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -174,28 +163,13 @@ describe('BannerItem', () => {
|
||||
describe('slide indicators', () => {
|
||||
it('renders correct number of indicator buttons', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
renderBannerItem()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders indicator buttons with correct numbers', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(screen.getByText('01')).toBeInTheDocument()
|
||||
expect(screen.getByText('02')).toBeInTheDocument()
|
||||
expect(screen.getByText('03')).toBeInTheDocument()
|
||||
@ -203,13 +177,7 @@ describe('BannerItem', () => {
|
||||
|
||||
it('calls scrollTo when indicator is clicked', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
renderBannerItem()
|
||||
|
||||
const secondIndicator = screen.getByText('02').closest('button')
|
||||
fireEvent.click(secondIndicator!)
|
||||
@ -219,81 +187,39 @@ describe('BannerItem', () => {
|
||||
|
||||
it('renders no indicators when no slides', () => {
|
||||
mockSlideNodes.mockReturnValue([])
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPaused prop', () => {
|
||||
it('defaults isPaused to false', () => {
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts isPaused prop', () => {
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
isPaused={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem(createMockBanner(), { isPaused: true })
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('responsive behavior', () => {
|
||||
it('sets up ResizeObserver on mount', () => {
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(mockResizeObserverObserve).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds resize event listener on mount', () => {
|
||||
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
addEventListenerSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes resize event listener on unmount', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const banner = createMockBanner()
|
||||
const { unmount } = render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
const { unmount } = renderBannerItem()
|
||||
|
||||
unmount()
|
||||
|
||||
@ -308,14 +234,7 @@ describe('BannerItem', () => {
|
||||
value: 1000,
|
||||
})
|
||||
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -326,14 +245,7 @@ describe('BannerItem', () => {
|
||||
value: 800,
|
||||
})
|
||||
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem()
|
||||
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -348,13 +260,8 @@ describe('BannerItem', () => {
|
||||
'img-src': 'https://example.com/img.png',
|
||||
},
|
||||
} as Partial<Banner>)
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem(banner)
|
||||
expect(screen.getByText('Very Long Category Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -367,13 +274,8 @@ describe('BannerItem', () => {
|
||||
'img-src': 'https://example.com/img.png',
|
||||
},
|
||||
} as Partial<Banner>)
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem(banner)
|
||||
const titleElement = screen.getByText('A Very Long Title That Should Be Truncated Eventually')
|
||||
expect(titleElement).toHaveClass('line-clamp-2')
|
||||
})
|
||||
@ -387,13 +289,8 @@ describe('BannerItem', () => {
|
||||
'img-src': 'https://example.com/img.png',
|
||||
},
|
||||
} as Partial<Banner>)
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
renderBannerItem(banner)
|
||||
const descriptionElement = screen.getByText(/A very long description/)
|
||||
expect(descriptionElement).toHaveClass('line-clamp-4')
|
||||
})
|
||||
@ -402,56 +299,26 @@ describe('BannerItem', () => {
|
||||
describe('slide calculation', () => {
|
||||
it('calculates next index correctly for first slide', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
renderBannerItem()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('handles single slide case', () => {
|
||||
mockSlideNodes.mockReturnValue([{}])
|
||||
const banner = createMockBanner()
|
||||
render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
renderBannerItem()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapper styling', () => {
|
||||
it('has cursor-pointer class', () => {
|
||||
const banner = createMockBanner()
|
||||
const { container } = render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const { container } = renderBannerItem()
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('has rounded-2xl class', () => {
|
||||
const banner = createMockBanner()
|
||||
const { container } = render(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={5000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const { container } = renderBannerItem()
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Banner from '../banner'
|
||||
|
||||
const mockUseGetBanners = vi.fn()
|
||||
const mockUseSelector = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetBanners: (...args: unknown[]) => mockUseGetBanners(...args),
|
||||
@ -15,6 +17,14 @@ vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (...args: unknown[]) => mockUseSelector(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/carousel', () => ({
|
||||
Carousel: Object.assign(
|
||||
({ children, onMouseEnter, onMouseLeave, className }: {
|
||||
@ -54,9 +64,12 @@ vi.mock('@/app/components/base/carousel', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../banner-item', () => ({
|
||||
BannerItem: ({ banner, autoplayDelay, isPaused }: {
|
||||
BannerItem: ({ banner, autoplayDelay, isPaused, sort, language, accountId }: {
|
||||
banner: BannerType
|
||||
autoplayDelay: number
|
||||
sort: number
|
||||
language: string
|
||||
accountId?: string
|
||||
isPaused?: boolean
|
||||
}) => (
|
||||
<div
|
||||
@ -64,6 +77,9 @@ vi.mock('../banner-item', () => ({
|
||||
data-banner-id={banner.id}
|
||||
data-autoplay-delay={autoplayDelay}
|
||||
data-is-paused={isPaused}
|
||||
data-sort={sort}
|
||||
data-language={language}
|
||||
data-account-id={accountId}
|
||||
>
|
||||
BannerItem:
|
||||
{' '}
|
||||
@ -87,6 +103,11 @@ const createMockBanner = (id: string, status: string = 'enabled', title: string
|
||||
describe('Banner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockUseSelector.mockImplementation(selector => selector({
|
||||
userProfile: {
|
||||
id: 'account-123',
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -235,6 +256,59 @@ describe('Banner', () => {
|
||||
|
||||
expect(screen.getByTestId('carousel')).toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('tracks enabled banner impressions with expected payload', () => {
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: [
|
||||
createMockBanner('1', 'enabled', 'Enabled Banner 1'),
|
||||
createMockBanner('2', 'disabled', 'Disabled Banner'),
|
||||
createMockBanner('3', 'enabled', 'Enabled Banner 2'),
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(2)
|
||||
expect(mockTrackEvent).toHaveBeenNthCalledWith(1, 'explore_banner_impression', expect.objectContaining({
|
||||
banner_id: '1',
|
||||
title: 'Enabled Banner 1',
|
||||
sort: 1,
|
||||
link: 'https://example.com',
|
||||
page: 'explore',
|
||||
language: 'en-US',
|
||||
account_id: 'account-123',
|
||||
event_time: expect.any(Number),
|
||||
}))
|
||||
expect(mockTrackEvent).toHaveBeenNthCalledWith(2, 'explore_banner_impression', expect.objectContaining({
|
||||
banner_id: '3',
|
||||
title: 'Enabled Banner 2',
|
||||
sort: 2,
|
||||
link: 'https://example.com',
|
||||
page: 'explore',
|
||||
language: 'en-US',
|
||||
account_id: 'account-123',
|
||||
event_time: expect.any(Number),
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not track impressions when account id is unavailable', () => {
|
||||
mockUseSelector.mockImplementation(selector => selector({
|
||||
userProfile: {
|
||||
id: '',
|
||||
},
|
||||
}))
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: [createMockBanner('1', 'enabled', 'Enabled Banner 1')],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hover behavior', () => {
|
||||
@ -435,8 +509,25 @@ describe('Banner', () => {
|
||||
|
||||
const bannerItems = screen.getAllByTestId('banner-item')
|
||||
expect(bannerItems[0]).toHaveAttribute('data-banner-id', '1')
|
||||
expect(bannerItems[0]).toHaveAttribute('data-sort', '1')
|
||||
expect(bannerItems[1]).toHaveAttribute('data-banner-id', '2')
|
||||
expect(bannerItems[1]).toHaveAttribute('data-sort', '2')
|
||||
expect(bannerItems[2]).toHaveAttribute('data-banner-id', '3')
|
||||
expect(bannerItems[2]).toHaveAttribute('data-sort', '3')
|
||||
})
|
||||
|
||||
it('passes tracking context to banner item', () => {
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: [createMockBanner('1', 'enabled', 'Banner 1')],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
const bannerItem = screen.getByTestId('banner-item')
|
||||
expect(bannerItem).toHaveAttribute('data-language', 'en-US')
|
||||
expect(bannerItem).toHaveAttribute('data-account-id', 'account-123')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import type { Banner } from '@/models/app'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { useCarousel } from '@/app/components/base/carousel'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { IndicatorButton } from './indicator-button'
|
||||
@ -11,6 +12,9 @@ import { IndicatorButton } from './indicator-button'
|
||||
type BannerItemProps = {
|
||||
banner: Banner
|
||||
autoplayDelay: number
|
||||
sort: number
|
||||
language: string
|
||||
accountId?: string
|
||||
isPaused?: boolean
|
||||
}
|
||||
|
||||
@ -20,7 +24,14 @@ const INDICATOR_WIDTH = 20
|
||||
const INDICATOR_GAP = 8
|
||||
const MIN_VIEW_MORE_WIDTH = 480
|
||||
|
||||
export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPaused = false }) => {
|
||||
export const BannerItem: FC<BannerItemProps> = ({
|
||||
banner,
|
||||
autoplayDelay,
|
||||
sort,
|
||||
language,
|
||||
accountId,
|
||||
isPaused = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { api, selectedIndex } = useCarousel()
|
||||
const { category, title, description, 'img-src': imgSrc } = banner.content
|
||||
@ -91,9 +102,21 @@ export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPause
|
||||
|
||||
const handleBannerClick = useCallback(() => {
|
||||
incrementResetKey()
|
||||
|
||||
trackEvent('explore_banner_click', {
|
||||
banner_id: banner.id,
|
||||
title: banner.content.title,
|
||||
sort,
|
||||
link: banner.link,
|
||||
page: 'explore',
|
||||
language,
|
||||
account_id: accountId,
|
||||
event_time: Date.now(),
|
||||
})
|
||||
|
||||
if (banner.link)
|
||||
window.open(banner.link, '_blank', 'noopener,noreferrer')
|
||||
}, [banner.link, incrementResetKey])
|
||||
}, [accountId, banner, incrementResetKey, language, sort])
|
||||
|
||||
const handleIndicatorClick = useCallback((index: number) => {
|
||||
incrementResetKey()
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { Carousel } from '@/app/components/base/carousel'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useGetBanners } from '@/service/use-explore'
|
||||
import Loading from '../../base/loading'
|
||||
@ -23,9 +25,11 @@ const LoadingState: FC = () => (
|
||||
const Banner: FC = () => {
|
||||
const locale = useLocale()
|
||||
const { data: banners, isLoading, isError } = useGetBanners(locale)
|
||||
const accountId = useSelector(s => s.userProfile.id)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const trackedBannerIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const enabledBanners = useMemo(
|
||||
() => banners?.filter(banner => banner.status === 'enabled') ?? [],
|
||||
@ -56,6 +60,28 @@ const Banner: FC = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId)
|
||||
return
|
||||
|
||||
enabledBanners.forEach((banner, index) => {
|
||||
if (trackedBannerIdsRef.current.has(banner.id))
|
||||
return
|
||||
|
||||
trackEvent('explore_banner_impression', {
|
||||
banner_id: banner.id,
|
||||
title: banner.content.title,
|
||||
sort: index + 1,
|
||||
link: banner.link,
|
||||
page: 'explore',
|
||||
language: locale,
|
||||
account_id: accountId,
|
||||
event_time: Date.now(),
|
||||
})
|
||||
trackedBannerIdsRef.current.add(banner.id)
|
||||
})
|
||||
}, [accountId, enabledBanners, locale])
|
||||
|
||||
if (isLoading)
|
||||
return <LoadingState />
|
||||
|
||||
@ -77,12 +103,15 @@ const Banner: FC = () => {
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Carousel.Content>
|
||||
{enabledBanners.map(banner => (
|
||||
{enabledBanners.map((banner, index) => (
|
||||
<Carousel.Item key={banner.id}>
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
autoplayDelay={AUTOPLAY_DELAY}
|
||||
isPaused={isPaused}
|
||||
sort={index + 1}
|
||||
language={locale}
|
||||
accountId={accountId}
|
||||
/>
|
||||
</Carousel.Item>
|
||||
))}
|
||||
|
||||
@ -4,8 +4,9 @@ import type {
|
||||
MetadataShape,
|
||||
} from '../types'
|
||||
import type { DataSet, MetadataInDoc } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
ChunkingMode,
|
||||
DatasetPermission,
|
||||
@ -173,17 +174,26 @@ vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () =
|
||||
|
||||
vi.mock('@/app/components/app/configuration/dataset-config/settings-modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ currentDataset, onSave, onCancel }: { currentDataset: DataSet, onSave: (dataset: DataSet) => void, onCancel: () => void }) => (
|
||||
<div>
|
||||
<div>{currentDataset.name}</div>
|
||||
<button type="button" onClick={() => onSave(createDataset({ ...currentDataset, name: 'Updated Dataset' }))}>
|
||||
save-settings
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
cancel-settings
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
default: function MockSettingsModal({ currentDataset, onSave, onCancel }: { currentDataset: DataSet, onSave: (dataset: DataSet) => void, onCancel: () => void }) {
|
||||
const hasSavedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSavedRef.current)
|
||||
return
|
||||
|
||||
hasSavedRef.current = true
|
||||
onSave(createDataset({ ...currentDataset, name: 'Updated Dataset' }))
|
||||
}, [currentDataset, onSave])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{currentDataset.name}</div>
|
||||
<button type="button" onClick={onCancel}>
|
||||
cancel-settings
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/dataset-config/params-config/config-content', () => ({
|
||||
@ -265,6 +275,13 @@ vi.mock('../components/metadata/metadata-panel', () => ({
|
||||
}))
|
||||
|
||||
describe('knowledge-retrieval path', () => {
|
||||
const getDatasetItem = () => {
|
||||
const datasetItem = screen.getByText('Dataset Name').closest('.group\\/dataset-item')
|
||||
if (!(datasetItem instanceof HTMLElement))
|
||||
throw new Error('Dataset item container not found')
|
||||
return datasetItem
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasEditPermissionForDataset.mockReturnValue(true)
|
||||
@ -293,33 +310,43 @@ describe('knowledge-retrieval path', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should support editing and removing a dataset item', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('should support editing a dataset item', async () => {
|
||||
const onChange = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
|
||||
render(
|
||||
<DatasetItem
|
||||
payload={createDataset({ is_multimodal: true })}
|
||||
onChange={onChange}
|
||||
onRemove={onRemove}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
|
||||
fireEvent.mouseOver(screen.getByText('Dataset Name').closest('.group\\/dataset-item')!)
|
||||
const datasetItem = getDatasetItem()
|
||||
fireEvent.click(within(datasetItem).getByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
await user.click(buttons[0]!)
|
||||
await user.click(screen.getByText('save-settings'))
|
||||
await user.click(buttons[1]!)
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated Dataset' }))
|
||||
})
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated Dataset' }))
|
||||
it('should support removing a dataset item', () => {
|
||||
const onRemove = vi.fn()
|
||||
|
||||
render(
|
||||
<DatasetItem
|
||||
payload={createDataset({ is_multimodal: true })}
|
||||
onChange={vi.fn()}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
const datasetItem = getDatasetItem()
|
||||
fireEvent.click(within(datasetItem).getByRole('button', { name: 'common.operation.remove' }))
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render empty and populated dataset lists', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('should render empty and populated dataset lists', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
@ -338,8 +365,8 @@ describe('knowledge-retrieval path', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.mouseOver(screen.getByText('Dataset Name').closest('.group\\/dataset-item')!)
|
||||
await user.click(screen.getAllByRole('button')[1]!)
|
||||
const datasetItem = getDatasetItem()
|
||||
fireEvent.click(within(datasetItem).getByRole('button', { name: 'common.operation.remove' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
@ -85,6 +85,8 @@ const DatasetItem: FC<Props> = ({
|
||||
{
|
||||
editable && (
|
||||
<ActionButton
|
||||
aria-label={t('operation.edit', { ns: 'common' })}
|
||||
data-testid="dataset-item-edit-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
showSettingsModal()
|
||||
@ -95,6 +97,8 @@ const DatasetItem: FC<Props> = ({
|
||||
)
|
||||
}
|
||||
<ActionButton
|
||||
aria-label={t('operation.remove', { ns: 'common' })}
|
||||
data-testid="dataset-item-remove-button"
|
||||
onClick={handleRemove}
|
||||
state={isDeleteHovered ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onMouseEnter={() => setIsDeleteHovered(true)}
|
||||
|
||||
@ -9795,11 +9795,6 @@
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"hooks/use-breakpoints.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"hooks/use-format-time-from-now.spec.ts": {
|
||||
"regexp/no-dupe-disjunctions": {
|
||||
"count": 5
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
export enum MediaType {
|
||||
mobile = 'mobile',
|
||||
tablet = 'tablet',
|
||||
pc = 'pc',
|
||||
}
|
||||
export const MediaType = {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
} as const
|
||||
|
||||
const useBreakpoints = () => {
|
||||
type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType]
|
||||
|
||||
const useBreakpoints = (): MediaTypeValue => {
|
||||
const [width, setWidth] = React.useState(globalThis.innerWidth)
|
||||
const media = (() => {
|
||||
if (width <= 640)
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
"batchModal.tip": "CSV dosyası aşağıdaki yapıya uygun olmalıdır:",
|
||||
"batchModal.title": "Toplu İçe Aktarma",
|
||||
"editBy": "{{author}} tarafından düzenlendi",
|
||||
"editModal.answerName": "Storyteller Bot",
|
||||
"editModal.answerName": "Hikaye Anlatıcı Bot",
|
||||
"editModal.answerPlaceholder": "Cevabınızı buraya yazın",
|
||||
"editModal.createdAt": "Oluşturulma Tarihi",
|
||||
"editModal.queryName": "Kullanıcı Sorgusu",
|
||||
|
||||
@ -55,10 +55,10 @@
|
||||
"copied": "Kopyalandı",
|
||||
"copy": "Kopyala",
|
||||
"develop.noContent": "İçerik yok",
|
||||
"develop.pathParams": "Path Params",
|
||||
"develop.query": "Query",
|
||||
"develop.requestBody": "Request Body",
|
||||
"develop.toc": "Içeriği",
|
||||
"develop.pathParams": "Yol Parametreleri",
|
||||
"develop.query": "Sorgu",
|
||||
"develop.requestBody": "İstek Gövdesi",
|
||||
"develop.toc": "İçindekiler",
|
||||
"disabled": "Devre Dışı",
|
||||
"loading": "Yükleniyor",
|
||||
"merMaid.rerender": "Yeniden İşleme",
|
||||
@ -67,6 +67,6 @@
|
||||
"pause": "Duraklat",
|
||||
"play": "Oynat",
|
||||
"playing": "Oynatılıyor",
|
||||
"regenerate": "Yenilemek",
|
||||
"regenerate": "Yeniden Oluştur",
|
||||
"status": "Durum"
|
||||
}
|
||||
|
||||
@ -1,33 +1,33 @@
|
||||
{
|
||||
"agent.agentMode": "Agent Modu",
|
||||
"agent.agentModeDes": "Agent için çıkarım modunu ayarlayın",
|
||||
"agent.agentMode": "Ajan Modu",
|
||||
"agent.agentModeDes": "Ajan için çıkarım modunu ayarlayın",
|
||||
"agent.agentModeType.ReACT": "ReAct",
|
||||
"agent.agentModeType.functionCall": "Fonksiyon Çağrısı",
|
||||
"agent.buildInPrompt": "Yerleşik Prompt",
|
||||
"agent.firstPrompt": "İlk Prompt",
|
||||
"agent.nextIteration": "Sonraki Yineleme",
|
||||
"agent.promptPlaceholder": "Promptunuzu buraya yazın",
|
||||
"agent.setting.description": "Agent Asistanı ayarları, Agent modunu ve yerleşik promptlar gibi gelişmiş özellikleri ayarlamanıza olanak tanır. Sadece Agent türünde kullanılabilir.",
|
||||
"agent.setting.maximumIterations.description": "Bir Agent asistanının gerçekleştirebileceği yineleme sayısını sınırlayın",
|
||||
"agent.setting.description": "Ajan Asistanı ayarları, Ajan modunu ve yerleşik promptlar gibi gelişmiş özellikleri ayarlamanıza olanak tanır. Sadece Ajan türünde kullanılabilir.",
|
||||
"agent.setting.maximumIterations.description": "Bir Ajan asistanının gerçekleştirebileceği yineleme sayısını sınırlayın",
|
||||
"agent.setting.maximumIterations.name": "Maksimum Yineleme",
|
||||
"agent.setting.name": "Agent Ayarları",
|
||||
"agent.setting.name": "Ajan Ayarları",
|
||||
"agent.tools.description": "Araçlar kullanmak, internette arama yapmak veya bilimsel hesaplamalar yapmak gibi LLM yeteneklerini genişletebilir",
|
||||
"agent.tools.enabled": "Etkinleştirildi",
|
||||
"agent.tools.name": "Araçlar",
|
||||
"assistantType.agentAssistant.description": "Görevleri tamamlamak için araçları özerk bir şekilde seçebilen bir zeki Agent oluşturun",
|
||||
"assistantType.agentAssistant.name": "Agent Asistanı",
|
||||
"assistantType.agentAssistant.description": "Görevleri tamamlamak için araçları özerk bir şekilde seçebilen bir zeki Ajan oluşturun",
|
||||
"assistantType.agentAssistant.name": "Ajan Asistanı",
|
||||
"assistantType.chatAssistant.description": "Büyük Dil Modeli kullanarak sohbet tabanlı bir asistan oluşturun",
|
||||
"assistantType.chatAssistant.name": "Temel Asistan",
|
||||
"assistantType.name": "Asistan Türü",
|
||||
"autoAddVar": "Ön promptta referans verilen tanımlanmamış değişkenler, kullanıcı giriş formunda eklemek istiyor musunuz?",
|
||||
"chatSubTitle": "Talimatlar",
|
||||
"code.instruction": "Talimat",
|
||||
"codegen.apply": "Uygulamak",
|
||||
"codegen.apply": "Uygula",
|
||||
"codegen.applyChanges": "Değişiklikleri Uygula",
|
||||
"codegen.description": "Kod Oluşturucu, talimatlarınıza göre yüksek kaliteli kod oluşturmak için yapılandırılmış modelleri kullanır. Lütfen açık ve ayrıntılı talimatlar verin.",
|
||||
"codegen.generate": "Oluşturmak",
|
||||
"codegen.generate": "Oluştur",
|
||||
"codegen.generatedCodeTitle": "Oluşturulan Kod",
|
||||
"codegen.instruction": "Talimat -ları",
|
||||
"codegen.instruction": "Talimatlar",
|
||||
"codegen.instructionPlaceholder": "Oluşturmak istediğiniz kodun ayrıntılı açıklamasını girin.",
|
||||
"codegen.loading": "Kod oluşturuluyor...",
|
||||
"codegen.noDataLine1": "Solda kullanım durumunuzu açıklayın,",
|
||||
@ -40,11 +40,11 @@
|
||||
"datasetConfig.embeddingModelRequired": "Yapılandırılmış bir Gömme Modeli gereklidir",
|
||||
"datasetConfig.knowledgeTip": "Bilgi eklemek için “+” düğmesine tıklayın",
|
||||
"datasetConfig.params": "Parametreler",
|
||||
"datasetConfig.rerankModelRequired": "Rerank modeli gereklidir",
|
||||
"datasetConfig.rerankModelRequired": "Yeniden Sıralama modeli gereklidir",
|
||||
"datasetConfig.retrieveChangeTip": "Dizin modunu ve geri alım modunu değiştirmek, bu Bilgi ile ilişkili uygulamaları etkileyebilir.",
|
||||
"datasetConfig.retrieveMultiWay.description": "Kullanıcı niyetine dayanarak, tüm Bilgilerde sorgular, çoklu kaynaklardan ilgili metni alır ve yeniden sıraladıktan sonra kullanıcı sorgusuyla eşleşen en iyi sonuçları seçer.",
|
||||
"datasetConfig.retrieveMultiWay.title": "Çoklu yol geri alım",
|
||||
"datasetConfig.retrieveOneWay.description": "Kullanıcı niyetine ve Bilgi tanımına dayanarak, Agent en iyi Bilgi'yi sorgulamak için özerk bir şekilde seçer. Belirgin, sınırlı Bilgi bulunan uygulamalar için en iyisidir.",
|
||||
"datasetConfig.retrieveOneWay.description": "Kullanıcı niyetine ve Bilgi tanımına dayanarak, Ajan en iyi Bilgi'yi sorgulamak için özerk bir şekilde seçer. Belirgin, sınırlı Bilgi bulunan uygulamalar için en iyisidir.",
|
||||
"datasetConfig.retrieveOneWay.title": "N-to-1 geri alım",
|
||||
"datasetConfig.score_threshold": "Skor Eşiği",
|
||||
"datasetConfig.score_thresholdTip": "Parça filtreleme için benzerlik eşiğini ayarlamak için kullanılır.",
|
||||
@ -235,11 +235,16 @@
|
||||
"inputs.run": "ÇALIŞTIR",
|
||||
"inputs.title": "Hata ayıklama ve Önizleme",
|
||||
"inputs.userInputField": "Kullanıcı Giriş Alanı",
|
||||
"manageModels": "Modelleri yönet",
|
||||
"modelConfig.modeType.chat": "Sohbet",
|
||||
"modelConfig.modeType.completion": "Tamamlama",
|
||||
"modelConfig.model": "Model",
|
||||
"modelConfig.setTone": "Yanıtların tonunu ayarla",
|
||||
"modelConfig.title": "Model ve Parametreler",
|
||||
"noModelProviderConfigured": "Yapılandırılmış model sağlayıcı yok",
|
||||
"noModelProviderConfiguredTip": "Başlamak için bir model sağlayıcı yükleyin veya yapılandırın.",
|
||||
"noModelSelected": "Model seçilmedi",
|
||||
"noModelSelectedTip": "Devam etmek için yukarıdan bir model yapılandırın.",
|
||||
"noResult": "Çıktı burada görüntülenecektir.",
|
||||
"notSetAPIKey.description": "LLM sağlayıcı anahtarı ayarlanmadı, hata ayıklamadan önce ayarlanması gerekiyor.",
|
||||
"notSetAPIKey.settingBtn": "Ayarlar'a git",
|
||||
@ -267,12 +272,12 @@
|
||||
"operation.resetConfig": "Sıfırla",
|
||||
"operation.stopResponding": "Yanıtlamayı Durdur",
|
||||
"operation.userAction": "Kullanıcı",
|
||||
"orchestrate": "Orchestrate",
|
||||
"orchestrate": "Düzenle",
|
||||
"otherError.historyNoBeEmpty": "Konuşma geçmişi prompt'ta ayarlanmalıdır",
|
||||
"otherError.promptNoBeEmpty": "Prompt boş olamaz",
|
||||
"otherError.queryNoBeEmpty": "Sorgu prompt'ta ayarlanmalıdır",
|
||||
"pageTitle.line1": "PROMPT",
|
||||
"pageTitle.line2": "Engineering",
|
||||
"pageTitle.line2": "Mühendisliği",
|
||||
"promptMode.advanced": "Uzman Modu",
|
||||
"promptMode.advancedWarning.description": "Uzman Modunda, tüm PROMPT'u düzenleyebilirsiniz.",
|
||||
"promptMode.advancedWarning.learnMore": "Daha Fazla Bilgi",
|
||||
@ -320,7 +325,7 @@
|
||||
"variableConfig.file.image.name": "Resim",
|
||||
"variableConfig.file.supportFileTypes": "Destek Dosya Türleri",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hide": "Gizlemek",
|
||||
"variableConfig.hide": "Gizle",
|
||||
"variableConfig.inputPlaceholder": "Lütfen girin",
|
||||
"variableConfig.json": "JSON Kodu",
|
||||
"variableConfig.jsonSchema": "JSON Şeması",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"agentLog": "Agent Günlüğü",
|
||||
"agentLogDetail.agentMode": "Agent Modu",
|
||||
"agentLog": "Ajan Günlüğü",
|
||||
"agentLogDetail.agentMode": "Ajan Modu",
|
||||
"agentLogDetail.finalProcessing": "Son İşleme",
|
||||
"agentLogDetail.iteration": "Yineleme",
|
||||
"agentLogDetail.iterations": "Yinelemeler",
|
||||
@ -80,5 +80,5 @@
|
||||
"triggerBy.webhook": "Webhook",
|
||||
"viewLog": "Günlüğü Görüntüle",
|
||||
"workflowSubtitle": "Günlük, Automate'in çalışmasını kaydetmiştir.",
|
||||
"workflowTitle": "Workflow Günlükleri"
|
||||
"workflowTitle": "İş Akışı Günlükleri"
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
"overview.appInfo.preUseReminder": "Devam etmeden önce web app'i etkinleştirin.",
|
||||
"overview.appInfo.preview": "Önizleme",
|
||||
"overview.appInfo.qrcode.download": "QR Kodu İndir",
|
||||
"overview.appInfo.qrcode.scan": "Paylaşmak İçin Taramak",
|
||||
"overview.appInfo.qrcode.scan": "Paylaşmak İçin Tara",
|
||||
"overview.appInfo.qrcode.title": "Bağlantı QR Kodu",
|
||||
"overview.appInfo.regenerate": "Yeniden Oluştur",
|
||||
"overview.appInfo.regenerateNotice": "Genel URL'yi yeniden oluşturmak istiyor musunuz?",
|
||||
@ -102,11 +102,11 @@
|
||||
"overview.appInfo.settings.workflow.show": "Göster",
|
||||
"overview.appInfo.settings.workflow.showDesc": "web app'te iş akışı ayrıntılarını gösterme veya gizleme",
|
||||
"overview.appInfo.settings.workflow.subTitle": "İş Akışı Detayları",
|
||||
"overview.appInfo.settings.workflow.title": "Workflow Adımları",
|
||||
"overview.appInfo.settings.workflow.title": "İş Akışı Adımları",
|
||||
"overview.appInfo.title": "Web Uygulaması",
|
||||
"overview.disableTooltip.triggerMode": "Trigger Düğümü modunda {{feature}} özelliği desteklenmiyor.",
|
||||
"overview.status.disable": "Devre Dışı",
|
||||
"overview.status.running": "Hizmette",
|
||||
"overview.status.running": "Çalışıyor",
|
||||
"overview.title": "Genel Bakış",
|
||||
"overview.triggerInfo.explanation": "İş akışı tetikleyici yönetimi",
|
||||
"overview.triggerInfo.learnAboutTriggers": "Tetikleyiciler hakkında bilgi edinin",
|
||||
|
||||
@ -77,8 +77,8 @@
|
||||
"gotoAnything.actions.themeLightDesc": "Aydınlık görünüm kullan",
|
||||
"gotoAnything.actions.themeSystem": "Sistem Teması",
|
||||
"gotoAnything.actions.themeSystemDesc": "İşletim sisteminizin görünümünü takip edin",
|
||||
"gotoAnything.actions.zenDesc": "Toggle canvas focus mode",
|
||||
"gotoAnything.actions.zenTitle": "Zen Mode",
|
||||
"gotoAnything.actions.zenDesc": "Tuval odak modunu aç/kapat",
|
||||
"gotoAnything.actions.zenTitle": "Zen Modu",
|
||||
"gotoAnything.clearToSearchAll": "Tümünü aramak için @ işaretini kaldırın",
|
||||
"gotoAnything.commandHint": "Kategoriye göre göz atmak için @ yazın",
|
||||
"gotoAnything.emptyState.noAppsFound": "Uygulama bulunamadı",
|
||||
@ -129,11 +129,11 @@
|
||||
"mermaid.classic": "Klasik",
|
||||
"mermaid.handDrawn": "Elle çizilmiş",
|
||||
"newApp.Cancel": "İptal",
|
||||
"newApp.Confirm": "Onaylamak",
|
||||
"newApp.Confirm": "Onayla",
|
||||
"newApp.Create": "Oluştur",
|
||||
"newApp.advancedShortDescription": "Çok turlu sohbetler için geliştirilmiş iş akışı",
|
||||
"newApp.advancedUserDescription": "Ek bellek özellikleri ve sohbet robotu arayüzü ile iş akışı.",
|
||||
"newApp.agentAssistant": "Yeni Agent Asistanı",
|
||||
"newApp.agentAssistant": "Yeni Ajan Asistanı",
|
||||
"newApp.agentShortDescription": "Akıl yürütme ve otonom araç kullanımına sahip akıllı ajan",
|
||||
"newApp.agentUserDescription": "Görev hedeflerine ulaşmak için yinelemeli akıl yürütme ve otonom araç kullanımı yeteneğine sahip akıllı bir ajan.",
|
||||
"newApp.appCreateDSLErrorPart1": "DSL sürümlerinde önemli bir fark tespit edildi. İçe aktarmayı zorlamak, uygulamanın hatalı çalışmasına neden olabilir.",
|
||||
@ -161,12 +161,12 @@
|
||||
"newApp.completionShortDescription": "Metin oluşturma görevleri için yapay zeka asistanı",
|
||||
"newApp.completionUserDescription": "Basit yapılandırmayla metin oluşturma görevleri için hızlı bir şekilde bir yapay zeka asistanı oluşturun.",
|
||||
"newApp.dropDSLToCreateApp": "Uygulama oluşturmak için DSL dosyasını buraya bırakın",
|
||||
"newApp.forAdvanced": "İLERI DÜZEY KULLANICILAR IÇIN",
|
||||
"newApp.forAdvanced": "İLERİ DÜZEY KULLANICILAR İÇİN",
|
||||
"newApp.forBeginners": "Daha temel uygulama türleri",
|
||||
"newApp.foundResult": "{{count}} Sonuç",
|
||||
"newApp.foundResults": "{{count}} Sonuç -ları",
|
||||
"newApp.foundResults": "{{count}} Sonuç",
|
||||
"newApp.hideTemplates": "Mod seçim ekranına geri dön",
|
||||
"newApp.import": "Ithalat",
|
||||
"newApp.import": "İçe Aktar",
|
||||
"newApp.learnMore": "Daha fazla bilgi edinin",
|
||||
"newApp.nameNotEmpty": "İsim boş olamaz",
|
||||
"newApp.noAppsFound": "Uygulama bulunamadı",
|
||||
@ -182,7 +182,7 @@
|
||||
"newApp.workflowShortDescription": "Akıllı otomasyonlar için ajantik akış",
|
||||
"newApp.workflowUserDescription": "Sürükle-bırak kolaylığıyla görsel olarak otonom yapay zeka iş akışları oluşturun.",
|
||||
"newApp.workflowWarning": "Şu anda beta aşamasında",
|
||||
"newAppFromTemplate.byCategories": "KATEGORILERE GÖRE",
|
||||
"newAppFromTemplate.byCategories": "KATEGORİLERE GÖRE",
|
||||
"newAppFromTemplate.searchAllTemplate": "Tüm şablonlarda ara...",
|
||||
"newAppFromTemplate.sidebar.Agent": "Aracı",
|
||||
"newAppFromTemplate.sidebar.Assistant": "Asistan",
|
||||
@ -210,12 +210,12 @@
|
||||
"structOutput.required": "Gerekli",
|
||||
"structOutput.structured": "Yapılandırılmış",
|
||||
"structOutput.structuredTip": "Yapılandırılmış Çıktılar, modelin sağladığınız JSON Şemasına uyacak şekilde her zaman yanıtlar üretmesini sağlayan bir özelliktir.",
|
||||
"switch": "Workflow Orkestrasyonuna Geç",
|
||||
"switch": "İş Akışı Orkestrasyonuna Geç",
|
||||
"switchLabel": "Oluşturulacak uygulama kopyası",
|
||||
"switchStart": "Geçişi Başlat",
|
||||
"switchTip": "izin vermeyecek",
|
||||
"switchTipEnd": " Temel Orkestrasyona geri dönmek.",
|
||||
"switchTipStart": "Sizin için yeni bir uygulama kopyası oluşturulacak ve yeni kopya Workflow Orkestrasyonuna geçecektir. Yeni kopya ",
|
||||
"switchTipStart": "Sizin için yeni bir uygulama kopyası oluşturulacak ve yeni kopya İş Akışı Orkestrasyonuna geçecektir. Yeni kopya ",
|
||||
"theme.switchDark": "Koyu tema geçiş yap",
|
||||
"theme.switchLight": "Aydınlık tema'ya geç",
|
||||
"tracing.aliyun.description": "Alibaba Cloud tarafından sağlanan tamamen yönetilen ve bakım gerektirmeyen gözlemleme platformu, Dify uygulamalarının kutudan çıkar çıkmaz izlenmesi, takip edilmesi ve değerlendirilmesine olanak tanır.",
|
||||
@ -248,7 +248,7 @@
|
||||
"tracing.description": "Üçüncü taraf LLMOps sağlayıcısını yapılandırma ve uygulama performansını izleme.",
|
||||
"tracing.disabled": "Devre Dışı",
|
||||
"tracing.disabledTip": "Lütfen önce sağlayıcıyı yapılandırın",
|
||||
"tracing.enabled": "Hizmette",
|
||||
"tracing.enabled": "Etkin",
|
||||
"tracing.expand": "Genişlet",
|
||||
"tracing.inUse": "Kullanımda",
|
||||
"tracing.langfuse.description": "LLM uygulamanızı hata ayıklamak ve geliştirmek için izlemeler, değerlendirmeler, prompt yönetimi ve metrikler.",
|
||||
@ -258,7 +258,7 @@
|
||||
"tracing.mlflow.description": "Deney takibi, gözlemlenebilirlik ve değerlendirme için açık kaynaklı LLMOps platformu, AI/LLM uygulamalarını güvenle oluşturmak için.",
|
||||
"tracing.mlflow.title": "MLflow",
|
||||
"tracing.opik.description": "Opik, LLM uygulamalarını değerlendirmek, test etmek ve izlemek için açık kaynaklı bir platformdur.",
|
||||
"tracing.opik.title": "Opik Belediyesi",
|
||||
"tracing.opik.title": "Opik",
|
||||
"tracing.phoenix.description": "LLM iş akışlarınız ve ajanlarınız için açık kaynaklı ve OpenTelemetry tabanlı gözlemlenebilirlik, değerlendirme, istem mühendisliği ve deney platformu.",
|
||||
"tracing.phoenix.title": "Phoenix",
|
||||
"tracing.tencent.description": "Tencent Uygulama Performans İzleme, LLM uygulamaları için kapsamlı izleme ve çok boyutlu analiz sağlar.",
|
||||
@ -266,20 +266,20 @@
|
||||
"tracing.title": "Uygulama performansını izleme",
|
||||
"tracing.tracing": "İzleme",
|
||||
"tracing.tracingDescription": "Uygulama yürütmesinin tam bağlamını, LLM çağrıları, bağlam, promptlar, HTTP istekleri ve daha fazlası dahil olmak üzere üçüncü taraf izleme platformuna yakalama.",
|
||||
"tracing.view": "Görünüm",
|
||||
"tracing.view": "Görüntüle",
|
||||
"tracing.weave.description": "Weave, LLM uygulamalarını değerlendirmek, test etmek ve izlemek için açık kaynaklı bir platformdur.",
|
||||
"tracing.weave.title": "Dokuma",
|
||||
"tracing.weave.title": "Weave",
|
||||
"typeSelector.advanced": "Sohbet akışı",
|
||||
"typeSelector.agent": "Agent",
|
||||
"typeSelector.all": "All Types",
|
||||
"typeSelector.agent": "Ajan",
|
||||
"typeSelector.all": "Tüm Türler",
|
||||
"typeSelector.chatbot": "Chatbot",
|
||||
"typeSelector.completion": "Completion",
|
||||
"typeSelector.workflow": "Workflow",
|
||||
"typeSelector.completion": "Tamamlama",
|
||||
"typeSelector.workflow": "İş Akışı",
|
||||
"types.advanced": "Sohbet akışı",
|
||||
"types.agent": "Agent",
|
||||
"types.agent": "Ajan",
|
||||
"types.all": "Hepsi",
|
||||
"types.basic": "Temel",
|
||||
"types.chatbot": "Chatbot",
|
||||
"types.completion": "Tamamlama",
|
||||
"types.workflow": "Workflow"
|
||||
"types.workflow": "İş Akışı"
|
||||
}
|
||||
|
||||
@ -88,7 +88,7 @@
|
||||
"plansCommon.documentsTooltip": "Bilgi Veri Kaynağından ithal edilen belge sayısına kota.",
|
||||
"plansCommon.free": "Ücretsiz",
|
||||
"plansCommon.freeTrialTip": "200 OpenAI çağrısının ücretsiz denemesi.",
|
||||
"plansCommon.freeTrialTipPrefix": "Kaydolun ve bir",
|
||||
"plansCommon.freeTrialTipPrefix": "Kaydolun ve bir ",
|
||||
"plansCommon.freeTrialTipSuffix": "Kredi kartı gerekmez",
|
||||
"plansCommon.getStarted": "Başlayın",
|
||||
"plansCommon.logsHistory": "{{days}} günlük geçmişi",
|
||||
@ -97,7 +97,7 @@
|
||||
"plansCommon.messageRequest.title": "{{count,number}} mesaj kredisi",
|
||||
"plansCommon.messageRequest.titlePerMonth": "{{count,number}} mesaj/ay",
|
||||
"plansCommon.messageRequest.tooltip": "OpenAI modellerini (gpt4 hariç) kullanarak çeşitli planlar için mesaj çağrı kotaları. Limitin üzerindeki mesajlar OpenAI API Anahtarınızı kullanır.",
|
||||
"plansCommon.modelProviders": "Model Sağlayıcılar",
|
||||
"plansCommon.modelProviders": "OpenAI/Anthropic/Llama2/Azure OpenAI/Hugging Face/Replicate Desteği",
|
||||
"plansCommon.month": "ay",
|
||||
"plansCommon.mostPopular": "En Popüler",
|
||||
"plansCommon.planRange.monthly": "Aylık",
|
||||
@ -116,7 +116,7 @@
|
||||
"plansCommon.startNodes.unlimited": "Sınırsız Tetikleyiciler/iş akışı",
|
||||
"plansCommon.support": "Destek",
|
||||
"plansCommon.supportItems.SSOAuthentication": "SSO kimlik doğrulama",
|
||||
"plansCommon.supportItems.agentMode": "Agent Modu",
|
||||
"plansCommon.supportItems.agentMode": "Ajan Modu",
|
||||
"plansCommon.supportItems.bulkUpload": "Toplu doküman yükleme",
|
||||
"plansCommon.supportItems.communityForums": "Topluluk forumları",
|
||||
"plansCommon.supportItems.customIntegration": "Özel entegrasyon ve destek",
|
||||
@ -128,7 +128,7 @@
|
||||
"plansCommon.supportItems.personalizedSupport": "Kişiselleştirilmiş destek",
|
||||
"plansCommon.supportItems.priorityEmail": "Öncelikli e-posta ve sohbet desteği",
|
||||
"plansCommon.supportItems.ragAPIRequest": "RAG API Talepleri",
|
||||
"plansCommon.supportItems.workflow": "Workflow",
|
||||
"plansCommon.supportItems.workflow": "İş Akışı",
|
||||
"plansCommon.talkToSales": "Satışlarla Konuşun",
|
||||
"plansCommon.taxTip": "Tüm abonelik fiyatları (aylık/yıllık) geçerli vergiler (ör. KDV, satış vergisi) hariçtir.",
|
||||
"plansCommon.taxTipSecond": "Bölgenizde geçerli vergi gereksinimleri yoksa, ödeme sayfanızda herhangi bir vergi görünmeyecek ve tüm abonelik süresi boyunca ek bir ücret tahsil edilmeyecektir.",
|
||||
|
||||
@ -96,7 +96,7 @@
|
||||
"appMenus.logAndAnn": "Günlükler & Anlamlandırmalar",
|
||||
"appMenus.logs": "Günlükler",
|
||||
"appMenus.overview": "İzleme",
|
||||
"appMenus.promptEng": "Orchestrate",
|
||||
"appMenus.promptEng": "Düzenle",
|
||||
"appModes.chatApp": "Sohbet Uygulaması",
|
||||
"appModes.completionApp": "Metin Üreteci",
|
||||
"avatar.deleteDescription": "Profil resminizi kaldırmak istediğinize emin misiniz? Hesabınız varsayılan başlangıç avatarını kullanacaktır.",
|
||||
@ -114,7 +114,7 @@
|
||||
"chat.inputPlaceholder": "{{botName}} ile konuş",
|
||||
"chat.renameConversation": "Konuşmayı Yeniden Adlandır",
|
||||
"chat.resend": "Yeniden gönder",
|
||||
"chat.thinking": "Düşünü...",
|
||||
"chat.thinking": "Düşünüyor...",
|
||||
"chat.thought": "Düşünce",
|
||||
"compliance.gdpr": "GDPR DPA",
|
||||
"compliance.iso27001": "ISO 27001:2022 Sertifikası",
|
||||
@ -178,7 +178,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "{{type}} yüklemesi {{size}}'ı aşamaz",
|
||||
"fileUploader.uploadFromComputerReadError": "Dosya okuma başarısız oldu, lütfen tekrar deneyin.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Dosya yükleme başarısız oldu, lütfen tekrar yükleyin.",
|
||||
"imageInput.browse": "tarayıcı",
|
||||
"imageInput.browse": "göz atın",
|
||||
"imageInput.dropImageHere": "Görüntünüzü buraya bırakın veya",
|
||||
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP ve GIF'i destekler",
|
||||
"imageUploader.imageUpload": "Görüntü Yükleme",
|
||||
@ -265,7 +265,7 @@
|
||||
"menus.datasets": "Bilgi",
|
||||
"menus.datasetsTips": "YAKINDA: Kendi metin verilerinizi içe aktarın veya LLM bağlamını geliştirmek için Webhook aracılığıyla gerçek zamanlı veri yazın.",
|
||||
"menus.explore": "Keşfet",
|
||||
"menus.exploreMarketplace": "Marketplace'i Keşfedin",
|
||||
"menus.exploreMarketplace": "Pazar Yeri'ni Keşfedin",
|
||||
"menus.newApp": "Yeni Uygulama",
|
||||
"menus.newDataset": "Bilgi Oluştur",
|
||||
"menus.plugins": "Eklentiler",
|
||||
@ -340,11 +340,25 @@
|
||||
"modelProvider.auth.unAuthorized": "Yetkisiz",
|
||||
"modelProvider.buyQuota": "Kota Satın Al",
|
||||
"modelProvider.callTimes": "Çağrı Süreleri",
|
||||
"modelProvider.card.aiCreditsInUse": "Yapay zeka kredileri kullanımda",
|
||||
"modelProvider.card.aiCreditsOption": "Yapay zeka kredileri",
|
||||
"modelProvider.card.apiKeyOption": "API Anahtarı",
|
||||
"modelProvider.card.apiKeyRequired": "API anahtarı gerekli",
|
||||
"modelProvider.card.apiKeyUnavailableFallback": "API Anahtarı kullanılamıyor, şimdi yapay zeka kredileri kullanılıyor",
|
||||
"modelProvider.card.apiKeyUnavailableFallbackDescription": "Geri dönmek için API anahtarı yapılandırmanızı kontrol edin",
|
||||
"modelProvider.card.buyQuota": "Kota Satın Al",
|
||||
"modelProvider.card.callTimes": "Çağrı Süreleri",
|
||||
"modelProvider.card.creditsExhaustedDescription": "Lütfen <upgradeLink>planınızı yükseltin</upgradeLink> veya bir API anahtarı yapılandırın",
|
||||
"modelProvider.card.creditsExhaustedFallback": "Yapay zeka kredileri tükendi, şimdi API anahtarı kullanılıyor",
|
||||
"modelProvider.card.creditsExhaustedFallbackDescription": "Yapay zeka kredisi önceliğini sürdürmek için <upgradeLink>planınızı yükseltin</upgradeLink>.",
|
||||
"modelProvider.card.creditsExhaustedMessage": "Yapay zeka kredileri tükendi",
|
||||
"modelProvider.card.modelAPI": "{{modelName}} modelleri API Anahtarını kullanıyor.",
|
||||
"modelProvider.card.modelNotSupported": "{{modelName}} modelleri kurulu değil.",
|
||||
"modelProvider.card.modelSupported": "{{modelName}} modelleri bu kotayı kullanıyor.",
|
||||
"modelProvider.card.noApiKeysDescription": "Kendi model kimlik bilgilerinizi kullanmaya başlamak için bir API anahtarı ekleyin.",
|
||||
"modelProvider.card.noApiKeysFallback": "API anahtarı yok, bunun yerine yapay zeka kredileri kullanılıyor",
|
||||
"modelProvider.card.noApiKeysTitle": "Henüz API anahtarı yapılandırılmadı",
|
||||
"modelProvider.card.noAvailableUsage": "Kullanılabilir kullanım yok",
|
||||
"modelProvider.card.onTrial": "Deneme Sürümünde",
|
||||
"modelProvider.card.paid": "Ücretli",
|
||||
"modelProvider.card.priorityUse": "Öncelikli Kullan",
|
||||
@ -353,6 +367,11 @@
|
||||
"modelProvider.card.removeKey": "API Anahtarını Kaldır",
|
||||
"modelProvider.card.tip": "Mesaj kredileri {{modelNames}}'den modelleri destekler. Öncelik ücretli kotaya verilecektir. Ücretsiz kota, ücretli kota tükendiğinde kullanılacaktır.",
|
||||
"modelProvider.card.tokens": "Tokenler",
|
||||
"modelProvider.card.unavailable": "Kullanılamaz",
|
||||
"modelProvider.card.upgradePlan": "planınızı yükseltin",
|
||||
"modelProvider.card.usageLabel": "Kullanım",
|
||||
"modelProvider.card.usagePriority": "Kullanım Önceliği",
|
||||
"modelProvider.card.usagePriorityTip": "Modelleri çalıştırırken önce hangi kaynağın kullanılacağını belirleyin.",
|
||||
"modelProvider.collapse": "Daralt",
|
||||
"modelProvider.config": "Yapılandır",
|
||||
"modelProvider.configLoadBalancing": "Yük Dengelemeyi Yapılandır",
|
||||
@ -387,9 +406,11 @@
|
||||
"modelProvider.model": "Model",
|
||||
"modelProvider.modelAndParameters": "Model ve Parametreler",
|
||||
"modelProvider.modelHasBeenDeprecated": "Bu model kullanım dışıdır",
|
||||
"modelProvider.modelSettings": "Model Ayarları",
|
||||
"modelProvider.models": "Modeller",
|
||||
"modelProvider.modelsNum": "{{num}} Model",
|
||||
"modelProvider.noModelFound": "{{model}} için model bulunamadı",
|
||||
"modelProvider.noneConfigured": "Uygulamaları çalıştırmak için varsayılan bir sistem modeli yapılandırın",
|
||||
"modelProvider.notConfigured": "Sistem modeli henüz tam olarak yapılandırılmadı ve bazı işlevler kullanılamayabilir.",
|
||||
"modelProvider.parameters": "PARAMETRELER",
|
||||
"modelProvider.parametersInvalidRemoved": "Bazı parametreler geçersizdir ve kaldırılmıştır.",
|
||||
@ -403,8 +424,25 @@
|
||||
"modelProvider.resetDate": "{{date}} tarihinde sıfırla",
|
||||
"modelProvider.searchModel": "Model ara",
|
||||
"modelProvider.selectModel": "Modelinizi seçin",
|
||||
"modelProvider.selector.aiCredits": "Yapay zeka kredileri",
|
||||
"modelProvider.selector.apiKeyUnavailable": "API Anahtarı kullanılamıyor",
|
||||
"modelProvider.selector.apiKeyUnavailableTip": "API anahtarı kaldırıldı. Lütfen yeni bir API anahtarı yapılandırın.",
|
||||
"modelProvider.selector.configure": "Yapılandır",
|
||||
"modelProvider.selector.configureRequired": "Yapılandırma gerekli",
|
||||
"modelProvider.selector.creditsExhausted": "Krediler tükendi",
|
||||
"modelProvider.selector.creditsExhaustedTip": "Yapay zeka kredileriniz tükendi. Lütfen planınızı yükseltin veya bir API anahtarı ekleyin.",
|
||||
"modelProvider.selector.disabled": "Devre Dışı",
|
||||
"modelProvider.selector.discoverMoreInMarketplace": "Pazar Yeri'nde daha fazlasını keşfedin",
|
||||
"modelProvider.selector.emptySetting": "Lütfen ayarlara gidip yapılandırın",
|
||||
"modelProvider.selector.emptyTip": "Kullanılabilir model yok",
|
||||
"modelProvider.selector.fromMarketplace": "Pazar Yeri'nden",
|
||||
"modelProvider.selector.incompatible": "Uyumsuz",
|
||||
"modelProvider.selector.incompatibleTip": "Bu model mevcut sürümde kullanılamıyor. Lütfen başka bir kullanılabilir model seçin.",
|
||||
"modelProvider.selector.install": "Yükle",
|
||||
"modelProvider.selector.modelProviderSettings": "Model Sağlayıcı Ayarları",
|
||||
"modelProvider.selector.noProviderConfigured": "Yapılandırılmış model sağlayıcı yok",
|
||||
"modelProvider.selector.noProviderConfiguredDesc": "Yüklemek için Pazar Yeri'ne göz atın veya ayarlardan sağlayıcıları yapılandırın.",
|
||||
"modelProvider.selector.onlyCompatibleModelsShown": "Yalnızca uyumlu modeller gösterilir",
|
||||
"modelProvider.selector.rerankTip": "Lütfen Yeniden Sıralama modelini ayarlayın",
|
||||
"modelProvider.selector.tip": "Bu model kaldırıldı. Lütfen bir model ekleyin veya başka bir model seçin.",
|
||||
"modelProvider.setupModelFirst": "Lütfen önce modelinizi ayarlayın",
|
||||
@ -427,11 +465,11 @@
|
||||
"operation.cancel": "İptal",
|
||||
"operation.change": "Değiştir",
|
||||
"operation.clear": "Temizle",
|
||||
"operation.close": "Kapatmak",
|
||||
"operation.config": "Konfigürasyon",
|
||||
"operation.close": "Kapat",
|
||||
"operation.config": "Yapılandırma",
|
||||
"operation.confirm": "Onayla",
|
||||
"operation.confirmAction": "Lütfen işleminizi onaylayın.",
|
||||
"operation.copied": "Kopya -lanan",
|
||||
"operation.copied": "Kopyalandı",
|
||||
"operation.copy": "Kopyala",
|
||||
"operation.copyImage": "Resmi Kopyala",
|
||||
"operation.create": "Oluştur",
|
||||
@ -463,7 +501,7 @@
|
||||
"operation.openInNewTab": "Yeni sekmede aç",
|
||||
"operation.params": "Parametreler",
|
||||
"operation.refresh": "Yeniden Başlat",
|
||||
"operation.regenerate": "Yenilemek",
|
||||
"operation.regenerate": "Yeniden Oluştur",
|
||||
"operation.reload": "Yeniden Yükle",
|
||||
"operation.remove": "Kaldır",
|
||||
"operation.rename": "Yeniden Adlandır",
|
||||
@ -480,10 +518,10 @@
|
||||
"operation.send": "Gönder",
|
||||
"operation.settings": "Ayarlar",
|
||||
"operation.setup": "Kurulum",
|
||||
"operation.skip": "Gemi",
|
||||
"operation.skip": "Atla",
|
||||
"operation.submit": "Gönder",
|
||||
"operation.sure": "Eminim",
|
||||
"operation.view": "Görünüm",
|
||||
"operation.view": "Görüntüle",
|
||||
"operation.viewDetails": "Detayları Görüntüle",
|
||||
"operation.viewMore": "DAHA FAZLA GÖSTER",
|
||||
"operation.yes": "Evet",
|
||||
@ -500,7 +538,7 @@
|
||||
"promptEditor.context.item.title": "Bağlam",
|
||||
"promptEditor.context.modal.add": "Bağlam Ekle",
|
||||
"promptEditor.context.modal.footer": "Bağlamları aşağıdaki Bağlam bölümünde yönetebilirsiniz.",
|
||||
"promptEditor.context.modal.title": "Bağlamda {{num}} Knowledge",
|
||||
"promptEditor.context.modal.title": "Bağlamda {{num}} Bilgi",
|
||||
"promptEditor.existed": "Zaten prompt içinde mevcut",
|
||||
"promptEditor.history.item.desc": "Tarihi mesaj şablonunu ekle",
|
||||
"promptEditor.history.item.title": "Konuşma Geçmişi",
|
||||
@ -585,7 +623,7 @@
|
||||
"tag.selectorPlaceholder": "Aramak veya oluşturmak için yazın",
|
||||
"theme.auto": "sistem",
|
||||
"theme.dark": "koyu",
|
||||
"theme.light": "ışık",
|
||||
"theme.light": "açık",
|
||||
"theme.theme": "Tema",
|
||||
"toast.close": "Bildirimi kapat",
|
||||
"toast.notifications": "Bildirimler",
|
||||
@ -605,27 +643,27 @@
|
||||
"userProfile.support": "Destek",
|
||||
"userProfile.workspace": "Çalışma Alanı",
|
||||
"voice.language.arTN": "Tunus Arapçası",
|
||||
"voice.language.deDE": "German",
|
||||
"voice.language.enUS": "English",
|
||||
"voice.language.esES": "Spanish",
|
||||
"voice.language.deDE": "Almanca",
|
||||
"voice.language.enUS": "İngilizce",
|
||||
"voice.language.esES": "İspanyolca",
|
||||
"voice.language.faIR": "Farsça",
|
||||
"voice.language.frFR": "French",
|
||||
"voice.language.frFR": "Fransızca",
|
||||
"voice.language.hiIN": "Hintçe",
|
||||
"voice.language.idID": "Indonesian",
|
||||
"voice.language.itIT": "Italian",
|
||||
"voice.language.jaJP": "Japanese",
|
||||
"voice.language.koKR": "Korean",
|
||||
"voice.language.plPL": "Polish",
|
||||
"voice.language.ptBR": "Portuguese",
|
||||
"voice.language.idID": "Endonezyaca",
|
||||
"voice.language.itIT": "İtalyanca",
|
||||
"voice.language.jaJP": "Japonca",
|
||||
"voice.language.koKR": "Korece",
|
||||
"voice.language.plPL": "Lehçe",
|
||||
"voice.language.ptBR": "Portekizce",
|
||||
"voice.language.roRO": "Romence",
|
||||
"voice.language.ruRU": "Russian",
|
||||
"voice.language.ruRU": "Rusça",
|
||||
"voice.language.slSI": "Slovence",
|
||||
"voice.language.thTH": "Thai",
|
||||
"voice.language.thTH": "Tayca",
|
||||
"voice.language.trTR": "Türkçe",
|
||||
"voice.language.ukUA": "Ukrainian",
|
||||
"voice.language.viVN": "Vietnamese",
|
||||
"voice.language.zhHans": "Chinese",
|
||||
"voice.language.zhHant": "Traditional Chinese",
|
||||
"voice.language.ukUA": "Ukraynaca",
|
||||
"voice.language.viVN": "Vietnamca",
|
||||
"voice.language.zhHans": "Çince",
|
||||
"voice.language.zhHant": "Geleneksel Çince",
|
||||
"voiceInput.converting": "Metne dönüştürülüyor...",
|
||||
"voiceInput.notAllow": "mikrofon yetkilendirilmedi",
|
||||
"voiceInput.speaking": "Şimdi konuş...",
|
||||
|
||||
@ -142,8 +142,8 @@
|
||||
"stepTwo.previewChunk": "Önizleme Parçası",
|
||||
"stepTwo.previewChunkCount": "{{count}} Tahmini parçalar",
|
||||
"stepTwo.previewChunkTip": "Önizlemeyi yüklemek için soldaki 'Önizleme Parçası' düğmesini tıklayın",
|
||||
"stepTwo.previewSwitchTipEnd": "token",
|
||||
"stepTwo.previewSwitchTipStart": "Geçerli parça önizlemesi metin formatındadır, soru ve yanıt formatına geçiş ek tüketir",
|
||||
"stepTwo.previewSwitchTipEnd": " token tüketecektir",
|
||||
"stepTwo.previewSwitchTipStart": "Geçerli parça önizlemesi metin formatındadır, soru ve yanıt formatı önizlemesine geçiş ek",
|
||||
"stepTwo.previewTitle": "Önizleme",
|
||||
"stepTwo.previewTitleButton": "Önizleme",
|
||||
"stepTwo.previousStep": "Önceki adım",
|
||||
|
||||
@ -300,12 +300,12 @@
|
||||
"segment.collapseChunks": "Parçaları daraltma",
|
||||
"segment.contentEmpty": "İçerik boş olamaz",
|
||||
"segment.contentPlaceholder": "içeriği buraya ekleyin",
|
||||
"segment.dateTimeFormat": "MM/DD/YYYY HH:mm",
|
||||
"segment.dateTimeFormat": "DD/MM/YYYY HH:mm",
|
||||
"segment.delete": "Bu parçayı silmek istiyor musunuz?",
|
||||
"segment.editChildChunk": "Alt Parçayı Düzenle",
|
||||
"segment.editChunk": "Yığını Düzenle",
|
||||
"segment.editParentChunk": "Üst Parçayı Düzenle",
|
||||
"segment.edited": "DÜZENLEN -MİŞ",
|
||||
"segment.edited": "DÜZENLENMİŞ",
|
||||
"segment.editedAt": "Şurada düzenlendi:",
|
||||
"segment.empty": "Yığın bulunamadı",
|
||||
"segment.expandChunks": "Parçaları genişletme",
|
||||
@ -331,7 +331,7 @@
|
||||
"segment.regenerationSuccessMessage": "Bu pencereyi kapatabilirsiniz.",
|
||||
"segment.regenerationSuccessTitle": "Rejenerasyon tamamlandı",
|
||||
"segment.searchResults_one": "SONUÇ",
|
||||
"segment.searchResults_other": "SONUÇ -LARI",
|
||||
"segment.searchResults_other": "SONUÇLAR",
|
||||
"segment.searchResults_zero": "SONUÇ",
|
||||
"segment.summary": "ÖZET",
|
||||
"segment.summaryPlaceholder": "Daha iyi arama için kısa bir özet yazın…",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user