mirror of
https://github.com/langgenius/dify.git
synced 2026-03-23 16:57:10 +00:00
Compare commits
11 Commits
3-23-lazy-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5cc1c8b75 | ||
|
|
6698b42f97 | ||
|
|
848a041c25 | ||
|
|
29cff809b9 | ||
|
|
30deeb6f1c | ||
|
|
30dd36505c | ||
|
|
65223c8092 | ||
|
|
72e3fcd25f | ||
|
|
4b4a5c058e | ||
|
|
56e0907548 | ||
|
|
d956b919a0 |
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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": []}
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,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,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,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}
|
||||
|
||||
@@ -8,14 +8,12 @@ import AppListContext from '@/context/app-list-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useImportDSL } from '@/hooks/use-import-dsl'
|
||||
import { DSLImportMode } from '@/models/app'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import DSLConfirmModal from '../app/create-from-dsl-modal/dsl-confirm-modal'
|
||||
import CreateAppModal from '../explore/create-app-modal'
|
||||
import TryApp from '../explore/try-app'
|
||||
import List from './list'
|
||||
|
||||
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
|
||||
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
|
||||
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
|
||||
|
||||
const Apps = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useDebounceFn } from 'ahooks'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
@@ -205,12 +205,12 @@ const List: FC<Props> = ({
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="mr-2 flex h-7 items-center space-x-2">
|
||||
<Checkbox checked={isCreatedByMe} onCheck={handleCreatedByMeChange} />
|
||||
<div className="text-sm font-normal text-text-secondary">
|
||||
{t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
</div>
|
||||
</label>
|
||||
<CheckboxWithLabel
|
||||
className="mr-2"
|
||||
label={t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
isChecked={isCreatedByMe}
|
||||
onChange={handleCreatedByMeChange}
|
||||
/>
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
|
||||
@@ -5,12 +5,17 @@ import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
|
||||
import { AMPLITUDE_API_KEY, IS_CLOUD_EDITION } from '@/config'
|
||||
|
||||
export type IAmplitudeProps = {
|
||||
sessionReplaySampleRate?: number
|
||||
}
|
||||
|
||||
// Check if Amplitude should be enabled
|
||||
export const isAmplitudeEnabled = () => {
|
||||
return IS_CLOUD_EDITION && !!AMPLITUDE_API_KEY
|
||||
}
|
||||
|
||||
// Map URL pathname to English page name for consistent Amplitude tracking
|
||||
const getEnglishPageName = (pathname: string): string => {
|
||||
// Remove leading slash and get the first segment
|
||||
@@ -54,7 +59,7 @@ const AmplitudeProvider: FC<IAmplitudeProps> = ({
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
// Only enable in Saas edition with valid API key
|
||||
if (!isAmplitudeEnabled)
|
||||
if (!isAmplitudeEnabled())
|
||||
return
|
||||
|
||||
// Initialize Amplitude
|
||||
|
||||
@@ -2,24 +2,14 @@ import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AmplitudeProvider from '../AmplitudeProvider'
|
||||
import AmplitudeProvider, { isAmplitudeEnabled } from '../AmplitudeProvider'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
AMPLITUDE_API_KEY: 'test-api-key',
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get AMPLITUDE_API_KEY() {
|
||||
return mockConfig.AMPLITUDE_API_KEY
|
||||
},
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.IS_CLOUD_EDITION
|
||||
},
|
||||
get isAmplitudeEnabled() {
|
||||
return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY
|
||||
},
|
||||
}))
|
||||
vi.mock('@/config', () => mockConfig)
|
||||
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
init: vi.fn(),
|
||||
@@ -37,6 +27,22 @@ describe('AmplitudeProvider', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
})
|
||||
|
||||
describe('isAmplitudeEnabled', () => {
|
||||
it('returns true when cloud edition and api key present', () => {
|
||||
expect(isAmplitudeEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when cloud edition but no api key', () => {
|
||||
mockConfig.AMPLITUDE_API_KEY = ''
|
||||
expect(isAmplitudeEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when not cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
expect(isAmplitudeEnabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component', () => {
|
||||
it('initializes amplitude when enabled', () => {
|
||||
render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
|
||||
|
||||
32
web/app/components/base/amplitude/__tests__/index.spec.ts
Normal file
32
web/app/components/base/amplitude/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AmplitudeProvider, { isAmplitudeEnabled } from '../AmplitudeProvider'
|
||||
import indexDefault, {
|
||||
isAmplitudeEnabled as indexIsAmplitudeEnabled,
|
||||
resetUser,
|
||||
setUserId,
|
||||
setUserProperties,
|
||||
trackEvent,
|
||||
} from '../index'
|
||||
import {
|
||||
resetUser as utilsResetUser,
|
||||
setUserId as utilsSetUserId,
|
||||
setUserProperties as utilsSetUserProperties,
|
||||
trackEvent as utilsTrackEvent,
|
||||
} from '../utils'
|
||||
|
||||
describe('Amplitude index exports', () => {
|
||||
it('exports AmplitudeProvider as default', () => {
|
||||
expect(indexDefault).toBe(AmplitudeProvider)
|
||||
})
|
||||
|
||||
it('exports isAmplitudeEnabled', () => {
|
||||
expect(indexIsAmplitudeEnabled).toBe(isAmplitudeEnabled)
|
||||
})
|
||||
|
||||
it('exports utils', () => {
|
||||
expect(resetUser).toBe(utilsResetUser)
|
||||
expect(setUserId).toBe(utilsSetUserId)
|
||||
expect(setUserProperties).toBe(utilsSetUserProperties)
|
||||
expect(trackEvent).toBe(utilsTrackEvent)
|
||||
})
|
||||
})
|
||||
@@ -20,10 +20,8 @@ const MockIdentify = vi.hoisted(() =>
|
||||
},
|
||||
)
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get isAmplitudeEnabled() {
|
||||
return mockState.enabled
|
||||
},
|
||||
vi.mock('../AmplitudeProvider', () => ({
|
||||
isAmplitudeEnabled: () => mockState.enabled,
|
||||
}))
|
||||
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default } from './lazy-amplitude-provider'
|
||||
export { default, isAmplitudeEnabled } from './AmplitudeProvider'
|
||||
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { IAmplitudeProps } from './AmplitudeProvider'
|
||||
import dynamic from '@/next/dynamic'
|
||||
|
||||
const AmplitudeProvider = dynamic(() => import('./AmplitudeProvider'), { ssr: false })
|
||||
|
||||
const LazyAmplitudeProvider: FC<IAmplitudeProps> = props => <AmplitudeProvider {...props} />
|
||||
|
||||
export default LazyAmplitudeProvider
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { isAmplitudeEnabled } from '@/config'
|
||||
import { isAmplitudeEnabled } from './AmplitudeProvider'
|
||||
|
||||
/**
|
||||
* Track custom event
|
||||
@@ -7,7 +7,7 @@ import { isAmplitudeEnabled } from '@/config'
|
||||
* @param eventProperties Event properties (optional)
|
||||
*/
|
||||
export const trackEvent = (eventName: string, eventProperties?: Record<string, any>) => {
|
||||
if (!isAmplitudeEnabled)
|
||||
if (!isAmplitudeEnabled())
|
||||
return
|
||||
amplitude.track(eventName, eventProperties)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export const trackEvent = (eventName: string, eventProperties?: Record<string, a
|
||||
* @param userId User ID
|
||||
*/
|
||||
export const setUserId = (userId: string) => {
|
||||
if (!isAmplitudeEnabled)
|
||||
if (!isAmplitudeEnabled())
|
||||
return
|
||||
amplitude.setUserId(userId)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const setUserId = (userId: string) => {
|
||||
* @param properties User properties
|
||||
*/
|
||||
export const setUserProperties = (properties: Record<string, any>) => {
|
||||
if (!isAmplitudeEnabled)
|
||||
if (!isAmplitudeEnabled())
|
||||
return
|
||||
const identifyEvent = new amplitude.Identify()
|
||||
Object.entries(properties).forEach(([key, value]) => {
|
||||
@@ -40,7 +40,7 @@ export const setUserProperties = (properties: Record<string, any>) => {
|
||||
* Reset user (e.g., when user logs out)
|
||||
*/
|
||||
export const resetUser = () => {
|
||||
if (!isAmplitudeEnabled)
|
||||
if (!isAmplitudeEnabled())
|
||||
return
|
||||
amplitude.reset()
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { IS_DEV } from '@/config'
|
||||
import dynamic from '@/next/dynamic'
|
||||
|
||||
const Agentation = dynamic(() => import('agentation').then(module => module.Agentation), { ssr: false })
|
||||
|
||||
export function AgentationLoader() {
|
||||
if (!IS_DEV)
|
||||
return null
|
||||
|
||||
return <Agentation />
|
||||
}
|
||||
@@ -69,7 +69,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
const { mockConfig, mockEnv } = vi.hoisted(() => ({
|
||||
mockConfig: {
|
||||
IS_CLOUD_EDITION: false,
|
||||
AMPLITUDE_API_KEY: '',
|
||||
ZENDESK_WIDGET_KEY: '',
|
||||
SUPPORT_EMAIL_ADDRESS: '',
|
||||
},
|
||||
@@ -81,8 +80,6 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
|
||||
}))
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
|
||||
get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY },
|
||||
get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY },
|
||||
get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
|
||||
get SUPPORT_EMAIL_ADDRESS() { return mockConfig.SUPPORT_EMAIL_ADDRESS },
|
||||
IS_DEV: false,
|
||||
|
||||
@@ -9,18 +9,16 @@ import { flatten } from 'es-toolkit/compat'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog'
|
||||
import CreateAppModal from '@/app/components/app/create-app-modal'
|
||||
import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useParams } from '@/next/navigation'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Nav from '../nav'
|
||||
|
||||
const CreateAppTemplateDialog = dynamic(() => import('@/app/components/app/create-app-dialog'), { ssr: false })
|
||||
const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), { ssr: false })
|
||||
const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), { ssr: false })
|
||||
|
||||
const AppNav = () => {
|
||||
const { t } = useTranslation()
|
||||
const { appId } = useParams()
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { IS_DEV } from '@/config'
|
||||
import { env } from '@/env'
|
||||
import dynamic from '@/next/dynamic'
|
||||
|
||||
const SentryInitializer = dynamic(() => import('./sentry-initializer'), { ssr: false })
|
||||
|
||||
const LazySentryInitializer = () => {
|
||||
if (IS_DEV || !env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
return null
|
||||
|
||||
return <SentryInitializer />
|
||||
}
|
||||
|
||||
export default LazySentryInitializer
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import * as Sentry from '@sentry/react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { IS_DEV } from '@/config'
|
||||
import { env } from '@/env'
|
||||
|
||||
const SentryInitializer = () => {
|
||||
const SentryInitializer = ({
|
||||
children,
|
||||
}: { children: React.ReactElement }) => {
|
||||
useEffect(() => {
|
||||
const SENTRY_DSN = env.NEXT_PUBLIC_SENTRY_DSN
|
||||
if (!IS_DEV && SENTRY_DSN) {
|
||||
@@ -21,7 +24,7 @@ const SentryInitializer = () => {
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
return null
|
||||
return children
|
||||
}
|
||||
|
||||
export default SentryInitializer
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Viewport } from '@/next'
|
||||
import { Agentation } from 'agentation'
|
||||
import { Provider as JotaiProvider } from 'jotai/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import { IS_DEV } from '@/config'
|
||||
import GlobalPublicStoreProvider from '@/context/global-public-context'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import { getDatasetMap } from '@/env'
|
||||
@@ -10,10 +12,9 @@ import { ToastProvider } from './components/base/toast'
|
||||
import { ToastHost } from './components/base/ui/toast'
|
||||
import { TooltipProvider } from './components/base/ui/tooltip'
|
||||
import BrowserInitializer from './components/browser-initializer'
|
||||
import { AgentationLoader } from './components/devtools/agentation-loader'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
import LazySentryInitializer from './components/lazy-sentry-initializer'
|
||||
import { I18nServerProvider } from './components/provider/i18n-server'
|
||||
import SentryInitializer from './components/sentry-initializer'
|
||||
import RoutePrefixHandle from './routePrefixHandle'
|
||||
import './styles/globals.css'
|
||||
import './styles/markdown.scss'
|
||||
@@ -56,7 +57,6 @@ const LocaleLayout = async ({
|
||||
className="h-full select-auto"
|
||||
{...datasetMap}
|
||||
>
|
||||
<LazySentryInitializer />
|
||||
<div className="isolate h-full">
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
@@ -68,24 +68,26 @@ const LocaleLayout = async ({
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<BrowserInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastHost timeout={5000} limit={3} />
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
<TooltipProvider delay={300} closeDelay={200}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastHost timeout={5000} limit={3} />
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
<TooltipProvider delay={300} closeDelay={200}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
</BrowserInitializer>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</JotaiProvider>
|
||||
<RoutePrefixHandle />
|
||||
<AgentationLoader />
|
||||
{IS_DEV && <Agentation />}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -42,8 +42,6 @@ export const AMPLITUDE_API_KEY = getStringConfig(
|
||||
'',
|
||||
)
|
||||
|
||||
export const isAmplitudeEnabled = IS_CLOUD_EDITION && !!AMPLITUDE_API_KEY
|
||||
|
||||
export const IS_DEV = process.env.NODE_ENV === 'development'
|
||||
export const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
|
||||
@@ -1501,6 +1501,11 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/amplitude/AmplitudeProvider.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/amplitude/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"input.placeholder": "Bir metin girin, kısa bir bildirim cümlesi önerilir.",
|
||||
"input.testing": "Test Ediliyor",
|
||||
"input.title": "Kaynak metin",
|
||||
"keyword": "Anahtar kelime -ler",
|
||||
"keyword": "Anahtar Kelimeler",
|
||||
"noRecentTip": "Burada son sorgu sonuçları yok",
|
||||
"open": "Açık",
|
||||
"records": "Kayıt",
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
"onlineDrive.notSupportedFileType": "Bu dosya türü desteklenmiyor",
|
||||
"onlineDrive.resetKeywords": "Anahtar kelimeleri sıfırlama",
|
||||
"operations.backToDataSource": "Veri Kaynağına Geri Dön",
|
||||
"operations.choose": "Seçmek",
|
||||
"operations.convert": "Dönüştürmek",
|
||||
"operations.choose": "Seç",
|
||||
"operations.convert": "Dönüştür",
|
||||
"operations.dataSource": "Veri Kaynağı",
|
||||
"operations.details": "Şey",
|
||||
"operations.editInfo": "Bilgileri düzenle",
|
||||
@@ -85,7 +85,7 @@
|
||||
"publishTemplate.success.learnMore": "Daha fazla bilgi edinin",
|
||||
"publishTemplate.success.message": "İşlem hattı şablonu yayımlandı",
|
||||
"publishTemplate.success.tip": "Bu şablonu oluşturma sayfasında kullanabilirsiniz.",
|
||||
"templates.customized": "Özel -leştirilmiş",
|
||||
"templates.customized": "Özelleştirilmiş",
|
||||
"testRun.dataSource.localFiles": "Yerel Dosyalar",
|
||||
"testRun.notion.docTitle": "Kavram belgeleri",
|
||||
"testRun.notion.title": "Notion Sayfalarını Seçin",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"allExternalTip": "Yalnızca harici bilgileri kullanırken, kullanıcı Rerank modelinin etkinleştirilip etkinleştirilmeyeceğini seçebilir. Etkinleştirilmezse, alınan parçalar puanlara göre sıralanır. Farklı bilgi tabanlarının erişim stratejileri tutarsız olduğunda, yanlış olacaktır.",
|
||||
"allExternalTip": "Yalnızca harici bilgileri kullanırken, kullanıcı Yeniden Sıralama modelinin etkinleştirilip etkinleştirilmeyeceğini seçebilir. Etkinleştirilmezse, alınan parçalar puanlara göre sıralanır. Farklı bilgi tabanlarının erişim stratejileri tutarsız olduğunda, yanlış olacaktır.",
|
||||
"allKnowledge": "Tüm Bilgiler",
|
||||
"allKnowledgeDescription": "Bu çalışma alanındaki tüm bilgileri görüntülemek için seçin. Yalnızca Çalışma Alanı Sahibi tüm bilgileri yönetebilir.",
|
||||
"appCount": " bağlı uygulamalar",
|
||||
"batchAction.archive": "Arşiv",
|
||||
"batchAction.cancel": "İptal",
|
||||
"batchAction.delete": "Silmek",
|
||||
"batchAction.disable": "Devre dışı bırakmak",
|
||||
"batchAction.delete": "Sil",
|
||||
"batchAction.disable": "Devre Dışı Bırak",
|
||||
"batchAction.download": "İndir",
|
||||
"batchAction.enable": "Etkinleştirmek",
|
||||
"batchAction.enable": "Etkinleştir",
|
||||
"batchAction.reIndex": "Yeniden dizinle",
|
||||
"batchAction.selected": "Seçilmiş",
|
||||
"chunkingMode.general": "Genel",
|
||||
@@ -32,7 +32,7 @@
|
||||
"createDatasetIntro": "Kendi metin verilerinizi içe aktarın veya Webhook aracılığıyla gerçek zamanlı olarak veri yazın, LLM bağlamını geliştirin.",
|
||||
"createExternalAPI": "Harici bilgi API'si ekleme",
|
||||
"createFromPipeline": "Bilgi İşlem Hattından Oluşturun",
|
||||
"createNewExternalAPI": "Yeni bir External Knowledge API oluşturma",
|
||||
"createNewExternalAPI": "Yeni bir Harici Bilgi API'si oluşturma",
|
||||
"datasetDeleteFailed": "Bilgi silinemedi",
|
||||
"datasetDeleted": "Bilgi silindi",
|
||||
"datasetUsedByApp": "Bilgi bazı uygulamalar tarafından kullanılıyor. Uygulamalar artık bu Bilgiyi kullanamayacak ve tüm prompt yapılandırmaları ve günlükler kalıcı olarak silinecektir.",
|
||||
@@ -45,7 +45,7 @@
|
||||
"deleteExternalAPIConfirmWarningContent.content.front": "Bu Harici Bilgi API'si aşağıdakilerle bağlantılıdır",
|
||||
"deleteExternalAPIConfirmWarningContent.noConnectionContent": "Bu API'yi sildiğinizden emin misiniz?",
|
||||
"deleteExternalAPIConfirmWarningContent.title.end": "?",
|
||||
"deleteExternalAPIConfirmWarningContent.title.front": "Silmek",
|
||||
"deleteExternalAPIConfirmWarningContent.title.front": "Sil",
|
||||
"didYouKnow": "Biliyor muydunuz?",
|
||||
"docAllEnabled_one": "{{count}} belgesi etkinleştirildi",
|
||||
"docAllEnabled_other": "Tüm {{count}} belgeleri etkinleştirildi",
|
||||
@@ -54,29 +54,29 @@
|
||||
"documentsDisabled": "{{num}} belge devre dışı - 30 günden uzun süre etkin değil",
|
||||
"editExternalAPIConfirmWarningContent.end": "Dışsal bilgi ve bu değişiklik hepsine uygulanacaktır. Bu değişikliği kaydetmek istediğinizden emin misiniz?",
|
||||
"editExternalAPIConfirmWarningContent.front": "Bu Harici Bilgi API'si aşağıdakilerle bağlantılıdır",
|
||||
"editExternalAPIFormTitle": "External Knowledge API'yi düzenleme",
|
||||
"editExternalAPIFormTitle": "Harici Bilgi API'sini düzenleme",
|
||||
"editExternalAPIFormWarning.end": "Dış bilgi",
|
||||
"editExternalAPIFormWarning.front": "Bu Harici API aşağıdakilere bağlıdır:",
|
||||
"editExternalAPITooltipTitle": "BAĞLANTILI BILGI",
|
||||
"editExternalAPITooltipTitle": "BAĞLANTILI BİLGİ",
|
||||
"embeddingModelNotAvailable": "Gömme modeli mevcut değil.",
|
||||
"enable": "Etkinleştirmek",
|
||||
"enable": "Etkinleştir",
|
||||
"externalAPI": "Harici API",
|
||||
"externalAPIForm.apiKey": "API Anahtarı",
|
||||
"externalAPIForm.cancel": "İptal",
|
||||
"externalAPIForm.edit": "Düzenlemek",
|
||||
"externalAPIForm.edit": "Düzenle",
|
||||
"externalAPIForm.encrypted.end": "Teknoloji.",
|
||||
"externalAPIForm.encrypted.front": "API Token'ınız kullanılarak şifrelenecek ve saklanacaktır.",
|
||||
"externalAPIForm.endpoint": "API Uç Noktası",
|
||||
"externalAPIForm.name": "Ad",
|
||||
"externalAPIForm.save": "Kurtarmak",
|
||||
"externalAPIForm.save": "Kaydet",
|
||||
"externalAPIPanelDescription": "Harici bilgi API'si, Dify dışındaki bir bilgi bankasına bağlanmak ve bu bilgi bankasından bilgi almak için kullanılır.",
|
||||
"externalAPIPanelDocumentation": "External Knowledge API'nin nasıl oluşturulacağını öğrenin",
|
||||
"externalAPIPanelDocumentation": "Harici Bilgi API'sinin nasıl oluşturulacağını öğrenin",
|
||||
"externalAPIPanelTitle": "Harici Bilgi API'si",
|
||||
"externalKnowledgeBase": "Harici Bilgi Bankası",
|
||||
"externalKnowledgeDescription": "Bilgi Açıklaması",
|
||||
"externalKnowledgeDescriptionPlaceholder": "Bu Bilgi Bankası'nda neler olduğunu açıklayın (isteğe bağlı)",
|
||||
"externalKnowledgeForm.cancel": "İptal",
|
||||
"externalKnowledgeForm.connect": "Bağlamak",
|
||||
"externalKnowledgeForm.connect": "Bağla",
|
||||
"externalKnowledgeForm.connectedFailed": "Harici Bilgi Tabanına bağlanılamadı",
|
||||
"externalKnowledgeForm.connectedSuccess": "Harici Bilgi Tabanı başarıyla bağlandı",
|
||||
"externalKnowledgeId": "Harici Bilgi Kimliği",
|
||||
@@ -126,7 +126,7 @@
|
||||
"metadata.datasetMetadata.deleteContent": "Bu {{name}} meta verisini silmek istediğinizden emin misiniz?",
|
||||
"metadata.datasetMetadata.deleteTitle": "Silmek için onayla",
|
||||
"metadata.datasetMetadata.description": "Bu bilgideki tüm meta verileri yönetebilirsiniz. Değişiklikler her belgeye senkronize edilecektir.",
|
||||
"metadata.datasetMetadata.disabled": "Devre dışı bırakıldı.",
|
||||
"metadata.datasetMetadata.disabled": "Devre Dışı",
|
||||
"metadata.datasetMetadata.name": "İsim",
|
||||
"metadata.datasetMetadata.namePlaceholder": "Meta veri adı",
|
||||
"metadata.datasetMetadata.rename": "Yeniden Adlandır",
|
||||
@@ -140,8 +140,8 @@
|
||||
"metadata.selectMetadata.newAction": "Yeni Veriler",
|
||||
"metadata.selectMetadata.search": "Arama meta verileri",
|
||||
"mixtureHighQualityAndEconomicTip": "Yüksek kaliteli ve ekonomik bilgi tabanlarının karışımı için Yeniden Sıralama modeli gereklidir.",
|
||||
"mixtureInternalAndExternalTip": "Rerank modeli, iç ve dış bilgilerin karışımı için gereklidir.",
|
||||
"multimodal": "Multimodal",
|
||||
"mixtureInternalAndExternalTip": "Yeniden Sıralama modeli, iç ve dış bilgilerin karışımı için gereklidir.",
|
||||
"multimodal": "Çok Modlu",
|
||||
"nTo1RetrievalLegacy": "Geri alım stratejisinin optimizasyonu ve yükseltilmesi nedeniyle, N-to-1 geri alımı Eylül ayında resmi olarak kullanım dışı kalacaktır. O zamana kadar normal şekilde kullanabilirsiniz.",
|
||||
"nTo1RetrievalLegacyLink": "Daha fazla bilgi edin",
|
||||
"nTo1RetrievalLegacyLinkText": "N-1 geri alma Eylül ayında resmi olarak kullanımdan kaldırılacaktır.",
|
||||
@@ -172,12 +172,12 @@
|
||||
"serviceApi.card.apiReference": "API Referansı",
|
||||
"serviceApi.card.endpoint": "Hizmet API Uç Noktası",
|
||||
"serviceApi.card.title": "Backend servis api",
|
||||
"serviceApi.disabled": "Engelli",
|
||||
"serviceApi.enabled": "Hizmette",
|
||||
"serviceApi.disabled": "Devre Dışı",
|
||||
"serviceApi.enabled": "Etkin",
|
||||
"serviceApi.title": "Servis API'si",
|
||||
"unavailable": "Kullanılamıyor",
|
||||
"unknownError": "Bilinmeyen hata",
|
||||
"updated": "Güncel -leştirilmiş",
|
||||
"updated": "Güncellendi",
|
||||
"weightedScore.customized": "Özelleştirilmiş",
|
||||
"weightedScore.description": "Verilen ağırlıkları ayarlayarak bu yeniden sıralama stratejisi, anlamsal mı yoksa anahtar kelime eşleştirmesini mi önceliklendireceğini belirler.",
|
||||
"weightedScore.keyword": "Anahtar Kelime",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"checkCode.validTime": "Kodun 5 dakika boyunca geçerli olduğunu unutmayın",
|
||||
"checkCode.verificationCode": "Doğrulama kodu",
|
||||
"checkCode.verificationCodePlaceholder": "6 haneli kodu girin",
|
||||
"checkCode.verify": "Doğrulamak",
|
||||
"checkCode.verify": "Doğrula",
|
||||
"checkEmailForResetLink": "Şifrenizi sıfırlamak için bir bağlantı içeren e-postayı kontrol edin. Birkaç dakika içinde görünmezse, spam klasörünüzü kontrol ettiğinizden emin olun.",
|
||||
"confirmPassword": "Şifreyi Onayla",
|
||||
"confirmPasswordPlaceholder": "Yeni şifrenizi onaylayın",
|
||||
@@ -57,8 +57,8 @@
|
||||
"invitationCode": "Davet Kodu",
|
||||
"invitationCodePlaceholder": "Davet kodunuz",
|
||||
"join": "Katıl",
|
||||
"joinTipEnd": "takımına davet ediyor",
|
||||
"joinTipStart": "Sizi",
|
||||
"joinTipEnd": " takımına Dify'de davet ediyor",
|
||||
"joinTipStart": "Sizi ",
|
||||
"license.link": "Açık Kaynak Lisansını",
|
||||
"license.tip": "Dify Community Edition'ı başlatmadan önce GitHub'daki",
|
||||
"licenseExpired": "Lisansın Süresi Doldu",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"common.confirmPublishContent": "Bilgi işlem hattı başarıyla yayımlandıktan sonra, bu bilgi bankasının öbek yapısı değiştirilemez. Yayınlamak istediğinizden emin misiniz?",
|
||||
"common.goToAddDocuments": "Belge eklemeye git",
|
||||
"common.preparingDataSource": "Veri Kaynağını Hazırlama",
|
||||
"common.processing": "Işleme",
|
||||
"common.processing": "İşleme",
|
||||
"common.publishAs": "Bilgi İşlem Hattı Olarak Yayımlama",
|
||||
"common.publishAsPipeline.description": "Bilgi açıklaması",
|
||||
"common.publishAsPipeline.descriptionPlaceholder": "Lütfen bu Bilgi İşlem Hattının açıklamasını girin. (İsteğe bağlı)",
|
||||
@@ -12,13 +12,13 @@
|
||||
"common.reRun": "Yeniden çalıştır",
|
||||
"common.testRun": "Test Çalıştırması",
|
||||
"inputField.create": "Kullanıcı giriş alanı oluştur",
|
||||
"inputField.manage": "Yönetmek",
|
||||
"inputField.manage": "Yönet",
|
||||
"publishToast.desc": "İşlem hattı yayımlanmadığında, bilgi bankası düğümündeki öbek yapısını değiştirebilirsiniz ve işlem hattı düzenlemesi ve değişiklikleri otomatik olarak taslak olarak kaydedilir.",
|
||||
"publishToast.title": "Bu işlem hattı henüz yayımlanmadı",
|
||||
"ragToolSuggestions.noRecommendationPlugins": "Önerilen eklenti yok, daha fazlasını <CustomLink>Marketplace</CustomLink> içinde bulabilirsiniz",
|
||||
"ragToolSuggestions.noRecommendationPlugins": "Önerilen eklenti yok, daha fazlasını <CustomLink>Pazar Yeri</CustomLink> içinde bulabilirsiniz",
|
||||
"ragToolSuggestions.title": "RAG için Öneriler",
|
||||
"result.resultPreview.error": "Yürütme sırasında hata oluştu",
|
||||
"result.resultPreview.footerTip": "Test çalıştırma modunda, {{count}} parçaya kadar önizleme yapabilirsiniz",
|
||||
"result.resultPreview.loading": "Işleme... Lütfen bekleyin",
|
||||
"result.resultPreview.loading": "İşleniyor... Lütfen bekleyin",
|
||||
"result.resultPreview.viewDetails": "Ayrıntıları görüntüleme"
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
"tags.medical": "Tıbbi",
|
||||
"tags.news": "Haberler",
|
||||
"tags.other": "Diğer",
|
||||
"tags.productivity": "Verimli -lik",
|
||||
"tags.productivity": "Verimlilik",
|
||||
"tags.rag": "PAÇAVRA",
|
||||
"tags.search": "Aramak",
|
||||
"tags.search": "Ara",
|
||||
"tags.social": "Sosyal",
|
||||
"tags.travel": "Seyahat",
|
||||
"tags.utilities": "Yardımcı program",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"action.delete": "Eklentiyi kaldır",
|
||||
"action.deleteContentLeft": "Kaldırmak ister misiniz",
|
||||
"action.deleteContentRight": "eklenti?",
|
||||
"action.deleteSuccess": "Eklenti başarıyla kaldırıldı",
|
||||
"action.pluginInfo": "Eklenti bilgisi",
|
||||
"action.usedInApps": "Bu eklenti {{num}} uygulamalarında kullanılıyor.",
|
||||
"allCategories": "Tüm Kategoriler",
|
||||
@@ -48,12 +49,12 @@
|
||||
"autoUpdate.pluginDowngradeWarning.title": "Eklenti Düşürme",
|
||||
"autoUpdate.specifyPluginsToUpdate": "Güncellemek için eklentileri belirtin",
|
||||
"autoUpdate.strategy.disabled.description": "Eklentiler otomatik olarak güncellenmeyecek",
|
||||
"autoUpdate.strategy.disabled.name": "Engelli",
|
||||
"autoUpdate.strategy.disabled.name": "Devre Dışı",
|
||||
"autoUpdate.strategy.fixOnly.description": "Yalnızca yamanın sürüm güncellemeleri için otomatik güncelleme (örneğin, 1.0.1 → 1.0.2). Küçük sürüm değişiklikleri güncellemeleri tetiklemez.",
|
||||
"autoUpdate.strategy.fixOnly.name": "Sadece Düzelt",
|
||||
"autoUpdate.strategy.fixOnly.selectedDescription": "Sadece yamanın versiyonları için otomatik güncelleme",
|
||||
"autoUpdate.strategy.latest.description": "Her zaman en son sürüme güncelle",
|
||||
"autoUpdate.strategy.latest.name": "Son",
|
||||
"autoUpdate.strategy.latest.name": "En Son",
|
||||
"autoUpdate.strategy.latest.selectedDescription": "Her zaman en son sürüme güncelle",
|
||||
"autoUpdate.updateSettings": "Ayarları Güncelle",
|
||||
"autoUpdate.updateTime": "Güncelleme zamanı",
|
||||
@@ -67,25 +68,25 @@
|
||||
"category.all": "Tüm",
|
||||
"category.bundles": "Paketler",
|
||||
"category.datasources": "Veri Kaynakları",
|
||||
"category.extensions": "Uzantı -ları",
|
||||
"category.models": "Model",
|
||||
"category.tools": "Araçları",
|
||||
"category.extensions": "Uzantılar",
|
||||
"category.models": "Modeller",
|
||||
"category.tools": "Araçlar",
|
||||
"category.triggers": "Tetikleyiciler",
|
||||
"categorySingle.agent": "Temsilci Stratejisi",
|
||||
"categorySingle.bundle": "Bohça",
|
||||
"categorySingle.agent": "Ajan Stratejisi",
|
||||
"categorySingle.bundle": "Paket",
|
||||
"categorySingle.datasource": "Veri Kaynağı",
|
||||
"categorySingle.extension": "Uzantı",
|
||||
"categorySingle.model": "Model",
|
||||
"categorySingle.tool": "Alet",
|
||||
"categorySingle.trigger": "Tetik",
|
||||
"categorySingle.tool": "Araç",
|
||||
"categorySingle.trigger": "Tetikleyici",
|
||||
"debugInfo.title": "Hata ayıklama",
|
||||
"debugInfo.viewDocs": "Belgeleri Görüntüle",
|
||||
"deprecated": "Kaldırılmış",
|
||||
"detailPanel.actionNum": "{{num}} {{action}} DAHİL",
|
||||
"detailPanel.categoryTip.debugging": "Hata Ayıklama Eklentisi",
|
||||
"detailPanel.categoryTip.github": "Github'dan yüklendi",
|
||||
"detailPanel.categoryTip.github": "GitHub'dan yüklendi",
|
||||
"detailPanel.categoryTip.local": "Yerel Eklenti",
|
||||
"detailPanel.categoryTip.marketplace": "Marketplace'ten yüklendi",
|
||||
"detailPanel.categoryTip.marketplace": "Pazar Yeri'nden yüklendi",
|
||||
"detailPanel.configureApp": "Uygulamayı Yapılandır",
|
||||
"detailPanel.configureModel": "Modeli yapılandırma",
|
||||
"detailPanel.configureTool": "Aracı yapılandır",
|
||||
@@ -95,7 +96,7 @@
|
||||
"detailPanel.deprecation.reason.businessAdjustments": "iş ayarlamaları",
|
||||
"detailPanel.deprecation.reason.noMaintainer": "bakımcı yok",
|
||||
"detailPanel.deprecation.reason.ownershipTransferred": "mülkiyet devredildi",
|
||||
"detailPanel.disabled": "Sakat",
|
||||
"detailPanel.disabled": "Devre Dışı",
|
||||
"detailPanel.endpointDeleteContent": "{{name}} öğesini kaldırmak ister misiniz?",
|
||||
"detailPanel.endpointDeleteTip": "Uç Noktayı Kaldır",
|
||||
"detailPanel.endpointDisableContent": "{{name}} öğesini devre dışı bırakmak ister misiniz?",
|
||||
@@ -109,18 +110,19 @@
|
||||
"detailPanel.modelNum": "{{num}} DAHİL OLAN MODELLER",
|
||||
"detailPanel.operation.back": "Geri",
|
||||
"detailPanel.operation.checkUpdate": "Güncellemeyi Kontrol Et",
|
||||
"detailPanel.operation.detail": "Şey",
|
||||
"detailPanel.operation.detail": "Detay",
|
||||
"detailPanel.operation.info": "Eklenti Bilgileri",
|
||||
"detailPanel.operation.install": "Yüklemek",
|
||||
"detailPanel.operation.remove": "Kaldırmak",
|
||||
"detailPanel.operation.update": "Güncelleştirmek",
|
||||
"detailPanel.operation.viewDetail": "ayrıntılara bakın",
|
||||
"detailPanel.operation.install": "Yükle",
|
||||
"detailPanel.operation.remove": "Kaldır",
|
||||
"detailPanel.operation.update": "Güncelle",
|
||||
"detailPanel.operation.updateTooltip": "En son modellere erişmek için güncelleyin.",
|
||||
"detailPanel.operation.viewDetail": "Pazar Yeri'nde görüntüle",
|
||||
"detailPanel.serviceOk": "Servis Tamam",
|
||||
"detailPanel.strategyNum": "{{num}} {{strategy}} DAHİL",
|
||||
"detailPanel.switchVersion": "Sürümü Değiştir",
|
||||
"detailPanel.toolSelector.auto": "Otomatik",
|
||||
"detailPanel.toolSelector.descriptionLabel": "Araç açıklaması",
|
||||
"detailPanel.toolSelector.descriptionPlaceholder": "Aletin amacının kısa açıklaması, örneğin belirli bir konum için sıcaklığı elde edin.",
|
||||
"detailPanel.toolSelector.descriptionPlaceholder": "Aracın amacının kısa açıklaması, örneğin belirli bir konum için sıcaklığı elde edin.",
|
||||
"detailPanel.toolSelector.empty": "Araç eklemek için '+' düğmesini tıklayın. Birden fazla araç ekleyebilirsiniz.",
|
||||
"detailPanel.toolSelector.params": "AKIL YÜRÜTME YAPILANDIRMASI",
|
||||
"detailPanel.toolSelector.paramsTip1": "LLM çıkarım parametrelerini kontrol eder.",
|
||||
@@ -128,7 +130,7 @@
|
||||
"detailPanel.toolSelector.placeholder": "Bir araç seçin...",
|
||||
"detailPanel.toolSelector.settings": "KULLANICI AYARLARI",
|
||||
"detailPanel.toolSelector.title": "Araç ekle",
|
||||
"detailPanel.toolSelector.toolLabel": "Alet",
|
||||
"detailPanel.toolSelector.toolLabel": "Araç",
|
||||
"detailPanel.toolSelector.toolSetting": "Araç Ayarları",
|
||||
"detailPanel.toolSelector.uninstalledContent": "Bu eklenti yerel/GitHub deposundan yüklenir. Lütfen kurulumdan sonra kullanın.",
|
||||
"detailPanel.toolSelector.uninstalledLink": "Eklentilerde Yönet",
|
||||
@@ -142,11 +144,11 @@
|
||||
"error.fetchReleasesError": "Sürümler alınamıyor. Lütfen daha sonra tekrar deneyin.",
|
||||
"error.inValidGitHubUrl": "Geçersiz GitHub URL'si. Lütfen şu biçimde geçerli bir URL girin: https://github.com/owner/repo",
|
||||
"error.noReleasesFound": "Yayın bulunamadı. Lütfen GitHub deposunu veya giriş URL'sini kontrol edin.",
|
||||
"findMoreInMarketplace": "Marketplace'te daha fazla bilgi edinin",
|
||||
"findMoreInMarketplace": "Pazar Yeri'nde daha fazla bilgi edinin",
|
||||
"from": "Kaynak",
|
||||
"fromMarketplace": "Pazar Yerinden",
|
||||
"fromMarketplace": "Pazar Yeri'nden",
|
||||
"install": "{{num}} yükleme",
|
||||
"installAction": "Yüklemek",
|
||||
"installAction": "Yükle",
|
||||
"installFrom": "ŞURADAN YÜKLE",
|
||||
"installFromGitHub.gitHubRepo": "GitHub deposu",
|
||||
"installFromGitHub.installFailed": "Yükleme başarısız oldu",
|
||||
@@ -161,10 +163,10 @@
|
||||
"installFromGitHub.uploadFailed": "Karşıya yükleme başarısız oldu",
|
||||
"installModal.back": "Geri",
|
||||
"installModal.cancel": "İptal",
|
||||
"installModal.close": "Kapatmak",
|
||||
"installModal.close": "Kapat",
|
||||
"installModal.dropPluginToInstall": "Yüklemek için eklenti paketini buraya bırakın",
|
||||
"installModal.fromTrustSource": "Lütfen eklentileri yalnızca <trustSource>güvenilir bir kaynaktan</trustSource> yüklediğinizden emin olun.",
|
||||
"installModal.install": "Yüklemek",
|
||||
"installModal.install": "Yükle",
|
||||
"installModal.installComplete": "Kurulum tamamlandı",
|
||||
"installModal.installFailed": "Yükleme başarısız oldu",
|
||||
"installModal.installFailedDesc": "Eklenti yüklenemedi, başarısız oldu.",
|
||||
@@ -176,7 +178,7 @@
|
||||
"installModal.labels.package": "Paket",
|
||||
"installModal.labels.repository": "Depo",
|
||||
"installModal.labels.version": "Sürüm",
|
||||
"installModal.next": "Önümüzdeki",
|
||||
"installModal.next": "İleri",
|
||||
"installModal.pluginLoadError": "Eklenti yükleme hatası",
|
||||
"installModal.pluginLoadErrorDesc": "Bu eklenti yüklenmeyecek",
|
||||
"installModal.readyToInstall": "Aşağıdaki eklentiyi yüklemek üzere",
|
||||
@@ -189,16 +191,16 @@
|
||||
"list.notFound": "Eklenti bulunamadı",
|
||||
"list.source.github": "GitHub'dan yükleyin",
|
||||
"list.source.local": "Yerel Paket Dosyasından Yükle",
|
||||
"list.source.marketplace": "Marketten Yükleme",
|
||||
"list.source.marketplace": "Pazar Yeri'nden Yükleme",
|
||||
"marketplace.and": "ve",
|
||||
"marketplace.difyMarketplace": "Dify Pazar Yeri",
|
||||
"marketplace.discover": "Keşfetmek",
|
||||
"marketplace.discover": "Keşfet",
|
||||
"marketplace.empower": "Yapay zeka geliştirmenizi güçlendirin",
|
||||
"marketplace.moreFrom": "Marketplace'ten daha fazlası",
|
||||
"marketplace.moreFrom": "Pazar Yeri'nden daha fazlası",
|
||||
"marketplace.noPluginFound": "Eklenti bulunamadı",
|
||||
"marketplace.partnerTip": "Dify partner'ı tarafından doğrulandı",
|
||||
"marketplace.pluginsResult": "{{num}} sonuç",
|
||||
"marketplace.sortBy": "Kara şehir",
|
||||
"marketplace.sortBy": "Sırala",
|
||||
"marketplace.sortOption.firstReleased": "İlk Çıkanlar",
|
||||
"marketplace.sortOption.mostPopular": "En popüler",
|
||||
"marketplace.sortOption.newlyReleased": "Yeni Çıkanlar",
|
||||
@@ -207,7 +209,7 @@
|
||||
"marketplace.viewMore": "Daha fazla göster",
|
||||
"metadata.title": "Eklentiler",
|
||||
"pluginInfoModal.packageName": "Paket",
|
||||
"pluginInfoModal.release": "Serbest bırakma",
|
||||
"pluginInfoModal.release": "Sürüm",
|
||||
"pluginInfoModal.repository": "Depo",
|
||||
"pluginInfoModal.title": "Eklenti bilgisi",
|
||||
"privilege.admins": "Yöneticiler",
|
||||
@@ -220,32 +222,38 @@
|
||||
"readmeInfo.failedToFetch": "README alınamadı",
|
||||
"readmeInfo.needHelpCheckReadme": "Yardıma mı ihtiyacınız var? README dosyasına bakın.",
|
||||
"readmeInfo.noReadmeAvailable": "README mevcut değil",
|
||||
"readmeInfo.title": "OKUMA MESELESİ",
|
||||
"readmeInfo.title": "BENİOKU",
|
||||
"requestAPlugin": "Bir eklenti iste",
|
||||
"search": "Aramak",
|
||||
"search": "Ara",
|
||||
"searchCategories": "Arama Kategorileri",
|
||||
"searchInMarketplace": "Marketplace'te arama yapma",
|
||||
"searchInMarketplace": "Pazar Yeri'nde arama yapın",
|
||||
"searchPlugins": "Eklentileri ara",
|
||||
"searchTools": "Arama araçları...",
|
||||
"source.github": "GitHub (İngilizce)",
|
||||
"source.github": "GitHub",
|
||||
"source.local": "Yerel Paket Dosyası",
|
||||
"source.marketplace": "Pazar",
|
||||
"source.marketplace": "Pazar Yeri",
|
||||
"task.clearAll": "Tümünü temizle",
|
||||
"task.errorPlugins": "Failed to Install Plugins",
|
||||
"task.errorMsg.github": "Bu eklenti otomatik olarak yüklenemedi.\nLütfen GitHub'dan yükleyin.",
|
||||
"task.errorMsg.marketplace": "Bu eklenti otomatik olarak yüklenemedi.\nLütfen Pazar Yeri'nden yükleyin.",
|
||||
"task.errorMsg.unknown": "Bu eklenti yüklenemedi.\nEklenti kaynağı belirlenemedi.",
|
||||
"task.errorPlugins": "Eklentiler Yüklenemedi",
|
||||
"task.installError": "{{errorLength}} eklentileri yüklenemedi, görüntülemek için tıklayın",
|
||||
"task.installSuccess": "{{successLength}} plugins installed successfully",
|
||||
"task.installed": "Installed",
|
||||
"task.installFromGithub": "GitHub'dan yükle",
|
||||
"task.installFromMarketplace": "Pazar Yeri'nden yükle",
|
||||
"task.installSuccess": "{{successLength}} eklenti başarıyla yüklendi",
|
||||
"task.installed": "Yüklendi",
|
||||
"task.installedError": "{{errorLength}} eklentileri yüklenemedi",
|
||||
"task.installing": "Eklentiler yükleniyor.",
|
||||
"task.installingHint": "Yükleniyor... Bu işlem birkaç dakika sürebilir.",
|
||||
"task.installingWithError": "{{installingLength}} eklentileri yükleniyor, {{successLength}} başarılı, {{errorLength}} başarısız oldu",
|
||||
"task.installingWithSuccess": "{{installingLength}} eklentileri yükleniyor, {{successLength}} başarılı.",
|
||||
"task.runningPlugins": "Installing Plugins",
|
||||
"task.successPlugins": "Successfully Installed Plugins",
|
||||
"upgrade.close": "Kapatmak",
|
||||
"upgrade.description": "Aşağıdaki eklentiyi yüklemek üzere",
|
||||
"upgrade.successfulTitle": "Yükleme başarılı",
|
||||
"upgrade.title": "Eklentiyi Yükle",
|
||||
"upgrade.upgrade": "Yüklemek",
|
||||
"upgrade.upgrading": "Yükleme...",
|
||||
"task.runningPlugins": "Eklentiler Yükleniyor",
|
||||
"task.successPlugins": "Başarıyla Yüklenen Eklentiler",
|
||||
"upgrade.close": "Kapat",
|
||||
"upgrade.description": "Aşağıdaki eklentiyi güncellemek üzeresiniz",
|
||||
"upgrade.successfulTitle": "Güncelleme başarılı",
|
||||
"upgrade.title": "Eklentiyi Güncelle",
|
||||
"upgrade.upgrade": "Güncelle",
|
||||
"upgrade.upgrading": "Güncelleniyor...",
|
||||
"upgrade.usedInApps": "{{num}} uygulamalarında kullanılır"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"dateFormats.display": "MMMM D, YYYY",
|
||||
"dateFormats.displayWithTime": "MMMM D, YYYY hh:mm A",
|
||||
"dateFormats.input": "YYYY-AA-GG",
|
||||
"dateFormats.output": "YYYY-AA-GG",
|
||||
"dateFormats.outputWithTime": "YYYY-AA-GGSS:DD:DDS.SSSZ",
|
||||
"daysInWeek.Fri": "Cuma",
|
||||
"daysInWeek.Mon": "Mon",
|
||||
"daysInWeek.Sat": "Sat",
|
||||
"daysInWeek.Sun": "Güneş",
|
||||
"daysInWeek.Thu": "Perşembe",
|
||||
"daysInWeek.Tue": "Salı",
|
||||
"daysInWeek.Wed": "Çarşamba",
|
||||
"dateFormats.display": "D MMMM YYYY",
|
||||
"dateFormats.displayWithTime": "D MMMM YYYY hh:mm A",
|
||||
"dateFormats.input": "YYYY-MM-DD",
|
||||
"dateFormats.output": "YYYY-MM-DD",
|
||||
"dateFormats.outputWithTime": "YYYY-MM-DDTHH:mm:ss.SSSZ",
|
||||
"daysInWeek.Fri": "Cum",
|
||||
"daysInWeek.Mon": "Pzt",
|
||||
"daysInWeek.Sat": "Cmt",
|
||||
"daysInWeek.Sun": "Paz",
|
||||
"daysInWeek.Thu": "Per",
|
||||
"daysInWeek.Tue": "Sal",
|
||||
"daysInWeek.Wed": "Çar",
|
||||
"defaultPlaceholder": "Bir zaman seç...",
|
||||
"months.April": "Nisan",
|
||||
"months.August": "Ağustos",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"auth.setupModalTitleDescription": "Kimlik bilgilerini yapılandırdıktan sonra, çalışma alanındaki tüm üyeler uygulamaları düzenlerken bu aracı kullanabilir.",
|
||||
"auth.unauthorized": "Yetkisiz",
|
||||
"author": "Tarafından",
|
||||
"builtInPromptTitle": "Prompt",
|
||||
"builtInPromptTitle": "İstem",
|
||||
"contribute.line1": "Dify'ye ",
|
||||
"contribute.line2": "araçlar eklemekle ilgileniyorum.",
|
||||
"contribute.viewGuide": "Rehberi Görüntüle",
|
||||
@@ -192,7 +192,7 @@
|
||||
"setBuiltInTools.parameters": "parametreler",
|
||||
"setBuiltInTools.required": "Gerekli",
|
||||
"setBuiltInTools.setting": "Ayar",
|
||||
"setBuiltInTools.string": "string",
|
||||
"setBuiltInTools.string": "metin",
|
||||
"setBuiltInTools.toolDescription": "Araç açıklaması",
|
||||
"test.parameters": "Parametreler",
|
||||
"test.parametersValue": "Parametreler ve Değer",
|
||||
@@ -205,9 +205,9 @@
|
||||
"thought.used": "Kullanıldı",
|
||||
"thought.using": "Kullanılıyor",
|
||||
"title": "Araçlar",
|
||||
"toolNameUsageTip": "Agent akıl yürütme ve prompt için araç çağrı adı",
|
||||
"toolNameUsageTip": "Ajan akıl yürütme ve istem için araç çağrı adı",
|
||||
"toolRemoved": "Araç kaldırıldı",
|
||||
"type.builtIn": "Yerleşik",
|
||||
"type.custom": "Özel",
|
||||
"type.workflow": "Workflow"
|
||||
"type.workflow": "İş Akışı"
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"changeHistory.hintText": "Düzenleme işlemleriniz, bu oturum süresince cihazınızda saklanan bir değişiklik geçmişinde izlenir. Bu tarihçesi düzenleyiciden çıktığınızda temizlenir.",
|
||||
"changeHistory.nodeAdd": "Düğüm eklendi",
|
||||
"changeHistory.nodeChange": "Düğüm değişti",
|
||||
"changeHistory.nodeConnect": "Node bağlandı",
|
||||
"changeHistory.nodeConnect": "Düğüm bağlandı",
|
||||
"changeHistory.nodeDelete": "Düğüm silindi",
|
||||
"changeHistory.nodeDescriptionChange": "Düğüm açıklaması değiştirildi",
|
||||
"changeHistory.nodeDragStop": "Düğüm taşındı",
|
||||
@@ -129,7 +129,7 @@
|
||||
"common.currentView": "Geçerli Görünüm",
|
||||
"common.currentWorkflow": "Mevcut İş Akışı",
|
||||
"common.debugAndPreview": "Önizleme",
|
||||
"common.disconnect": "Ayırmak",
|
||||
"common.disconnect": "Bağlantıyı Kes",
|
||||
"common.duplicate": "Çoğalt",
|
||||
"common.editing": "Düzenleme",
|
||||
"common.effectVarConfirm.content": "Değişken diğer düğümlerde kullanılıyor. Yine de kaldırmak istiyor musunuz?",
|
||||
@@ -151,7 +151,7 @@
|
||||
"common.humanInputEmailTipInDebugMode": "E-posta (Teslimat Yöntemi) <email>{{email}}</email> adresine gönderildi",
|
||||
"common.humanInputWebappTip": "Yalnızca hata ayıklama önizlemesi, kullanıcı bunu web uygulamasında görmeyecek.",
|
||||
"common.importDSL": "DSL İçe Aktar",
|
||||
"common.importDSLTip": "Geçerli taslak üzerine yazılacak. İçe aktarmadan önce workflow yedekleyin.",
|
||||
"common.importDSLTip": "Geçerli taslak üzerine yazılacak. İçe aktarmadan önce iş akışını yedekleyin.",
|
||||
"common.importFailure": "İçe Aktarma Başarısız",
|
||||
"common.importSuccess": "İçe Aktarma Başarılı",
|
||||
"common.importWarning": "Dikkat",
|
||||
@@ -220,10 +220,10 @@
|
||||
"common.viewDetailInTracingPanel": "Ayrıntıları görüntüle",
|
||||
"common.viewOnly": "Sadece Görüntüleme",
|
||||
"common.viewRunHistory": "Çalıştırma geçmişini görüntüle",
|
||||
"common.workflowAsTool": "Araç Olarak Workflow",
|
||||
"common.workflowAsTool": "Araç Olarak İş Akışı",
|
||||
"common.workflowAsToolDisabledHint": "En son iş akışını yayınlayın ve bunu bir araç olarak yapılandırmadan önce bağlı bir Kullanıcı Girdisi düğümünün olduğundan emin olun.",
|
||||
"common.workflowAsToolTip": "Workflow güncellemesinden sonra araç yeniden yapılandırması gereklidir.",
|
||||
"common.workflowProcess": "Workflow Süreci",
|
||||
"common.workflowAsToolTip": "İş Akışı güncellemesinden sonra araç yeniden yapılandırması gereklidir.",
|
||||
"common.workflowProcess": "İş Akışı Süreci",
|
||||
"customWebhook": "Özel Webhook",
|
||||
"debug.copyLastRun": "Son Çalışmayı Kopyala",
|
||||
"debug.copyLastRunError": "Son çalışma girdilerini kopyalamak başarısız oldu.",
|
||||
@@ -240,7 +240,7 @@
|
||||
"debug.relations.dependentsDescription": "Bu düğüme dayanan düğümler",
|
||||
"debug.relations.noDependencies": "Bağımlılık yok",
|
||||
"debug.relations.noDependents": "Bakmakla yükümlü olunan kişi yok",
|
||||
"debug.relationsTab": "Ilişkiler",
|
||||
"debug.relationsTab": "İlişkiler",
|
||||
"debug.settingsTab": "Ayarlar",
|
||||
"debug.variableInspect.chatNode": "Konuşma",
|
||||
"debug.variableInspect.clearAll": "Hepsini sıfırla",
|
||||
@@ -249,7 +249,7 @@
|
||||
"debug.variableInspect.emptyLink": "Daha fazla öğrenin",
|
||||
"debug.variableInspect.emptyTip": "Bir düğümü kanvas üzerinde geçtikten veya bir düğümü adım adım çalıştırdıktan sonra, Düğüm Değişkeni'ndeki mevcut değeri Değişken İncele'de görüntüleyebilirsiniz.",
|
||||
"debug.variableInspect.envNode": "Çevre",
|
||||
"debug.variableInspect.export": "Ihracat",
|
||||
"debug.variableInspect.export": "Dışa Aktar",
|
||||
"debug.variableInspect.exportToolTip": "Değişkeni Dosya Olarak Dışa Aktar",
|
||||
"debug.variableInspect.largeData": "Büyük veri, salt okunur önizleme. Tümünü görüntülemek için dışa aktarın.",
|
||||
"debug.variableInspect.largeDataNoExport": "Büyük veri - yalnızca kısmi önizleme",
|
||||
@@ -294,11 +294,12 @@
|
||||
"env.modal.value": "Değer",
|
||||
"env.modal.valuePlaceholder": "env değeri",
|
||||
"error.operations.addingNodes": "düğüm ekleme",
|
||||
"error.operations.connectingNodes": "düğümleri bağlamak",
|
||||
"error.operations.connectingNodes": "düğümleri bağlama",
|
||||
"error.operations.modifyingWorkflow": "iş akışını değiştirme",
|
||||
"error.operations.updatingWorkflow": "iş akışını güncelleme",
|
||||
"error.startNodeRequired": "Lütfen {{operation}} işleminden önce önce bir başlangıç düğümü ekleyin",
|
||||
"errorMsg.authRequired": "Yetkilendirme gereklidir",
|
||||
"errorMsg.configureModel": "Bir model yapılandırın",
|
||||
"errorMsg.fieldRequired": "{{field}} gereklidir",
|
||||
"errorMsg.fields.code": "Kod",
|
||||
"errorMsg.fields.model": "Model",
|
||||
@@ -308,6 +309,7 @@
|
||||
"errorMsg.fields.visionVariable": "Vizyon Değişkeni",
|
||||
"errorMsg.invalidJson": "{{field}} geçersiz JSON",
|
||||
"errorMsg.invalidVariable": "Geçersiz değişken",
|
||||
"errorMsg.modelPluginNotInstalled": "Geçersiz değişken. Bu değişkeni etkinleştirmek için bir model yapılandırın.",
|
||||
"errorMsg.noValidTool": "{{field}} geçerli bir araç seçilmedi",
|
||||
"errorMsg.rerankModelRequired": "Yeniden Sıralama Modelini açmadan önce, lütfen ayarlarda modelin başarıyla yapılandırıldığını onaylayın.",
|
||||
"errorMsg.startNodeRequired": "Lütfen {{operation}} işleminden önce önce bir başlangıç düğümü ekleyin",
|
||||
@@ -327,7 +329,7 @@
|
||||
"nodes.agent.installPlugin.cancel": "İptal",
|
||||
"nodes.agent.installPlugin.changelog": "Değişiklik günlüğü",
|
||||
"nodes.agent.installPlugin.desc": "Aşağıdaki eklentiyi yüklemek üzere",
|
||||
"nodes.agent.installPlugin.install": "Yüklemek",
|
||||
"nodes.agent.installPlugin.install": "Yükle",
|
||||
"nodes.agent.installPlugin.title": "Eklentiyi Yükle",
|
||||
"nodes.agent.learnMore": "Daha fazla bilgi edinin",
|
||||
"nodes.agent.linkToPlugin": "Eklentilere Bağlantı",
|
||||
@@ -352,7 +354,7 @@
|
||||
"nodes.agent.outputVars.text": "Temsilci Tarafından Oluşturulan İçerik",
|
||||
"nodes.agent.outputVars.usage": "Model Kullanım Bilgileri",
|
||||
"nodes.agent.parameterSchema": "Parametre Şeması",
|
||||
"nodes.agent.pluginInstaller.install": "Yüklemek",
|
||||
"nodes.agent.pluginInstaller.install": "Yükle",
|
||||
"nodes.agent.pluginInstaller.installing": "Yükleme",
|
||||
"nodes.agent.pluginNotFoundDesc": "Bu eklenti GitHub'dan yüklenmiştir. Lütfen şuraya gidin: Eklentiler yeniden yüklemek için",
|
||||
"nodes.agent.pluginNotInstalled": "Bu eklenti yüklü değil",
|
||||
@@ -363,7 +365,7 @@
|
||||
"nodes.agent.strategy.searchPlaceholder": "Arama aracısı stratejisi",
|
||||
"nodes.agent.strategy.selectTip": "Ajan stratejisi seçin",
|
||||
"nodes.agent.strategy.shortLabel": "Strateji",
|
||||
"nodes.agent.strategy.tooltip": "Farklı Agentic stratejileri, sistemin çok adımlı araç çağrılarını nasıl planladığını ve yürüttüğünü belirler",
|
||||
"nodes.agent.strategy.tooltip": "Farklı Ajan stratejileri, sistemin çok adımlı araç çağrılarını nasıl planladığını ve yürüttüğünü belirler",
|
||||
"nodes.agent.strategyNotFoundDesc": "Yüklenen eklenti sürümü bu stratejiyi sağlamaz.",
|
||||
"nodes.agent.strategyNotFoundDescAndSwitchVersion": "Yüklenen eklenti sürümü bu stratejiyi sağlamaz. Sürümü değiştirmek için tıklayın.",
|
||||
"nodes.agent.strategyNotInstallTooltip": "{{strategy}} yüklü değil",
|
||||
@@ -387,12 +389,12 @@
|
||||
"nodes.assigner.operations./=": "/=",
|
||||
"nodes.assigner.operations.append": "Ekleme",
|
||||
"nodes.assigner.operations.clear": "Berrak",
|
||||
"nodes.assigner.operations.extend": "Uzatmak",
|
||||
"nodes.assigner.operations.extend": "Genişlet",
|
||||
"nodes.assigner.operations.over-write": "Üzerine",
|
||||
"nodes.assigner.operations.overwrite": "Üzerine",
|
||||
"nodes.assigner.operations.remove-first": "İlkini kaldır",
|
||||
"nodes.assigner.operations.remove-last": "Sonuncuyu Kaldır",
|
||||
"nodes.assigner.operations.set": "Ayarlamak",
|
||||
"nodes.assigner.operations.set": "Ayarla",
|
||||
"nodes.assigner.operations.title": "İşlem",
|
||||
"nodes.assigner.over-write": "Üzerine Yaz",
|
||||
"nodes.assigner.plus": "Artı",
|
||||
@@ -438,10 +440,11 @@
|
||||
"nodes.common.memory.windowSize": "Pencere Boyutu",
|
||||
"nodes.common.outputVars": "Çıktı Değişkenleri",
|
||||
"nodes.common.pluginNotInstalled": "Eklenti yüklü değil",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} eklenti yüklenmedi",
|
||||
"nodes.common.retry.maxRetries": "En fazla yeniden deneme",
|
||||
"nodes.common.retry.ms": "Ms",
|
||||
"nodes.common.retry.retries": "{{num}} Yeni -den deneme",
|
||||
"nodes.common.retry.retry": "Yeni -den deneme",
|
||||
"nodes.common.retry.retries": "{{num}} Yeniden deneme",
|
||||
"nodes.common.retry.retry": "Yeniden deneme",
|
||||
"nodes.common.retry.retryFailed": "Yeniden deneme başarısız oldu",
|
||||
"nodes.common.retry.retryFailedTimes": "{{times}} yeniden denemeleri başarısız oldu",
|
||||
"nodes.common.retry.retryInterval": "Yeniden deneme aralığı",
|
||||
@@ -639,7 +642,7 @@
|
||||
"nodes.ifElse.optionName.url": "URL",
|
||||
"nodes.ifElse.optionName.video": "Video",
|
||||
"nodes.ifElse.or": "veya",
|
||||
"nodes.ifElse.select": "Seçmek",
|
||||
"nodes.ifElse.select": "Seç",
|
||||
"nodes.ifElse.selectVariable": "Değişken seçin...",
|
||||
"nodes.iteration.ErrorMethod.continueOnError": "Hata Üzerine Devam Et",
|
||||
"nodes.iteration.ErrorMethod.operationTerminated": "Sonlandırıldı",
|
||||
@@ -676,9 +679,14 @@
|
||||
"nodes.knowledgeBase.chunksInput": "Parçalar",
|
||||
"nodes.knowledgeBase.chunksInputTip": "Bilgi tabanı düğümünün girdi değişkeni 'Chunks'tır. Değişkenin tipi, seçilen parça yapısıyla tutarlı olması gereken belirli bir JSON Şemasına sahip bir nesnedir.",
|
||||
"nodes.knowledgeBase.chunksVariableIsRequired": "Chunks değişkeni gereklidir",
|
||||
"nodes.knowledgeBase.embeddingModelApiKeyUnavailable": "API anahtarı kullanılamıyor",
|
||||
"nodes.knowledgeBase.embeddingModelCreditsExhausted": "Krediler tükendi",
|
||||
"nodes.knowledgeBase.embeddingModelIncompatible": "Uyumsuz",
|
||||
"nodes.knowledgeBase.embeddingModelIsInvalid": "Gömme modeli geçersiz",
|
||||
"nodes.knowledgeBase.embeddingModelIsRequired": "Gömme modeli gereklidir",
|
||||
"nodes.knowledgeBase.embeddingModelNotConfigured": "Gömme modeli yapılandırılmadı",
|
||||
"nodes.knowledgeBase.indexMethodIsRequired": "İndeks yöntemi gereklidir",
|
||||
"nodes.knowledgeBase.notConfigured": "Yapılandırılmadı",
|
||||
"nodes.knowledgeBase.rerankingModelIsInvalid": "Yeniden sıralama modeli geçersiz",
|
||||
"nodes.knowledgeBase.rerankingModelIsRequired": "Yeniden sıralama modeli gereklidir",
|
||||
"nodes.knowledgeBase.retrievalSettingIsRequired": "Alma ayarı gereklidir",
|
||||
@@ -687,7 +695,7 @@
|
||||
"nodes.knowledgeRetrieval.metadata.options.automatic.subTitle": "Kullanıcı sorgusuna dayalı olarak otomatik olarak meta veri filtreleme koşulları oluşturun.",
|
||||
"nodes.knowledgeRetrieval.metadata.options.automatic.title": "Otomatik",
|
||||
"nodes.knowledgeRetrieval.metadata.options.disabled.subTitle": "Meta veri filtreleme özelliğini devre dışı bırakma",
|
||||
"nodes.knowledgeRetrieval.metadata.options.disabled.title": "Devre dışı bırakıldı.",
|
||||
"nodes.knowledgeRetrieval.metadata.options.disabled.title": "Devre Dışı",
|
||||
"nodes.knowledgeRetrieval.metadata.options.manual.subTitle": "Manuel olarak meta veri filtreleme koşulları ekleyin",
|
||||
"nodes.knowledgeRetrieval.metadata.options.manual.title": "Kılavuz",
|
||||
"nodes.knowledgeRetrieval.metadata.panel.add": "Koşul Ekle",
|
||||
@@ -861,7 +869,8 @@
|
||||
"nodes.templateTransform.codeSupportTip": "Sadece Jinja2 destekler",
|
||||
"nodes.templateTransform.inputVars": "Giriş Değişkenleri",
|
||||
"nodes.templateTransform.outputVars.output": "Dönüştürülmüş içerik",
|
||||
"nodes.tool.authorize": "Yetkilendirmek",
|
||||
"nodes.tool.authorizationRequired": "Yetkilendirme gerekli",
|
||||
"nodes.tool.authorize": "Yetkilendir",
|
||||
"nodes.tool.inputVars": "Giriş Değişkenleri",
|
||||
"nodes.tool.insertPlaceholder1": "Yazın veya basın",
|
||||
"nodes.tool.insertPlaceholder2": "değişken ekle",
|
||||
@@ -961,7 +970,7 @@
|
||||
"nodes.triggerSchedule.title": "Program",
|
||||
"nodes.triggerSchedule.useCronExpression": "Cron ifadesi kullan",
|
||||
"nodes.triggerSchedule.useVisualPicker": "Görsel seçici kullan",
|
||||
"nodes.triggerSchedule.visualConfig": "Görsel Konfigürasyon",
|
||||
"nodes.triggerSchedule.visualConfig": "Görsel Yapılandırma",
|
||||
"nodes.triggerSchedule.weekdays": "Hafta günleri",
|
||||
"nodes.triggerWebhook.addHeader": "Ekle",
|
||||
"nodes.triggerWebhook.addParameter": "Ekle",
|
||||
@@ -1021,8 +1030,8 @@
|
||||
"onboarding.back": "Geri",
|
||||
"onboarding.description": "Farklı başlangıç düğümlerinin farklı yetenekleri vardır. Endişelenmeyin, bunları her zaman daha sonra değiştirebilirsiniz.",
|
||||
"onboarding.escTip.key": "esc",
|
||||
"onboarding.escTip.press": "Basın",
|
||||
"onboarding.escTip.toDismiss": "reddetmek",
|
||||
"onboarding.escTip.press": "Kapatmak için",
|
||||
"onboarding.escTip.toDismiss": "tuşuna basın",
|
||||
"onboarding.learnMore": "Daha fazla bilgi edin",
|
||||
"onboarding.title": "Başlamak için bir başlangıç düğümü seçin",
|
||||
"onboarding.trigger": "Tetik",
|
||||
@@ -1051,10 +1060,12 @@
|
||||
"panel.change": "Değiştir",
|
||||
"panel.changeBlock": "Düğümü Değiştir",
|
||||
"panel.checklist": "Kontrol Listesi",
|
||||
"panel.checklistDescription": "Yayınlamadan önce aşağıdaki sorunları çözün",
|
||||
"panel.checklistResolved": "Tüm sorunlar çözüldü",
|
||||
"panel.checklistTip": "Yayınlamadan önce tüm sorunların çözüldüğünden emin olun",
|
||||
"panel.createdBy": "Oluşturan: ",
|
||||
"panel.goTo": "Git",
|
||||
"panel.goToFix": "Düzeltmeye git",
|
||||
"panel.helpLink": "Yardım",
|
||||
"panel.maximize": "Kanvası Maksimize Et",
|
||||
"panel.minimize": "Tam Ekrandan Çık",
|
||||
@@ -1069,8 +1080,8 @@
|
||||
"panel.startNode": "Başlangıç Düğümü",
|
||||
"panel.userInputField": "Kullanıcı Giriş Alanı",
|
||||
"publishLimit.startNodeDesc": "Bu plan için bir iş akışında 2 tetikleyici sınırına ulaştınız. Bu iş akışını yayınlamak için yükseltme yapın.",
|
||||
"publishLimit.startNodeTitlePrefix": "Yükselt",
|
||||
"publishLimit.startNodeTitleSuffix": "her iş akışı için sınırsız tetikleyici aç",
|
||||
"publishLimit.startNodeTitlePrefix": "Yükseltme: ",
|
||||
"publishLimit.startNodeTitleSuffix": "her iş akışı için sınırsız tetikleyicinin kilidini açın",
|
||||
"sidebar.exportWarning": "Mevcut Kaydedilmiş Versiyonu Dışa Aktar",
|
||||
"sidebar.exportWarningDesc": "Bu, çalışma akışınızın mevcut kaydedilmiş sürümünü dışa aktaracaktır. Editörde kaydedilmemiş değişiklikleriniz varsa, lütfen önce bunları çalışma akışı alanındaki dışa aktarma seçeneğini kullanarak kaydedin.",
|
||||
"singleRun.back": "Geri",
|
||||
@@ -1095,8 +1106,8 @@
|
||||
"tabs.hideActions": "Araçları gizle",
|
||||
"tabs.installed": "Yüklendi",
|
||||
"tabs.logic": "Mantık",
|
||||
"tabs.noFeaturedPlugins": "Marketplace'te daha fazla araç keşfedin",
|
||||
"tabs.noFeaturedTriggers": "Marketplace'te daha fazla tetikleyici keşfedin",
|
||||
"tabs.noFeaturedPlugins": "Pazar Yeri'nde daha fazla araç keşfedin",
|
||||
"tabs.noFeaturedTriggers": "Pazar Yeri'nde daha fazla tetikleyici keşfedin",
|
||||
"tabs.noPluginsFound": "Hiç eklenti bulunamadı",
|
||||
"tabs.noResult": "Eşleşen bulunamadı",
|
||||
"tabs.plugin": "Eklenti",
|
||||
@@ -1116,7 +1127,7 @@
|
||||
"tabs.transform": "Dönüştür",
|
||||
"tabs.usePlugin": "Araç seç",
|
||||
"tabs.utilities": "Yardımcı Araçlar",
|
||||
"tabs.workflowTool": "Workflow",
|
||||
"tabs.workflowTool": "İş Akışı",
|
||||
"tracing.stopBy": "{{user}} tarafından durduruldu",
|
||||
"triggerStatus.disabled": "TETİKLEYİCİ • DEVRE DIŞI",
|
||||
"triggerStatus.enabled": "TETİK",
|
||||
|
||||
Reference in New Issue
Block a user