Compare commits

..

11 Commits

Author SHA1 Message Date
Desel72
f5cc1c8b75 test: migrate saved message service tests to testcontainers (#33949)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-23 22:26:31 +09:00
Desel72
6698b42f97 test: migrate api based extension service tests to testcontainers (#33952) 2026-03-23 22:20:53 +09:00
Desel72
848a041c25 test: migrate dataset service create dataset tests to testcontainers (#33945) 2026-03-23 22:20:25 +09:00
Baki Burak Öğün
29cff809b9 fix(i18n): comprehensive Turkish (tr-TR) translation fixes and missing keys (#33885)
Co-authored-by: bakiburakogun <bakiburakogun@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Baki Burak Öğün <b.burak.ogun@goc.local>
2026-03-23 21:19:53 +08:00
kurokobo
30deeb6f1c feat(firecrawl): follow pagination when crawl status is completed (#33864)
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-03-23 21:19:32 +08:00
Desel72
30dd36505c test: migrate batch update document status tests to testcontainers (#33951) 2026-03-23 21:57:01 +09:00
Desel72
65223c8092 test: remove mock-based tests superseded by testcontainers (#33946) 2026-03-23 21:55:50 +09:00
Desel72
72e3fcd25f test: migrate end user service batch tests to testcontainers (#33947) 2026-03-23 21:54:37 +09:00
Desel72
4b4a5c058e test: migrate file service zip and lookup tests to testcontainers (#33944) 2026-03-23 21:52:31 +09:00
letterbeezps
56e0907548 fix: do not block upsert for baidu vdb (#33280)
Co-authored-by: zhangping24 <zhangping24@baidu.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-23 20:42:57 +08:00
Asuka Minato
d956b919a0 ci: fix AttributeError: 'Flask' object has no attribute 'login_manager' FAILED #33891 (#33896)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-23 20:27:14 +08:00
71 changed files with 2899 additions and 4377 deletions

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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,
),
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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")

View File

@@ -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"

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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"}

View File

@@ -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,
}

View File

@@ -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 == []

View File

@@ -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
)

View File

@@ -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(),
)

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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"

View File

@@ -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()

View File

@@ -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": []}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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"

View File

@@ -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")

View File

@@ -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"] == {}

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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}

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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} />)

View 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)
})
})

View File

@@ -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', () => ({

View File

@@ -1,2 +1,2 @@
export { default } from './lazy-amplitude-provider'
export { default, isAmplitudeEnabled } from './AmplitudeProvider'
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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 />
}

View File

@@ -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,

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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'

View File

@@ -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

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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ıı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ı",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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ışı"
}

View File

@@ -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.",

View File

@@ -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": "ı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ş...",

View File

@@ -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",

View File

@@ -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…",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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ışı"
}

View File

@@ -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ı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",