Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e75e7c5b0 | ||
|
|
8c91bce282 | ||
|
|
798e5ed7a7 | ||
|
|
eef709e475 | ||
|
|
04158ac8ea | ||
|
|
ae0b66311d | ||
|
|
b97abe5328 | ||
|
|
24a66293fc | ||
|
|
d94314627f | ||
|
|
7ec6a57ddf | ||
|
|
35b539e35b | ||
|
|
3b040e6bed | ||
|
|
9300be03f0 | ||
|
|
137d4f3f60 | ||
|
|
e723b348cf | ||
|
|
59fb603ec6 | ||
|
|
63f072ebfb | ||
|
|
0913d04d33 | ||
|
|
80ff108fc0 | ||
|
|
ea58129ebe | ||
|
|
698869460c | ||
|
|
f3f2f63110 | ||
|
|
b100cdc382 | ||
|
|
9e90b32991 | ||
|
|
b2d54cb2e9 | ||
|
|
1e5e47b889 | ||
|
|
dd28b0d165 | ||
|
|
49e74e2f58 | ||
|
|
e6e5d761c2 | ||
|
|
e1ce808567 | ||
|
|
3c7ad816d9 | ||
|
|
f44dd343da | ||
|
|
2d9b2d50f3 | ||
|
|
003e0f9614 | ||
|
|
da0979b373 | ||
|
|
81fb57639e | ||
|
|
1e61078e93 | ||
|
|
b597bb1b17 | ||
|
|
65ead05dfc | ||
|
|
6f8ed69ee1 | ||
|
|
59b879d2df | ||
|
|
be88e6adda | ||
|
|
3b832da46a | ||
|
|
8812fd649c |
@@ -1,6 +1,7 @@
|
||||
name: Deploy Knowledge
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
on:
|
||||
@@ -18,6 +19,48 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/konwledge'
|
||||
steps:
|
||||
- name: Wait for KnowledgeFS CI
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
timeout-minutes: 35
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const workflowId = "knowledge-fs-ci.yml";
|
||||
const headBranch = context.payload.workflow_run.head_branch;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
const pollIntervalMs = 15 * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
branch: headBranch,
|
||||
event: "push",
|
||||
head_sha: headSha,
|
||||
per_page: 10,
|
||||
});
|
||||
const run = data.workflow_runs[0];
|
||||
|
||||
if (!run) {
|
||||
core.info(`Waiting for ${workflowId} to start for ${headSha}.`);
|
||||
} else if (run.status !== "completed") {
|
||||
core.info(`Waiting for ${run.html_url}; current status is ${run.status}.`);
|
||||
} else if (run.conclusion !== "success") {
|
||||
throw new Error(
|
||||
`${workflowId} did not succeed for ${headSha}: ${run.conclusion} (${run.html_url})`,
|
||||
);
|
||||
} else {
|
||||
core.info(`KnowledgeFS CI succeeded for ${headSha}: ${run.html_url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${workflowId} to succeed for ${headSha}.`);
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
|
||||
+2
-2
@@ -99,9 +99,9 @@ ENV VIRTUAL_ENV=/app/api/.venv
|
||||
COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
|
||||
# Download nltk data
|
||||
RUN mkdir -p /usr/local/share/nltk_data \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger'); nltk.download('stopwords')" \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -m nltk.downloader punkt_tab averaged_perceptron_tagger_eng stopwords \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.data.find('tokenizers/punkt_tab'); nltk.data.find('taggers/averaged_perceptron_tagger_eng'); nltk.data.find('corpora/stopwords')" \
|
||||
&& chmod -R 755 /usr/local/share/nltk_data
|
||||
|
||||
ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache
|
||||
|
||||
@@ -52,7 +52,7 @@ def with_session[T, **P, R](
|
||||
session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
session.rollback() # noqa: no-new-controller-sqlalchemy decorator owns transaction rollback
|
||||
session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback
|
||||
raise
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
|
||||
@@ -317,7 +317,7 @@ class _WorkflowResponseSource:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def created_by_account(self) -> Account | None:
|
||||
|
||||
@@ -218,7 +218,7 @@ class _DatasetQueryResponseSource:
|
||||
return self.query.get_queries(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.query, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.query, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class DatasetQueryListResponse(ResponseModel):
|
||||
@@ -257,7 +257,7 @@ class _RelatedAppResponseSource:
|
||||
return self.app.mode_compatible_with_agent_with_session(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.app, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class RelatedAppListResponse(ResponseModel):
|
||||
|
||||
@@ -106,7 +106,7 @@ class ExternalKnowledgeApiResponseSource:
|
||||
return self.external_knowledge_api.get_dataset_bindings(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.external_knowledge_api, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def external_knowledge_api_response(
|
||||
|
||||
@@ -404,7 +404,7 @@ class TrialWorkflowResponseSource:
|
||||
return self.workflow.get_tool_published(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
register_schema_models(
|
||||
|
||||
@@ -66,7 +66,7 @@ from services.enterprise.plugin_manager_service import (
|
||||
PreUninstallPluginRequest,
|
||||
)
|
||||
from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import FeatureService, PluginInstallationScope
|
||||
from services.feature_service import FeatureService, PluginInstallationPermissionModel, PluginInstallationScope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity])
|
||||
@@ -604,22 +604,30 @@ class PluginService:
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _check_marketplace_only_permission():
|
||||
def _check_marketplace_only_permission() -> None:
|
||||
"""
|
||||
Check if the marketplace only permission is enabled
|
||||
"""
|
||||
features = FeatureService.get_system_features()
|
||||
if features.plugin_installation_permission.restrict_to_marketplace_only:
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
if permission.restrict_to_marketplace_only:
|
||||
raise PluginInstallationForbiddenError("Plugin installation is restricted to marketplace only")
|
||||
|
||||
@staticmethod
|
||||
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None):
|
||||
def _get_plugin_installation_permission() -> PluginInstallationPermissionModel:
|
||||
"""Resolve the validated policy and reject deny-all before any installation side effect."""
|
||||
permission = FeatureService.get_plugin_installation_permission()
|
||||
if permission.plugin_installation_scope == PluginInstallationScope.NONE:
|
||||
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
|
||||
return permission
|
||||
|
||||
@staticmethod
|
||||
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None) -> None:
|
||||
"""
|
||||
Check the plugin installation scope
|
||||
"""
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
match features.plugin_installation_permission.plugin_installation_scope:
|
||||
match permission.plugin_installation_scope:
|
||||
case PluginInstallationScope.OFFICIAL_ONLY:
|
||||
if (
|
||||
plugin_verification is None
|
||||
@@ -634,10 +642,10 @@ class PluginService:
|
||||
raise PluginInstallationForbiddenError(
|
||||
"Plugin installation is restricted to official and specific partners"
|
||||
)
|
||||
case PluginInstallationScope.NONE:
|
||||
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
|
||||
case PluginInstallationScope.ALL:
|
||||
pass
|
||||
case _:
|
||||
raise PluginInstallationForbiddenError("Plugin installation policy is invalid")
|
||||
|
||||
@staticmethod
|
||||
def get_debugging_key(tenant_id: str) -> str:
|
||||
@@ -907,7 +915,7 @@ class PluginService:
|
||||
# check if plugin pkg is already downloaded
|
||||
manager = PluginInstaller()
|
||||
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
try:
|
||||
manager.fetch_plugin_manifest(tenant_id, new_plugin_unique_identifier)
|
||||
@@ -919,7 +927,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
|
||||
# check if the plugin is available to install
|
||||
@@ -974,11 +982,11 @@ class PluginService:
|
||||
"""
|
||||
PluginService._check_marketplace_only_permission()
|
||||
manager = PluginInstaller()
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -996,13 +1004,13 @@ class PluginService:
|
||||
pkg = download_with_size_limit(
|
||||
f"https://github.com/{repo}/releases/download/{version}/{package}", dify_config.PLUGIN_MAX_PACKAGE_SIZE
|
||||
)
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
manager = PluginInstaller()
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -1076,7 +1084,7 @@ class PluginService:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError("marketplace is not enabled")
|
||||
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
manager = PluginInstaller()
|
||||
try:
|
||||
@@ -1086,7 +1094,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
@@ -1108,7 +1116,7 @@ class PluginService:
|
||||
# collect actual plugin_unique_identifiers
|
||||
actual_plugin_unique_identifiers = []
|
||||
metas = []
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
# check if already downloaded
|
||||
for plugin_unique_identifier in plugin_unique_identifiers:
|
||||
@@ -1126,7 +1134,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -107,6 +107,8 @@ class MCPTool(Tool):
|
||||
if self.entity.output_schema and result.structuredContent:
|
||||
for k, v in result.structuredContent.items():
|
||||
yield self.create_variable_message(k, v)
|
||||
elif result.structuredContent:
|
||||
yield self.create_json_message(result.structuredContent)
|
||||
|
||||
def _process_text_content(self, content: TextContent) -> Generator[ToolInvokeMessage, None, None]:
|
||||
"""Process text content and yield appropriate messages."""
|
||||
|
||||
@@ -198,8 +198,23 @@ class AgentRuntimeSupport:
|
||||
if model_schema:
|
||||
model_schema = self._remove_unsupported_model_features_for_old_version(model_schema)
|
||||
value["entity"] = model_schema.model_dump(mode="json")
|
||||
# The model selector value from the workflow frontend only
|
||||
# carries provider/model/mode — it does NOT include
|
||||
# completion_params. AgentStrategy plugins (cot_agent,
|
||||
# function_calling) read completion_params to build the
|
||||
# LLMModelConfig that is backwards-invoked, and some model
|
||||
# providers raise KeyError('required') when
|
||||
# completion_params is empty because their parameter_rules
|
||||
# declare required fields with no default. Populate
|
||||
# completion_params with the defaults declared in the model
|
||||
# schema so the plugin daemon always receives a valid set
|
||||
# of model parameters.
|
||||
if "completion_params" not in value:
|
||||
value["completion_params"] = self._extract_default_completion_params(model_schema)
|
||||
else:
|
||||
value["entity"] = None
|
||||
if "completion_params" not in value:
|
||||
value["completion_params"] = {}
|
||||
result[parameter_name] = value
|
||||
|
||||
return result
|
||||
@@ -275,6 +290,24 @@ class AgentRuntimeSupport:
|
||||
model_schema.features.remove(feature)
|
||||
return model_schema
|
||||
|
||||
@staticmethod
|
||||
def _extract_default_completion_params(model_schema: AIModelEntity) -> dict[str, Any]:
|
||||
"""Build a completion_params dict from the model schema's parameter_rules.
|
||||
|
||||
The workflow Agent node's model-selector parameter only stores
|
||||
provider/model/mode — it never carries completion_params. When the
|
||||
value is forwarded to the plugin daemon, AgentModelConfig defaults
|
||||
completion_params to ``{}``, which causes some model providers to fail
|
||||
because their parameter_rules declare required fields. This helper
|
||||
collects the ``default`` value of every parameter_rule that has one so
|
||||
the plugin daemon receives a valid, non-empty set of model parameters.
|
||||
"""
|
||||
completion_params: dict[str, Any] = {}
|
||||
for rule in model_schema.parameter_rules:
|
||||
if rule.default is not None:
|
||||
completion_params[rule.name] = rule.default
|
||||
return completion_params
|
||||
|
||||
@staticmethod
|
||||
def _filter_mcp_type_tool(
|
||||
strategy: ResolvedAgentStrategy,
|
||||
|
||||
@@ -34,7 +34,7 @@ class _SessionResponseSource[SourceT]:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._source, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._source, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]):
|
||||
|
||||
@@ -227,7 +227,7 @@ class DatasetDetailResponseSource:
|
||||
return self.dataset.get_total_available_documents(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.dataset, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.dataset, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def dataset_detail_response_source(dataset: Any, *, session: Session) -> DatasetDetailResponseSource:
|
||||
|
||||
@@ -90,7 +90,7 @@ class DocumentWithSession:
|
||||
return self.document.get_doc_metadata_details(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.document, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.document, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def document_response(document: Document, *, session: Session) -> DocumentResponse:
|
||||
|
||||
+5
-1
@@ -289,7 +289,11 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)]
|
||||
|
||||
def alphanumeric(value: str):
|
||||
# check if the value is alphanumeric and underlined
|
||||
if re.match(r"^[a-zA-Z0-9_]+$", value):
|
||||
# Use re.fullmatch instead of re.match to reject trailing newlines.
|
||||
# In Python, '$' matches at end-of-string OR just before a trailing newline,
|
||||
# so re.match accepts "tool_name\n". re.fullmatch requires the entire
|
||||
# string to match. Regression for #39666 (sibling of #39234 / #39548).
|
||||
if re.fullmatch(r"^[a-zA-Z0-9_]+$", value):
|
||||
return value
|
||||
|
||||
raise ValueError(f"{value} is not a valid alphanumeric value")
|
||||
|
||||
+21
-12
@@ -1,3 +1,5 @@
|
||||
"""Unit tests for Aliyun trace utility transformations and database lookups."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
@@ -25,11 +27,13 @@ from dify_trace_aliyun.utils import (
|
||||
serialize_json_data,
|
||||
)
|
||||
from opentelemetry.trace import Link, StatusCode
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.rag.models.document import Document
|
||||
from graphon.entities import WorkflowNodeExecution
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from models import EndUser
|
||||
from models.enums import EndUserType
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -40,35 +44,40 @@ def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPat
|
||||
assert get_user_id_from_message_data(message_data) == "account_id"
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
message_data = MagicMock()
|
||||
message_data.from_account_id = "account_id"
|
||||
message_data.from_end_user_id = "end_user_id"
|
||||
|
||||
end_user_data = MagicMock(spec=EndUser)
|
||||
end_user_data.session_id = "session_id"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = end_user_data
|
||||
end_user_data = EndUser(
|
||||
id="end_user_id",
|
||||
tenant_id="tenant_id",
|
||||
app_id="app_id",
|
||||
type=EndUserType.BROWSER,
|
||||
session_id="session_id",
|
||||
)
|
||||
sqlite3_session.add(end_user_data)
|
||||
sqlite3_session.commit()
|
||||
|
||||
from dify_trace_aliyun.utils import db
|
||||
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
monkeypatch.setattr(db, "session", sqlite3_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "session_id"
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_get_user_id_from_message_data_end_user_not_found(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
message_data = MagicMock()
|
||||
message_data.from_account_id = "account_id"
|
||||
message_data.from_end_user_id = "end_user_id"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = None
|
||||
|
||||
from dify_trace_aliyun.utils import db
|
||||
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
monkeypatch.setattr(db, "session", sqlite3_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "account_id"
|
||||
|
||||
|
||||
+67
-29
@@ -1,5 +1,8 @@
|
||||
"""Unit tests for LangSmith trace translation with SQLite-backed lookups."""
|
||||
|
||||
import collections
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import override
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -11,6 +14,7 @@ from dify_trace_langsmith.entities.langsmith_trace_entity import (
|
||||
LangSmithRunUpdateModel,
|
||||
)
|
||||
from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.ops.entities.trace_entity import (
|
||||
DatasetRetrievalTraceInfo,
|
||||
@@ -24,6 +28,7 @@ from core.ops.entities.trace_entity import (
|
||||
)
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey
|
||||
from models import EndUser
|
||||
from models.enums import EndUserType
|
||||
|
||||
|
||||
def _dt() -> datetime:
|
||||
@@ -108,7 +113,8 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
mocks["generate_name_trace"].assert_called_once_with(info)
|
||||
|
||||
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
# Setup trace info
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
@@ -137,10 +143,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
# Mock dependencies
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
|
||||
# Mock node executions
|
||||
node_llm = MagicMock()
|
||||
@@ -228,7 +234,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
assert call_args[4].run_type == LangSmithRunType.retriever
|
||||
|
||||
|
||||
def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_no_start_time(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -256,9 +265,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
mock_factory = MagicMock()
|
||||
@@ -271,7 +281,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey
|
||||
assert trace_instance.add_run.called
|
||||
|
||||
|
||||
def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_missing_app_id(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.trace_id = "trace-1"
|
||||
trace_info.message_id = None
|
||||
@@ -287,15 +300,17 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.Monke
|
||||
trace_info.workflow_run_outputs = {}
|
||||
trace_info.error = ""
|
||||
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="No app_id found in trace_info metadata"):
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
message_data = MagicMock()
|
||||
message_data.id = "msg-1"
|
||||
message_data.from_account_id = "acc-1"
|
||||
@@ -321,10 +336,19 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
message_file_data=MagicMock(url="file-url"),
|
||||
)
|
||||
|
||||
# Mock EndUser lookup
|
||||
mock_end_user = MagicMock(spec=EndUser)
|
||||
mock_end_user.session_id = "session-id-123"
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db.session.get", lambda model, pk: mock_end_user)
|
||||
end_user = EndUser(
|
||||
id="end-user-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
type=EndUserType.BROWSER,
|
||||
session_id="session-id-123",
|
||||
)
|
||||
sqlite3_session.add(end_user)
|
||||
sqlite3_session.commit()
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
@@ -521,9 +545,13 @@ def test_update_run_error(trace_instance):
|
||||
trace_instance.update_run(update_data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_usage_extraction_error(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
trace_instance,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
sqlite3_session: Session,
|
||||
) -> None:
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -576,8 +604,10 @@ def test_workflow_trace_usage_extraction_error(
|
||||
mock_factory = MagicMock()
|
||||
mock_factory.create_workflow_node_execution_repository.return_value = repo
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock())
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
@@ -644,9 +674,11 @@ def _make_workflow_trace_info(
|
||||
)
|
||||
|
||||
|
||||
def _patch_workflow_trace_deps(monkeypatch, trace_instance):
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Session) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
factory = MagicMock()
|
||||
@@ -656,14 +688,17 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance):
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
|
||||
def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_id_uses_message_id_not_external(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
"""Chatflow with external trace_id: LangSmith trace_id must be message_id, not external."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id="msg-abc",
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
@@ -677,14 +712,17 @@ def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypa
|
||||
assert trace_info.metadata.get("external_trace_id") == "external-999"
|
||||
|
||||
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
"""Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id=None,
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
@@ -316,10 +316,10 @@ class OracleVector(BaseVector):
|
||||
entities.append(current_entity)
|
||||
else:
|
||||
try:
|
||||
nltk.data.find("tokenizers/punkt")
|
||||
nltk.data.find("tokenizers/punkt_tab")
|
||||
nltk.data.find("corpora/stopwords")
|
||||
except LookupError:
|
||||
raise LookupError("Unable to find the required NLTK data package: punkt and stopwords")
|
||||
raise LookupError("Unable to find the required NLTK data package: punkt_tab and stopwords")
|
||||
e_str = re.sub(r"[^\w ]", "", query)
|
||||
all_tokens = nltk.word_tokenize(e_str)
|
||||
stop_words = stopwords.words("english")
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
@@ -206,7 +206,7 @@ storage = [
|
||||
############################################################
|
||||
# [ Tools ] dependency group
|
||||
############################################################
|
||||
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.9.1,<4.0.0"]
|
||||
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.0,<4.0.0"]
|
||||
|
||||
############################################################
|
||||
# [ VDB ] workspace plugins — hollow packages under providers/vdb/*
|
||||
|
||||
@@ -114,7 +114,7 @@ class AppModelConfigResponseView:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._app_model_config, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._app_model_config, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def annotation_reply_dict(self) -> Any:
|
||||
@@ -129,7 +129,7 @@ class AppResponseView:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._app, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def desc_or_prompt(self) -> str:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from configs import dify_config
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
@@ -10,6 +12,8 @@ from enums.hosted_provider import HostedTrialProvider
|
||||
from services.billing_service import BillingInfo, BillingService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeatureResponseModel(BaseModel):
|
||||
model_config = ConfigDict(json_schema_serialization_defaults_required=True, protected_namespaces=())
|
||||
@@ -131,6 +135,13 @@ class PluginInstallationPermissionModel(FeatureResponseModel):
|
||||
restrict_to_marketplace_only: bool = False
|
||||
|
||||
|
||||
class _EnterprisePluginInstallationPermission(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
plugin_installation_scope: PluginInstallationScope = Field(alias="pluginInstallationScope")
|
||||
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
|
||||
|
||||
|
||||
class FeatureModel(FeatureResponseModel):
|
||||
billing: BillingModel = BillingModel()
|
||||
education: EducationModel = EducationModel()
|
||||
@@ -285,6 +296,14 @@ class FeatureService:
|
||||
"""Return whether Enterprise plugin credential policies must be enforced."""
|
||||
return dify_config.ENTERPRISE_ENABLED
|
||||
|
||||
@classmethod
|
||||
def get_plugin_installation_permission(cls) -> PluginInstallationPermissionModel:
|
||||
"""Resolve the validated deployment-wide plugin installation policy."""
|
||||
if not dify_config.ENTERPRISE_ENABLED:
|
||||
return PluginInstallationPermissionModel()
|
||||
|
||||
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
|
||||
|
||||
@classmethod
|
||||
def get_license(cls) -> LicenseModel:
|
||||
"""Return full license detail. Enterprise-only; requires an authenticated caller.
|
||||
@@ -452,6 +471,33 @@ class FeatureService:
|
||||
)
|
||||
return license_model
|
||||
|
||||
@classmethod
|
||||
def _resolve_plugin_installation_permission(
|
||||
cls, enterprise_info: Mapping[str, object]
|
||||
) -> PluginInstallationPermissionModel:
|
||||
if "PluginInstallationPermission" not in enterprise_info:
|
||||
return PluginInstallationPermissionModel()
|
||||
|
||||
try:
|
||||
permission = _EnterprisePluginInstallationPermission.model_validate(
|
||||
enterprise_info["PluginInstallationPermission"]
|
||||
)
|
||||
except ValidationError as exc:
|
||||
# Do not attach the exception because it may contain raw Enterprise configuration values.
|
||||
logger.error( # noqa: TRY400
|
||||
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
|
||||
exc.errors(include_input=False),
|
||||
)
|
||||
return PluginInstallationPermissionModel(
|
||||
plugin_installation_scope=PluginInstallationScope.NONE,
|
||||
restrict_to_marketplace_only=True,
|
||||
)
|
||||
|
||||
return PluginInstallationPermissionModel(
|
||||
plugin_installation_scope=permission.plugin_installation_scope,
|
||||
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
|
||||
enterprise_info = EnterpriseService.get_info()
|
||||
@@ -499,11 +545,4 @@ class FeatureService:
|
||||
status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
|
||||
)
|
||||
|
||||
if "PluginInstallationPermission" in enterprise_info:
|
||||
plugin_installation_info = enterprise_info["PluginInstallationPermission"]
|
||||
features.plugin_installation_permission.plugin_installation_scope = plugin_installation_info[
|
||||
"pluginInstallationScope"
|
||||
]
|
||||
features.plugin_installation_permission.restrict_to_marketplace_only = plugin_installation_info[
|
||||
"restrictToMarketplaceOnly"
|
||||
]
|
||||
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)
|
||||
|
||||
@@ -2,114 +2,94 @@ extend = "../../.ruff.toml"
|
||||
src = ["../.."]
|
||||
|
||||
[lint]
|
||||
extend-select = ["ANN401", "ARG", "TID251"]
|
||||
extend-select = ["ANN401", "ARG"]
|
||||
|
||||
# Existing strict-mode debt. Remove a file entry when bringing it under strict checking.
|
||||
[lint.per-file-ignores]
|
||||
"core/rag/pipeline/test_queue_integration.py" = ["ANN401", "TID251", "ARG"]
|
||||
"controllers/console/test_apikey.py" = ["ARG002"]
|
||||
"controllers/openapi/test_app_dsl.py" = ["ARG002"]
|
||||
"controllers/service_api/dataset/test_dataset.py" = ["ARG002"]
|
||||
"controllers/web/test_conversation.py" = ["ARG002"]
|
||||
"controllers/web/test_human_input_form.py" = ["ARG001"]
|
||||
"controllers/web/test_wraps.py" = ["ARG002"]
|
||||
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG002"]
|
||||
"core/rag/pipeline/test_queue_integration.py" = ["ARG002", "TID251"]
|
||||
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG002"]
|
||||
"models/test_conversation_message_inputs.py" = ["ARG001"]
|
||||
"models/test_types_enum_text.py" = ["ANN401", "TID251"]
|
||||
"services/test_app_dsl_service.py" = ["ANN401", "TID251", "ARG"]
|
||||
"services/test_file_service_zip_and_lookup.py" = ["ANN401", "TID251", "ARG"]
|
||||
"trigger/conftest.py" = ["ANN401", "TID251"]
|
||||
"trigger/test_trigger_e2e.py" = ["ANN401", "TID251", "ARG"]
|
||||
"controllers/console/app/test_app_apis.py" = ["ARG"]
|
||||
"controllers/console/app/test_app_import_api.py" = ["ARG"]
|
||||
"controllers/console/auth/test_oauth.py" = ["ARG"]
|
||||
"controllers/console/auth/test_password_reset.py" = ["ARG"]
|
||||
"controllers/console/datasets/test_data_source.py" = ["ARG"]
|
||||
"controllers/console/test_apikey.py" = ["ARG"]
|
||||
"controllers/console/workspace/test_tool_provider.py" = ["ARG"]
|
||||
"controllers/mcp/test_mcp.py" = ["ARG"]
|
||||
"controllers/openapi/test_app_dsl.py" = ["ARG"]
|
||||
"controllers/openapi/test_workspaces.py" = ["ARG"]
|
||||
"controllers/service_api/dataset/test_dataset.py" = ["ARG"]
|
||||
"controllers/web/test_conversation.py" = ["ARG"]
|
||||
"controllers/web/test_human_input_form.py" = ["ARG"]
|
||||
"controllers/web/test_wraps.py" = ["ARG"]
|
||||
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG"]
|
||||
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG"]
|
||||
"models/test_conversation_message_inputs.py" = ["ARG"]
|
||||
"models/test_conversation_status_count.py" = ["ARG"]
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG"]
|
||||
"repositories/test_workflow_run_repository.py" = ["ARG"]
|
||||
"services/auth/test_api_key_auth_service.py" = ["ARG"]
|
||||
"services/auth/test_auth_integration.py" = ["ARG"]
|
||||
"services/dataset_collection_binding.py" = ["ARG"]
|
||||
"services/dataset_service_update_delete.py" = ["ARG"]
|
||||
"services/document_service_status.py" = ["ARG"]
|
||||
"services/enterprise/test_account_deletion_sync.py" = ["ARG"]
|
||||
"services/plugin/test_plugin_parameter_service.py" = ["ARG"]
|
||||
"services/plugin/test_plugin_service.py" = ["ARG"]
|
||||
"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG"]
|
||||
"services/recommend_app/test_database_retrieval.py" = ["ARG"]
|
||||
"services/test_account_service.py" = ["ARG"]
|
||||
"services/test_advanced_prompt_template_service.py" = ["ARG"]
|
||||
"services/test_annotation_service.py" = ["ARG"]
|
||||
"services/test_api_based_extension_service.py" = ["ARG"]
|
||||
"services/test_api_token_service.py" = ["ARG"]
|
||||
"services/test_app_generate_service.py" = ["ARG"]
|
||||
"services/test_app_service.py" = ["ARG"]
|
||||
"services/test_attachment_service.py" = ["ARG"]
|
||||
"services/test_conversation_variable_updater.py" = ["ARG"]
|
||||
"services/test_dataset_permission_service.py" = ["ARG"]
|
||||
"services/test_dataset_service_batch_update_document_status.py" = ["ARG"]
|
||||
"services/test_dataset_service_retrieval.py" = ["ARG"]
|
||||
"services/test_delete_archived_workflow_run.py" = ["ARG"]
|
||||
"services/test_document_service_rename_document.py" = ["ARG"]
|
||||
"services/test_end_user_service.py" = ["ARG"]
|
||||
"services/test_feature_service.py" = ["ARG"]
|
||||
"services/test_feedback_service.py" = ["ARG"]
|
||||
"services/test_file_service.py" = ["ARG"]
|
||||
"services/test_human_input_delivery_test_service.py" = ["ARG"]
|
||||
"services/test_message_service.py" = ["ARG"]
|
||||
"services/test_messages_clean_service.py" = ["ARG", "S110"]
|
||||
"services/test_metadata_partial_update.py" = ["ARG"]
|
||||
"services/test_metadata_service.py" = ["ARG"]
|
||||
"services/test_model_load_balancing_service.py" = ["ARG"]
|
||||
"services/test_model_provider_service.py" = ["ARG"]
|
||||
"services/test_oauth_server_service.py" = ["ARG"]
|
||||
"services/test_ops_service.py" = ["ARG"]
|
||||
"services/test_saved_message_service.py" = ["ARG"]
|
||||
"services/test_web_conversation_service.py" = ["ARG"]
|
||||
"services/test_webapp_auth_service.py" = ["ARG"]
|
||||
"services/test_webhook_service.py" = ["ARG"]
|
||||
"services/test_workflow_app_service.py" = ["ARG"]
|
||||
"services/test_workflow_draft_variable_service.py" = ["ARG"]
|
||||
"services/test_workflow_run_service.py" = ["ARG"]
|
||||
"services/test_workflow_service.py" = ["ARG"]
|
||||
"services/test_workspace_service.py" = ["ARG"]
|
||||
"services/tools/test_api_tools_manage_service.py" = ["ARG"]
|
||||
"services/tools/test_mcp_tools_manage_service.py" = ["ARG"]
|
||||
"services/tools/test_tools_transform_service.py" = ["ARG"]
|
||||
"services/workflow/test_workflow_converter.py" = ["ARG"]
|
||||
"tasks/test_add_document_to_index_task.py" = ["ARG"]
|
||||
"tasks/test_batch_clean_document_task.py" = ["ARG"]
|
||||
"tasks/test_batch_create_segment_to_index_task.py" = ["ARG"]
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG002", "ARG005"]
|
||||
"repositories/test_workflow_run_repository.py" = ["ARG002"]
|
||||
"services/auth/test_auth_integration.py" = ["ARG002"]
|
||||
"services/dataset_collection_binding.py" = ["ARG002"]
|
||||
"services/document_service_status.py" = ["ARG002"]
|
||||
"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG002"]
|
||||
"services/recommend_app/test_database_retrieval.py" = ["ARG002"]
|
||||
"services/test_account_service.py" = ["ARG002"]
|
||||
"services/test_advanced_prompt_template_service.py" = ["ARG002"]
|
||||
"services/test_app_dsl_service.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"services/test_app_service.py" = ["ARG002"]
|
||||
"services/test_attachment_service.py" = ["ARG002"]
|
||||
"services/test_conversation_variable_updater.py" = ["ARG002"]
|
||||
"services/test_dataset_service_batch_update_document_status.py" = ["ARG002"]
|
||||
"services/test_delete_archived_workflow_run.py" = ["ARG002"]
|
||||
"services/test_document_service_rename_document.py" = ["ARG001"]
|
||||
"services/test_end_user_service.py" = ["ARG002"]
|
||||
"services/test_feature_service.py" = ["ARG002"]
|
||||
"services/test_file_service.py" = ["ARG002"]
|
||||
"services/test_file_service_zip_and_lookup.py" = ["TID251"]
|
||||
"services/test_messages_clean_service.py" = ["ARG002", "S110"]
|
||||
"services/test_metadata_partial_update.py" = ["ARG002"]
|
||||
"services/test_metadata_service.py" = ["ARG002"]
|
||||
"services/test_model_load_balancing_service.py" = ["ARG002"]
|
||||
"services/test_model_provider_service.py" = ["ARG002"]
|
||||
"services/test_ops_service.py" = ["ARG002"]
|
||||
"services/test_webapp_auth_service.py" = ["ARG002"]
|
||||
"services/test_webhook_service.py" = ["ARG002"]
|
||||
"services/test_workflow_draft_variable_service.py" = ["ARG002"]
|
||||
"services/test_workflow_run_service.py" = ["ARG002"]
|
||||
"services/test_workflow_service.py" = ["ARG002"]
|
||||
"services/test_workspace_service.py" = ["ARG002"]
|
||||
"services/tools/test_api_tools_manage_service.py" = ["ARG002"]
|
||||
"services/tools/test_mcp_tools_manage_service.py" = ["ARG002", "ARG005"]
|
||||
"services/tools/test_tools_transform_service.py" = ["ARG002"]
|
||||
"services/workflow/test_workflow_converter.py" = ["ARG002"]
|
||||
"tasks/test_add_document_to_index_task.py" = ["ARG002"]
|
||||
"tasks/test_batch_clean_document_task.py" = ["ARG002"]
|
||||
"tasks/test_batch_create_segment_to_index_task.py" = ["ARG001", "ARG002"]
|
||||
"tasks/test_clean_dataset_task.py" = ["T201"]
|
||||
"tasks/test_clean_notion_document_task.py" = ["ARG"]
|
||||
"tasks/test_create_segment_to_index_task.py" = ["ARG"]
|
||||
"tasks/test_dataset_indexing_task.py" = ["ARG"]
|
||||
"tasks/test_deal_dataset_vector_index_task.py" = ["ARG"]
|
||||
"tasks/test_delete_segment_from_index_task.py" = ["ARG"]
|
||||
"tasks/test_disable_segment_from_index_task.py" = ["ARG"]
|
||||
"tasks/test_disable_segments_from_index_task.py" = ["ARG"]
|
||||
"tasks/test_document_indexing_sync_task.py" = ["ARG"]
|
||||
"tasks/test_document_indexing_task.py" = ["ARG"]
|
||||
"tasks/test_document_indexing_update_task.py" = ["ARG"]
|
||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG"]
|
||||
"tasks/test_enable_segments_to_index_task.py" = ["ARG"]
|
||||
"tasks/test_mail_change_mail_task.py" = ["ARG"]
|
||||
"tasks/test_mail_email_code_login_task.py" = ["ARG"]
|
||||
"tasks/test_mail_human_input_delivery_task.py" = ["ARG"]
|
||||
"tasks/test_mail_inner_task.py" = ["ARG"]
|
||||
"tasks/test_mail_invite_member_task.py" = ["ARG"]
|
||||
"tasks/test_mail_owner_transfer_task.py" = ["ARG"]
|
||||
"tasks/test_mail_register_task.py" = ["ARG"]
|
||||
"tasks/test_rag_pipeline_run_tasks.py" = ["ARG"]
|
||||
"tasks/test_clean_notion_document_task.py" = ["ARG002"]
|
||||
"tasks/test_create_segment_to_index_task.py" = ["ARG002"]
|
||||
"tasks/test_dataset_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_deal_dataset_vector_index_task.py" = ["ARG002"]
|
||||
"tasks/test_delete_segment_from_index_task.py" = ["ARG002"]
|
||||
"tasks/test_disable_segment_from_index_task.py" = ["ARG002"]
|
||||
"tasks/test_disable_segments_from_index_task.py" = ["ARG002"]
|
||||
"tasks/test_document_indexing_sync_task.py" = ["ARG002"]
|
||||
"tasks/test_document_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_document_indexing_update_task.py" = ["ARG002"]
|
||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_enable_segments_to_index_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_change_mail_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_email_code_login_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_human_input_delivery_task.py" = ["ARG001"]
|
||||
"tasks/test_mail_inner_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_invite_member_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_owner_transfer_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_register_task.py" = ["ARG002"]
|
||||
"tasks/test_rag_pipeline_run_tasks.py" = ["ARG002"]
|
||||
"test_workflow_pause_integration.py" = ["T201"]
|
||||
"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG"]
|
||||
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG"]
|
||||
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG"]
|
||||
"trigger/conftest.py" = ["ANN401", "TID251"]
|
||||
"trigger/test_trigger_e2e.py" = ["ANN401", "ARG001", "TID251"]
|
||||
"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG002"]
|
||||
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG002"]
|
||||
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG002"]
|
||||
"workflow/nodes/code_executor/test_utils.py" = ["T201"]
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."typing.Any"]
|
||||
msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead."
|
||||
|
||||
+71
-480
@@ -1,494 +1,85 @@
|
||||
"""Testcontainers integration tests for controllers.console.datasets.data_source endpoints."""
|
||||
"""Integration coverage for Notion page bindings backed by persisted documents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from inspect import unwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets import data_source
|
||||
from controllers.console.datasets.data_source import (
|
||||
DataSourceApi,
|
||||
DataSourceNotionDatasetSyncApi,
|
||||
DataSourceNotionDocumentSyncApi,
|
||||
DataSourceNotionIndexingEstimateApi,
|
||||
DataSourceNotionListApi,
|
||||
DataSourceNotionPreviewApi,
|
||||
)
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from models import Account, DataSourceOauthBinding
|
||||
from controllers.console.datasets.data_source import DataSourceNotionListApi
|
||||
from models import Account
|
||||
from models.dataset import Document
|
||||
from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
account = Account(name="Test User", email="u1@example.com")
|
||||
account.id = "u1"
|
||||
return account
|
||||
def test_notion_page_is_marked_bound_from_persisted_document(
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
dataset_id = str(uuid4())
|
||||
account = Account(name="Test User", email="user@example.com")
|
||||
account.id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info='{"notion_page_id": "page-1"}',
|
||||
batch=f"batch-{uuid4()}",
|
||||
name="Notion Page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
indexing_status=IndexingStatus.COMPLETED,
|
||||
enabled=True,
|
||||
)
|
||||
db_session_with_containers.add(document)
|
||||
db_session_with_containers.commit()
|
||||
runtime = MagicMock(
|
||||
get_online_document_pages=lambda **_kwargs: iter(
|
||||
[
|
||||
MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="workspace-1",
|
||||
workspace_name="Workspace",
|
||||
workspace_icon=None,
|
||||
pages=[
|
||||
MagicMock(
|
||||
page_id="page-1",
|
||||
page_name="Page",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
datasource_provider_type=lambda: None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_engine() -> Iterator[None]:
|
||||
with patch.object(
|
||||
type(data_source.db),
|
||||
"engine",
|
||||
new_callable=PropertyMock,
|
||||
return_value=MagicMock(),
|
||||
with (
|
||||
flask_app_with_containers.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "token"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(data_source_type="notion_import"),
|
||||
),
|
||||
patch(
|
||||
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
|
||||
return_value=runtime,
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
class TestDataSourceApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_success(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
binding = DataSourceOauthBinding(
|
||||
tenant_id="tenant-1",
|
||||
access_token="token",
|
||||
provider="notion",
|
||||
source_info={
|
||||
"workspace_name": "Workspace",
|
||||
"workspace_id": "workspace-1",
|
||||
"workspace_icon": None,
|
||||
"total": 1,
|
||||
"pages": [
|
||||
{
|
||||
"page_id": "page-1",
|
||||
"page_name": "Page",
|
||||
"page_icon": {"type": "emoji", "emoji": "P", "url": None},
|
||||
"parent_id": "parent-1",
|
||||
"type": "page",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
binding.id = "b1"
|
||||
binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC)
|
||||
binding.disabled = False
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.db.session.scalars",
|
||||
return_value=MagicMock(all=lambda: [binding]),
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
assert response["data"][0] == {
|
||||
"id": "b1",
|
||||
"provider": "notion",
|
||||
"created_at": 1779670923,
|
||||
"is_bound": True,
|
||||
"disabled": False,
|
||||
"source_info": {
|
||||
"workspace_name": "Workspace",
|
||||
"workspace_id": "workspace-1",
|
||||
"workspace_icon": None,
|
||||
"pages": [
|
||||
{
|
||||
"page_name": "Page",
|
||||
"page_id": "page-1",
|
||||
"page_icon": {"type": "emoji", "url": None, "emoji": "P"},
|
||||
"parent_id": "parent-1",
|
||||
"type": "page",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
"link": "http://localhost/console/api/oauth/data-source/notion",
|
||||
}
|
||||
|
||||
def test_get_no_bindings(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.db.session.scalars",
|
||||
return_value=MagicMock(all=lambda: []),
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
assert response["data"] == []
|
||||
|
||||
def test_patch_enable_binding(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
response, status = method(api, session, "tenant-1", "b1", "enable")
|
||||
|
||||
assert status == 200
|
||||
assert binding.disabled is False
|
||||
|
||||
def test_patch_disable_binding(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=False)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
response, status = method(api, session, "tenant-1", "b1", "disable")
|
||||
|
||||
assert status == 200
|
||||
assert binding.disabled is True
|
||||
|
||||
def test_patch_binding_not_found(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, session, "tenant-1", "b1", "enable")
|
||||
|
||||
def test_patch_enable_already_enabled(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=False)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, session, "tenant-1", "b1", "enable")
|
||||
|
||||
def test_patch_disable_already_disabled(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, session, "tenant-1", "b1", "disable")
|
||||
|
||||
|
||||
class TestDataSourceNotionListApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_credential_not_found(self, app: Flask, current_user: Account) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
|
||||
def test_get_success_no_dataset_id(self, app: Flask, current_user: Account, mock_engine: None) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
page = MagicMock(
|
||||
page_id="p1",
|
||||
page_name="Page 1",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
response, status = unwrap(DataSourceNotionListApi().get)(
|
||||
DataSourceNotionListApi(), db_session_with_containers, tenant_id, account
|
||||
)
|
||||
|
||||
online_document_message = MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="w1",
|
||||
workspace_name="My Workspace",
|
||||
workspace_icon="icon",
|
||||
pages=[page],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "t"},
|
||||
),
|
||||
patch(
|
||||
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
|
||||
return_value=MagicMock(
|
||||
get_online_document_pages=lambda **kw: iter([online_document_message]),
|
||||
datasource_provider_type=lambda: None,
|
||||
),
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_success_with_dataset_id(
|
||||
self, app: Flask, current_user: Account, mock_engine: None, db_session_with_containers: Session
|
||||
) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
tenant_id = str(uuid4())
|
||||
dataset_id = str(uuid4())
|
||||
|
||||
page = MagicMock(
|
||||
page_id="p1",
|
||||
page_name="Page 1",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
)
|
||||
|
||||
online_document_message = MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="w1",
|
||||
workspace_name="My Workspace",
|
||||
workspace_icon="icon",
|
||||
pages=[page],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
dataset = MagicMock(data_source_type="notion_import")
|
||||
document = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info='{"notion_page_id": "p1"}',
|
||||
batch=f"batch-{uuid4()}",
|
||||
name="Notion Page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
indexing_status=IndexingStatus.COMPLETED,
|
||||
enabled=True,
|
||||
)
|
||||
db_session_with_containers.add(document)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
with (
|
||||
app.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=dataset,
|
||||
),
|
||||
patch(
|
||||
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
|
||||
return_value=MagicMock(
|
||||
get_online_document_pages=lambda **kw: iter([online_document_message]),
|
||||
datasource_provider_type=lambda: None,
|
||||
),
|
||||
),
|
||||
):
|
||||
response, status = method(api, db_session_with_containers, tenant_id, current_user)
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_invalid_dataset_type(self, app: Flask, current_user: Account, mock_engine: None) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
dataset = MagicMock(data_source_type="other_type")
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1&dataset_id=ds1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=dataset,
|
||||
),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
|
||||
|
||||
class TestDataSourceNotionPreviewApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_preview_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionPreviewApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")])
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"integration_secret": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.NotionExtractor",
|
||||
return_value=extractor,
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1", "p1", "page")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionIndexingEstimateApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_post_indexing_estimate_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionIndexingEstimateApi()
|
||||
method = inspect.unwrap(api.post)
|
||||
|
||||
empty_rules: dict[str, object] = {}
|
||||
payload: dict[str, object] = {
|
||||
"notion_info_list": [
|
||||
{
|
||||
"workspace_id": "w1",
|
||||
"credential_id": "c1",
|
||||
"pages": [{"page_id": "p1", "type": "page"}],
|
||||
}
|
||||
],
|
||||
"process_rule": {"rules": empty_rules},
|
||||
"doc_form": IndexStructureType.PARAGRAPH_INDEX,
|
||||
"doc_language": "English",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.estimate_args_validate",
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.IndexingRunner.indexing_estimate",
|
||||
return_value=MagicMock(model_dump=lambda: {"total_pages": 1}),
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionDatasetSyncApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id",
|
||||
return_value=[MagicMock(id="d1")],
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "ds-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_dataset_not_found(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), "ds-1")
|
||||
|
||||
|
||||
class TestDataSourceNotionDocumentSyncApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "ds-1", "doc-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_document_not_found(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), "ds-1", "doc-1")
|
||||
assert status == 200
|
||||
assert response["notion_info"][0]["pages"][0]["is_bound"] is True
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
preset = "strict"
|
||||
strict-callable-subtyping = true
|
||||
project-includes = ["."]
|
||||
search-path = ["../.."]
|
||||
python-platform = "linux"
|
||||
python-version = "3.12.0"
|
||||
infer-with-first-use = true
|
||||
min-severity = "warn"
|
||||
|
||||
# Verify project-excludes from the repo root:
|
||||
# tmp_config=$(mktemp --tmpdir=api/tests/test_containers_integration_tests pyrefly-no-excludes.XXXXXX.toml)
|
||||
# awk 'BEGIN {skip=0} /^project-excludes = \[/ {skip=1; next} skip && /^\]/ {skip=0; next} !skip {print}' api/tests/test_containers_integration_tests/pyrefly.toml > "$tmp_config"
|
||||
# tmp_name=$(basename "$tmp_config")
|
||||
# comm -3 <(sed -n 's/^ "\(.*\)",$/\1/p' api/tests/test_containers_integration_tests/pyrefly.toml | sort) <(uv --directory api run pyrefly check --config "tests/test_containers_integration_tests/$tmp_name" --summary=none --output-format=min-text 2>/dev/null | rg '^ERROR ' | sed -E 's#^ERROR (tests/test_containers_integration_tests/[^:]+):.*#\1#' | sed 's#^tests/test_containers_integration_tests/##' | sort -u)
|
||||
# rm --force "$tmp_config"
|
||||
# Existing strict-mode debt. Remove a file when bringing it under strict checking.
|
||||
project-excludes = [
|
||||
"commands/test_legacy_model_type_migration.py",
|
||||
"controllers/console/app/test_app_apis.py",
|
||||
"controllers/console/app/test_app_import_api.py",
|
||||
"controllers/console/app/test_chat_conversation_status_count_api.py",
|
||||
"controllers/console/app/test_conversation_read_timestamp.py",
|
||||
"controllers/console/app/test_workflow_draft_variable.py",
|
||||
"controllers/console/auth/test_email_register.py",
|
||||
"controllers/console/auth/test_forgot_password.py",
|
||||
"controllers/console/auth/test_oauth.py",
|
||||
"controllers/console/auth/test_password_reset.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py",
|
||||
"controllers/console/datasets/test_data_source.py",
|
||||
"controllers/console/explore/test_conversation.py",
|
||||
"controllers/console/test_api_based_extension.py",
|
||||
"controllers/console/test_apikey.py",
|
||||
"controllers/console/workspace/test_tool_provider.py",
|
||||
"controllers/console/workspace/test_trigger_providers.py",
|
||||
"controllers/console/workspace/test_workspace_wraps.py",
|
||||
"controllers/mcp/test_mcp.py",
|
||||
"controllers/service_api/dataset/test_dataset.py",
|
||||
"controllers/service_api/test_site.py",
|
||||
"controllers/web/test_conversation.py",
|
||||
"controllers/web/test_site.py",
|
||||
"controllers/web/test_web_forgot_password.py",
|
||||
"controllers/web/test_wraps.py",
|
||||
"core/app/layers/test_pause_state_persist_layer.py",
|
||||
"core/rag/pipeline/test_queue_integration.py",
|
||||
@@ -57,22 +39,16 @@ project-excludes = [
|
||||
"repositories/test_sqlalchemy_execution_extra_content_repository.py",
|
||||
"repositories/test_sqlalchemy_workflow_node_execution_repository.py",
|
||||
"repositories/test_workflow_run_repository.py",
|
||||
"services/auth/test_api_key_auth_service.py",
|
||||
"services/auth/test_auth_integration.py",
|
||||
"services/dataset_collection_binding.py",
|
||||
"services/dataset_service_update_delete.py",
|
||||
"services/document_service_status.py",
|
||||
"services/enterprise/test_account_deletion_sync.py",
|
||||
"services/plugin/test_plugin_parameter_service.py",
|
||||
"services/plugin/test_plugin_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_service_db.py",
|
||||
"services/recommend_app/test_database_retrieval.py",
|
||||
"services/test_account_service.py",
|
||||
"services/test_advanced_prompt_template_service.py",
|
||||
"services/test_agent_service.py",
|
||||
"services/test_annotation_service.py",
|
||||
"services/test_api_based_extension_service.py",
|
||||
"services/test_api_token_service.py",
|
||||
"services/test_app_dsl_service.py",
|
||||
"services/test_app_generate_service.py",
|
||||
"services/test_app_service.py",
|
||||
@@ -97,20 +73,15 @@ project-excludes = [
|
||||
"services/test_document_service_rename_document.py",
|
||||
"services/test_end_user_service.py",
|
||||
"services/test_feature_service.py",
|
||||
"services/test_feedback_service.py",
|
||||
"services/test_file_service.py",
|
||||
"services/test_human_input_delivery_test.py",
|
||||
"services/test_human_input_delivery_test_service.py",
|
||||
"services/test_message_export_service.py",
|
||||
"services/test_message_service.py",
|
||||
"services/test_message_service_execution_extra_content.py",
|
||||
"services/test_message_service_extra_contents.py",
|
||||
"services/test_messages_clean_service.py",
|
||||
"services/test_metadata_partial_update.py",
|
||||
"services/test_metadata_service.py",
|
||||
"services/test_model_load_balancing_service.py",
|
||||
"services/test_model_provider_service.py",
|
||||
"services/test_oauth_server_service.py",
|
||||
"services/test_ops_service.py",
|
||||
"services/test_restore_archived_workflow_run.py",
|
||||
"services/test_saved_message_service.py",
|
||||
@@ -124,7 +95,6 @@ project-excludes = [
|
||||
"services/test_workflow_run_service.py",
|
||||
"services/test_workflow_service.py",
|
||||
"services/test_workspace_service.py",
|
||||
"services/tools/test_api_tools_manage_service.py",
|
||||
"services/tools/test_mcp_tools_manage_service.py",
|
||||
"services/tools/test_tools_transform_service.py",
|
||||
"services/tools/test_workflow_tools_manage_service.py",
|
||||
@@ -161,7 +131,6 @@ project-excludes = [
|
||||
"test_workflow_pause_integration.py",
|
||||
"trigger/conftest.py",
|
||||
"trigger/test_trigger_e2e.py",
|
||||
"workflow/nodes/code_executor/test_code_executor.py",
|
||||
"workflow/nodes/code_executor/test_code_javascript.py",
|
||||
"workflow/nodes/code_executor/test_code_jinja2.py",
|
||||
"workflow/nodes/code_executor/test_code_python3.py",
|
||||
@@ -169,6 +138,7 @@ project-excludes = [
|
||||
]
|
||||
|
||||
[errors]
|
||||
missing-override-decorator = "error"
|
||||
redundant-cast = true
|
||||
unannotated-return = true
|
||||
unnecessary-type-conversion = true
|
||||
|
||||
+1
-58
@@ -4,7 +4,7 @@ import datetime
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@@ -1172,65 +1172,8 @@ class TestMessagesCleanServiceIntegration:
|
||||
# Verify all messages were deleted
|
||||
assert db_session_with_containers.query(Message).where(Message.id.in_(msg_ids)).count() == 0
|
||||
|
||||
def test_from_time_range_validation(self):
|
||||
"""Test that from_time_range raises ValueError for invalid inputs."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
now = datetime.datetime.now()
|
||||
|
||||
with pytest.raises(ValueError, match="start_from .* must be less than end_before"):
|
||||
MessagesCleanService.from_time_range(policy, now, now)
|
||||
|
||||
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
|
||||
MessagesCleanService.from_time_range(policy, now - datetime.timedelta(days=1), now, batch_size=0)
|
||||
|
||||
def test_from_time_range_success(self):
|
||||
"""Test that from_time_range creates a service with correct parameters."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
start = datetime.datetime(2024, 1, 1)
|
||||
end = datetime.datetime(2024, 2, 1)
|
||||
|
||||
service = MessagesCleanService.from_time_range(policy, start, end)
|
||||
assert service._start_from == start
|
||||
assert service._end_before == end
|
||||
|
||||
def test_from_days_validation(self):
|
||||
"""Test that from_days raises ValueError for invalid inputs."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
|
||||
with pytest.raises(ValueError, match="days .* must be greater than or equal to 0"):
|
||||
MessagesCleanService.from_days(policy, days=-1)
|
||||
|
||||
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
|
||||
MessagesCleanService.from_days(policy, days=30, batch_size=0)
|
||||
|
||||
def test_from_days_success(self):
|
||||
"""Test that from_days creates a service with correct parameters."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
|
||||
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
|
||||
fixed_now = datetime.datetime(2024, 6, 1)
|
||||
mock_now.return_value = fixed_now
|
||||
|
||||
service = MessagesCleanService.from_days(policy, days=10)
|
||||
assert service._start_from is None
|
||||
assert service._end_before == fixed_now - datetime.timedelta(days=10)
|
||||
|
||||
def test_batch_delete_message_relations_empty(self, db_session_with_containers: Session):
|
||||
"""Test that batch_delete_message_relations with empty list does nothing."""
|
||||
# Get execute call count before
|
||||
MessagesCleanService._batch_delete_message_relations(db_session_with_containers, [])
|
||||
# No exception means success — empty list is a no-op
|
||||
|
||||
def test_run_calls_clean_messages(self):
|
||||
"""Test that run() delegates to _clean_messages_by_time_range."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
service = MessagesCleanService(
|
||||
policy=policy,
|
||||
end_before=datetime.datetime.now(),
|
||||
batch_size=10,
|
||||
)
|
||||
with patch.object(service, "_clean_messages_by_time_range") as mock_clean:
|
||||
mock_clean.return_value = {"total_deleted": 5}
|
||||
result = service.run()
|
||||
assert result == {"total_deleted": 5}
|
||||
mock_clean.assert_called_once()
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -12,13 +11,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
|
||||
from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.workflow import WorkflowAppLogCreatedFrom
|
||||
from services.account_service import AccountService, TenantService
|
||||
|
||||
# Delay import of AppService to avoid circular dependency
|
||||
# from services.app_service import AppService, CreateAppParams
|
||||
from services.workflow_app_service import LogView, WorkflowAppService
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
from tests.test_containers_integration_tests.helpers import generate_valid_password
|
||||
|
||||
|
||||
@@ -1627,73 +1626,3 @@ class TestWorkflowAppService:
|
||||
end_user_item = next(d for d in result["data"] if d["created_by_end_user"] is not None)
|
||||
assert account_item["created_by_account"].id == account.id
|
||||
assert end_user_item["created_by_end_user"].id == end_user.id
|
||||
|
||||
|
||||
class TestLogView:
|
||||
def test_details_and_proxy_attributes(self):
|
||||
log = SimpleNamespace(id="log-1", status="succeeded")
|
||||
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
|
||||
|
||||
assert view.details == {"trigger_metadata": {"type": "plugin"}}
|
||||
assert view.status == "succeeded"
|
||||
|
||||
|
||||
class TestHandleTriggerMetadata:
|
||||
def test_returns_empty_dict_when_metadata_missing(self):
|
||||
service = WorkflowAppService()
|
||||
assert service.handle_trigger_metadata("tenant-1", None) == {}
|
||||
|
||||
def test_enriches_plugin_icons(self):
|
||||
service = WorkflowAppService()
|
||||
meta = {
|
||||
"type": AppTriggerType.TRIGGER_PLUGIN.value,
|
||||
"icon_filename": "light.png",
|
||||
"icon_dark_filename": "dark.png",
|
||||
}
|
||||
with patch(
|
||||
"services.workflow_app_service.PluginService.get_plugin_icon_url",
|
||||
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
|
||||
) as mock_icon:
|
||||
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
|
||||
|
||||
assert result["icon"] == "https://cdn/light.png"
|
||||
assert result["icon_dark"] == "https://cdn/dark.png"
|
||||
assert mock_icon.call_count == 2
|
||||
|
||||
def test_non_plugin_metadata_without_icon_lookup(self):
|
||||
service = WorkflowAppService()
|
||||
meta = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
|
||||
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
|
||||
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
|
||||
|
||||
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
|
||||
mock_icon.assert_not_called()
|
||||
|
||||
|
||||
class TestSafeJsonLoads:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
('{"k":"v"}', {"k": "v"}),
|
||||
("not-json", None),
|
||||
({"raw": True}, {"raw": True}),
|
||||
],
|
||||
)
|
||||
def test_handles_various_inputs(self, value, expected):
|
||||
assert WorkflowAppService._safe_json_loads(value) == expected
|
||||
|
||||
|
||||
class TestSafeParseUuid:
|
||||
def test_returns_none_for_short_or_invalid_values(self):
|
||||
service = WorkflowAppService()
|
||||
assert service._safe_parse_uuid("short") is None
|
||||
assert service._safe_parse_uuid("x" * 40) is None
|
||||
|
||||
def test_returns_uuid_for_valid_string(self):
|
||||
service = WorkflowAppService()
|
||||
raw = str(uuid.uuid4())
|
||||
result = service._safe_parse_uuid(raw)
|
||||
assert result is not None
|
||||
assert str(result) == raw
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
extend = "../../.ruff.toml"
|
||||
src = ["../.."]
|
||||
|
||||
[lint]
|
||||
extend-select = ["ANN401", "ARG"]
|
||||
|
||||
# Existing strict-mode debt. Remove a file entry when bringing it under strict checking.
|
||||
[lint.per-file-ignores]
|
||||
"clients/agent_backend/test_request_builder.py" = ["TID251"]
|
||||
"commands/test_archive_workflow_runs.py" = ["ARG005"]
|
||||
"commands/test_data_migration_wizard.py" = ["ARG005"]
|
||||
"commands/test_legacy_model_type_migration.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"controllers/common/test_agent_app_parameters.py" = ["ARG005", "TID251"]
|
||||
"controllers/common/test_app_access.py" = ["ARG005"]
|
||||
"controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"]
|
||||
"controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/app/test_agent_config_inspector.py" = ["ARG005"]
|
||||
"controllers/console/app/test_agent_drive_inspector.py" = ["ARG005"]
|
||||
"controllers/console/app/test_agent_manage_guard.py" = ["ARG001"]
|
||||
"controllers/console/app/test_agent_skills.py" = ["ARG005"]
|
||||
"controllers/console/app/test_annotation_security.py" = ["ARG002"]
|
||||
"controllers/console/app/test_app_apis.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/app/test_app_import_api.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"controllers/console/app/test_app_response_models.py" = ["ARG002", "ARG004", "ARG005"]
|
||||
"controllers/console/app/test_conversation_api.py" = ["ARG001"]
|
||||
"controllers/console/app/test_generator_api.py" = ["ARG001"]
|
||||
"controllers/console/app/test_mcp_server_response.py" = ["ARG002"]
|
||||
"controllers/console/app/test_message_api.py" = ["ARG001"]
|
||||
"controllers/console/app/test_statistic_api.py" = ["ANN401", "ARG001", "TID251"]
|
||||
"controllers/console/app/test_workflow.py" = ["ARG001", "ARG005"]
|
||||
"controllers/console/app/test_workflow_convert_api.py" = ["ARG005"]
|
||||
"controllers/console/app/test_workflow_node_output_inspector.py" = ["ANN401", "ARG001", "TID251"]
|
||||
"controllers/console/app/test_workflow_run_api.py" = ["ANN401", "TID251"]
|
||||
"controllers/console/app/workflow_draft_variables_test.py" = ["TID251"]
|
||||
"controllers/console/auth/test_account_activation.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_authentication_security.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_email_verification.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_login_logout.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_oauth.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_oauth_timezone.py" = ["ARG001"]
|
||||
"controllers/console/auth/test_password_reset.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_token_refresh.py" = ["ARG002"]
|
||||
"controllers/console/billing/test_billing.py" = ["ARG002"]
|
||||
"controllers/console/datasets/test_datasets.py" = ["ARG005"]
|
||||
"controllers/console/datasets/test_datasets_document.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/datasets/test_datasets_document_download.py" = ["ARG005"]
|
||||
"controllers/console/datasets/test_datasets_segments.py" = ["TID251"]
|
||||
"controllers/console/datasets/test_external.py" = ["TID251"]
|
||||
"controllers/console/datasets/test_wraps.py" = ["ARG001"]
|
||||
"controllers/console/explore/test_trial.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"controllers/console/explore/test_wraps.py" = ["ARG001"]
|
||||
"controllers/console/snippets/test_snippet_workflow.py" = ["ARG001"]
|
||||
"controllers/console/tag/test_tags.py" = ["ARG002"]
|
||||
"controllers/console/test_files.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/test_human_input_form.py" = ["ARG001", "ARG005"]
|
||||
"controllers/console/test_init_validate.py" = ["ARG005"]
|
||||
"controllers/console/test_workspace_account.py" = ["ARG002"]
|
||||
"controllers/console/test_workspace_members.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/test_wraps.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/workspace/test_load_balancing_config.py" = ["ARG001"]
|
||||
"controllers/console/workspace/test_plugin.py" = ["ARG002", "TID251"]
|
||||
"controllers/console/workspace/test_snippets.py" = ["ARG002"]
|
||||
"controllers/console/workspace/test_tool_providers.py" = ["ARG001", "ARG005"]
|
||||
"controllers/console/workspace/test_trigger_providers.py" = ["ARG001"]
|
||||
"controllers/console/workspace/test_workspace.py" = ["ARG005"]
|
||||
"controllers/files/test_image_preview.py" = ["ARG005"]
|
||||
"controllers/files/test_tool_files.py" = ["ARG002", "ARG005"]
|
||||
"controllers/files/test_upload.py" = ["ARG002", "ARG005"]
|
||||
"controllers/inner_api/plugin/test_plugin.py" = ["ARG002"]
|
||||
"controllers/inner_api/plugin/test_plugin_wraps.py" = ["ARG001", "ARG002", "ARG003", "TID251"]
|
||||
"controllers/inner_api/test_runtime_credentials.py" = ["ARG001"]
|
||||
"controllers/mcp/test_mcp.py" = ["ARG002"]
|
||||
"controllers/openapi/auth/test_conditions.py" = ["ARG005"]
|
||||
"controllers/openapi/auth/test_flow.py" = ["ARG005"]
|
||||
"controllers/openapi/auth/test_pipeline.py" = ["ARG001"]
|
||||
"controllers/openapi/conftest.py" = ["ARG001"]
|
||||
"controllers/openapi/test_account.py" = ["ARG005"]
|
||||
"controllers/openapi/test_app_describe_builder.py" = ["ARG001"]
|
||||
"controllers/openapi/test_app_run_streaming.py" = ["ARG001"]
|
||||
"controllers/openapi/test_contract.py" = ["ARG001", "TID251"]
|
||||
"controllers/openapi/test_error_contract.py" = ["ARG002"]
|
||||
"controllers/openapi/test_human_input_form.py" = ["ARG002"]
|
||||
"controllers/openapi/test_oauth_sso_claims.py" = ["ARG002"]
|
||||
"controllers/openapi/test_workflow_events_openapi.py" = ["ARG002", "ARG005"]
|
||||
"controllers/openapi/test_workspaces_members.py" = ["ARG001"]
|
||||
"controllers/service_api/app/test_app.py" = ["ARG002"]
|
||||
"controllers/service_api/app/test_completion.py" = ["ARG002"]
|
||||
"controllers/service_api/app/test_file.py" = ["ARG002"]
|
||||
"controllers/service_api/app/test_hitl_service_api.py" = ["ARG002", "ARG005"]
|
||||
"controllers/service_api/app/test_workflow_events.py" = ["ARG005"]
|
||||
"controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py" = ["ARG002"]
|
||||
"controllers/service_api/dataset/test_dataset_segment.py" = ["ARG002"]
|
||||
"controllers/service_api/dataset/test_document.py" = ["ARG001", "ARG002"]
|
||||
"controllers/service_api/dataset/test_metadata.py" = ["ARG002"]
|
||||
"controllers/service_api/test_trace_session_id_parsing.py" = ["ARG001"]
|
||||
"controllers/service_api/test_wraps.py" = ["ARG001", "ARG002"]
|
||||
"controllers/trigger/test_trigger.py" = ["ARG002"]
|
||||
"controllers/trigger/test_webhook.py" = ["ARG002"]
|
||||
"controllers/web/conftest.py" = ["ANN401", "TID251"]
|
||||
"controllers/web/test_app.py" = ["ARG002", "ARG005"]
|
||||
"controllers/web/test_audio.py" = ["ARG002"]
|
||||
"controllers/web/test_completion.py" = ["ARG002"]
|
||||
"controllers/web/test_feature.py" = ["ARG002"]
|
||||
"controllers/web/test_human_input_form.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"controllers/web/test_message_endpoints.py" = ["ARG002"]
|
||||
"controllers/web/test_remote_files.py" = ["ARG002"]
|
||||
"controllers/web/test_saved_message.py" = ["ARG002"]
|
||||
"controllers/web/test_web_login.py" = ["ARG002"]
|
||||
"controllers/web/test_web_passport.py" = ["ARG002"]
|
||||
"controllers/web/test_workflow.py" = ["ARG002"]
|
||||
"core/agent/test_base_agent_runner.py" = ["ARG002"]
|
||||
"core/agent/test_cot_agent_runner.py" = ["ARG001"]
|
||||
"core/agent/test_cot_chat_agent_runner.py" = ["ARG002"]
|
||||
"core/agent/test_fc_agent_runner.py" = ["TID251"]
|
||||
"core/app/app_config/common/test_parameters_mapping.py" = ["ARG002"]
|
||||
"core/app/app_config/easy_ui_based_app/test_dataset_manager.py" = ["ARG001", "ARG002"]
|
||||
"core/app/app_config/easy_ui_based_app/test_model_config_converter.py" = ["ARG002"]
|
||||
"core/app/app_config/easy_ui_based_app/test_variables_manager.py" = ["ARG002"]
|
||||
"core/app/apps/advanced_chat/test_app_generator.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/advanced_chat/test_app_runner_input_moderation.py" = ["ARG001", "ARG005"]
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline.py" = ["ARG005"]
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/agent_app/test_app_generator.py" = ["ARG001", "ARG005"]
|
||||
"core/app/apps/agent_app/test_app_runner.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"core/app/apps/agent_app/test_input_guards.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/app/apps/agent_app/test_resolve_agent.py" = ["ANN401", "TID251"]
|
||||
"core/app/apps/agent_app/test_runtime_request_builder.py" = ["ARG002", "TID251"]
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_config_manager.py" = ["ARG005"]
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_generator.py" = ["ARG001"]
|
||||
"core/app/apps/chat/test_app_config_manager.py" = ["ARG001"]
|
||||
"core/app/apps/chat/test_app_generator_and_runner.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/common/test_workflow_response_converter_truncation.py" = ["TID251"]
|
||||
"core/app/apps/completion/test_app_runner.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/pipeline/test_pipeline_generator.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/pipeline/test_pipeline_runner.py" = ["ARG001", "ARG002"]
|
||||
"core/app/apps/test_advanced_chat_app_generator.py" = ["ARG001"]
|
||||
"core/app/apps/test_base_app_generator.py" = ["ARG005"]
|
||||
"core/app/apps/test_base_app_runner.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/test_pause_resume.py" = ["ANN401", "TID251"]
|
||||
"core/app/apps/test_streaming_utils.py" = ["ARG001"]
|
||||
"core/app/apps/test_workflow_app_generator.py" = ["ARG005"]
|
||||
"core/app/apps/test_workflow_app_runner_core.py" = ["ARG001", "ARG002", "ARG004", "ARG005"]
|
||||
"core/app/apps/test_workflow_app_runner_single_node.py" = ["ANN401", "TID251"]
|
||||
"core/app/apps/test_workflow_pause_events.py" = ["ARG005"]
|
||||
"core/app/apps/workflow/test_app_generator_extra.py" = ["ARG005"]
|
||||
"core/app/apps/workflow/test_generate_task_pipeline_core.py" = ["ARG002", "ARG005"]
|
||||
"core/app/features/rate_limiting/test_rate_limit.py" = ["ARG001"]
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py" = ["ARG002"]
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/task_pipeline/test_message_cycle_manager_optimization.py" = ["ARG002"]
|
||||
"core/app/test_easy_ui_model_config_manager.py" = ["ARG005"]
|
||||
"core/app/workflow/layers/test_persistence_inspector_publish.py" = ["ANN401", "ARG005", "TID251"]
|
||||
"core/app/workflow/test_file_runtime.py" = ["ARG001", "ARG005"]
|
||||
"core/app/workflow/test_observability_layer_extra.py" = ["ARG005"]
|
||||
"core/app/workflow/test_persistence_layer.py" = ["ARG001"]
|
||||
"core/base/test_app_generator_tts_publisher.py" = ["ARG002"]
|
||||
"core/callback_handler/test_agent_tool_callback_handler.py" = ["ARG002"]
|
||||
"core/callback_handler/test_workflow_tool_callback_handler.py" = ["ARG002"]
|
||||
"core/datasource/__base/test_datasource_provider.py" = ["ARG002"]
|
||||
"core/datasource/test_datasource_file_manager.py" = ["ARG001", "ARG002"]
|
||||
"core/datasource/test_notion_provider.py" = ["ARG002", "TID251"]
|
||||
"core/datasource/test_website_crawl.py" = ["ARG002"]
|
||||
"core/datasource/utils/test_message_transformer.py" = ["ARG002"]
|
||||
"core/entities/test_entities_mcp_provider.py" = ["ARG001"]
|
||||
"core/entities/test_entities_provider_configuration.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/extension/test_extensible.py" = ["ARG002", "ARG005"]
|
||||
"core/external_data_tool/api/test_api.py" = ["ARG001"]
|
||||
"core/external_data_tool/test_base.py" = ["TID251"]
|
||||
"core/external_data_tool/test_external_data_fetch.py" = ["ARG001"]
|
||||
"core/helper/code_executor/test_code_executor.py" = ["TID251"]
|
||||
"core/helper/code_executor/test_template_transformer.py" = ["ANN401", "TID251"]
|
||||
"core/llm_generator/test_llm_generator.py" = ["ARG002"]
|
||||
"core/mcp/auth/test_auth_flow.py" = ["ARG002"]
|
||||
"core/mcp/client/test_session.py" = ["ARG001", "TID251"]
|
||||
"core/mcp/client/test_sse.py" = ["ARG001", "TID251"]
|
||||
"core/mcp/client/test_streamable_http.py" = ["ARG001", "ARG005", "S110", "TID251"]
|
||||
"core/mcp/session/test_base_session.py" = ["S110"]
|
||||
"core/mcp/session/test_client_session.py" = ["ARG005"]
|
||||
"core/mcp/test_mcp_client.py" = ["ARG002"]
|
||||
"core/memory/test_token_buffer_memory.py" = ["ARG002"]
|
||||
"core/moderation/test_content_moderation.py" = ["TID251"]
|
||||
"core/moderation/test_output_moderation.py" = ["ARG001", "ARG002"]
|
||||
"core/ops/test_base_trace_instance.py" = ["ARG001"]
|
||||
"core/ops/test_lookup_helpers.py" = ["ARG002"]
|
||||
"core/ops/test_ops_trace_manager.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/ops/test_trace_queue_manager.py" = ["ARG004"]
|
||||
"core/ops/test_trace_session_metadata.py" = ["ARG001", "ARG005"]
|
||||
"core/plugin/impl/test_agent_client.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_datasource_manager.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_oauth_handler.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_tool_manager.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_trigger_client.py" = ["ARG001"]
|
||||
"core/plugin/test_endpoint_client.py" = ["ARG002"]
|
||||
"core/plugin/test_model_runtime_adapter.py" = ["ARG002"]
|
||||
"core/plugin/test_plugin_runtime.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/prompt/test_advanced_prompt_transform.py" = ["ARG005"]
|
||||
"core/prompt/test_prompt_transform.py" = ["ARG005"]
|
||||
"core/rag/datasource/keyword/jieba/test_jieba.py" = ["ARG001", "TID251"]
|
||||
"core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py" = ["ARG004"]
|
||||
"core/rag/datasource/keyword/test_keyword_factory.py" = ["ARG005"]
|
||||
"core/rag/datasource/test_datasource_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"core/rag/datasource/test_retrieval_attachment_access.py" = ["ARG005"]
|
||||
"core/rag/datasource/vdb/test_vector_factory.py" = ["ARG002"]
|
||||
"core/rag/embedding/test_embedding_base.py" = ["TID251"]
|
||||
"core/rag/embedding/test_embedding_service.py" = ["ARG001"]
|
||||
"core/rag/extractor/firecrawl/test_firecrawl.py" = ["TID251"]
|
||||
"core/rag/extractor/test_csv_extractor.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/test_excel_extractor.py" = ["ARG002", "ARG005"]
|
||||
"core/rag/extractor/test_extract_processor.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/test_helpers.py" = ["ARG002"]
|
||||
"core/rag/extractor/test_markdown_extractor.py" = ["ARG001"]
|
||||
"core/rag/extractor/test_notion_extractor.py" = ["ARG002", "ARG005"]
|
||||
"core/rag/extractor/test_pdf_extractor.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/test_text_extractor.py" = ["ARG001"]
|
||||
"core/rag/extractor/test_word_extractor.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/rag/extractor/unstructured/test_unstructured_extractors.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/watercrawl/test_watercrawl.py" = ["ARG001", "ARG005", "TID251"]
|
||||
"core/rag/indexing/processor/conftest.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/rag/indexing/processor/test_paragraph_index_processor.py" = ["ARG002", "TID251"]
|
||||
"core/rag/indexing/processor/test_qa_index_processor.py" = ["ARG001", "TID251"]
|
||||
"core/rag/indexing/test_index_processor.py" = ["ARG005"]
|
||||
"core/rag/indexing/test_indexing_runner.py" = ["ARG005", "TID251"]
|
||||
"core/rag/pipeline/test_queue.py" = ["ARG002"]
|
||||
"core/rag/retrieval/test_dataset_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"core/rag/splitter/test_text_splitter.py" = ["ARG002", "ARG005"]
|
||||
"core/repositories/test_celery_workflow_execution_repository.py" = ["ARG002"]
|
||||
"core/repositories/test_celery_workflow_node_execution_repository.py" = ["ARG002"]
|
||||
"core/repositories/test_human_input_form_repository_impl.py" = ["ARG001", "ARG005"]
|
||||
"core/repositories/test_human_input_repository.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/repositories/test_sqlalchemy_workflow_node_execution_repository.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"core/repositories/test_workflow_node_execution_truncation.py" = ["TID251"]
|
||||
"core/schemas/test_resolver.py" = ["ARG005", "T201"]
|
||||
"core/telemetry/test_facade.py" = ["ARG002", "ARG004"]
|
||||
"core/telemetry/test_gateway_integration.py" = ["ARG002"]
|
||||
"core/test_model_manager.py" = ["ARG001"]
|
||||
"core/test_trigger_debug_event_selectors.py" = ["ARG002"]
|
||||
"core/tools/test_base_tool.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/tools/test_builtin_tool_base.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/tools/test_builtin_tool_provider.py" = ["ARG001", "ARG005", "TID251"]
|
||||
"core/tools/test_builtin_tools_extra.py" = ["ARG005"]
|
||||
"core/tools/test_custom_tool.py" = ["ARG001", "ARG005", "TID251"]
|
||||
"core/tools/test_dataset_retriever_tool.py" = ["ARG005"]
|
||||
"core/tools/test_mcp_tool.py" = ["S110"]
|
||||
"core/tools/test_tool_engine.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"core/tools/test_tool_file_manager.py" = ["ARG001"]
|
||||
"core/tools/test_tool_label_manager.py" = ["TID251"]
|
||||
"core/tools/test_tool_manager.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/tools/test_tool_provider_controller.py" = ["TID251"]
|
||||
"core/tools/utils/test_configuration.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/tools/utils/test_encryption.py" = ["ANN401", "TID251"]
|
||||
"core/tools/utils/test_message_transformer.py" = ["TID251"]
|
||||
"core/tools/utils/test_misc_utils_extra.py" = ["ARG002"]
|
||||
"core/tools/utils/test_model_invocation_utils.py" = ["ARG005", "TID251"]
|
||||
"core/tools/utils/test_parser.py" = ["TID251"]
|
||||
"core/tools/utils/test_web_reader_tool.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/tools/workflow_as_tool/test_provider.py" = ["TID251"]
|
||||
"core/tools/workflow_as_tool/test_tool.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/trigger/conftest.py" = ["ANN401", "TID251"]
|
||||
"core/trigger/debug/test_debug_event_selectors.py" = ["ARG002", "TID251"]
|
||||
"core/variables/test_segment_type_validation.py" = ["TID251"]
|
||||
"core/workflow/context/test_execution_context.py" = ["ANN401", "ARG002", "S110", "TID251"]
|
||||
"core/workflow/context/test_flask_app_context.py" = ["ARG002"]
|
||||
"core/workflow/generator/test_runner.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/workflow/generator/test_runner_missing.py" = ["ARG003"]
|
||||
"core/workflow/generator/test_tool_catalogue.py" = ["ARG002"]
|
||||
"core/workflow/graph_engine/layers/test_observability.py" = ["ARG002"]
|
||||
"core/workflow/graph_engine/test_mock_config.py" = ["TID251"]
|
||||
"core/workflow/graph_engine/test_mock_factory.py" = ["TID251"]
|
||||
"core/workflow/graph_engine/test_mock_nodes.py" = ["ANN401", "S110", "TID251"]
|
||||
"core/workflow/graph_engine/test_parallel_human_input_join_resume.py" = ["ARG002", "TID251"]
|
||||
"core/workflow/graph_engine/test_table_runner.py" = ["ARG001", "TID251"]
|
||||
"core/workflow/nodes/agent_v2/test_agent_node.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/workflow/nodes/agent_v2/test_ask_human_hitl.py" = ["ANN401", "TID251"]
|
||||
"core/workflow/nodes/agent_v2/test_dify_tools_builder.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"core/workflow/nodes/agent_v2/test_output_adapter.py" = ["ARG005"]
|
||||
"core/workflow/nodes/agent_v2/test_runtime_request_builder.py" = ["ARG002"]
|
||||
"core/workflow/nodes/agent_v2/test_validators.py" = ["ARG001"]
|
||||
"core/workflow/nodes/http_request/test_http_request_node.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/workflow/nodes/human_input/test_entities.py" = ["TID251"]
|
||||
"core/workflow/nodes/human_input/test_human_input_form_filled_event.py" = ["TID251"]
|
||||
"core/workflow/nodes/iteration/test_iteration_child_engine_errors.py" = ["ARG002", "TID251"]
|
||||
"core/workflow/nodes/knowledge_index/test_knowledge_index_node.py" = ["ARG002"]
|
||||
"core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py" = ["ARG002"]
|
||||
"core/workflow/nodes/llm/test_node.py" = ["ARG002"]
|
||||
"core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py" = ["TID251"]
|
||||
"core/workflow/nodes/test_document_extractor_node.py" = ["ARG001"]
|
||||
"core/workflow/nodes/tool/test_tool_node.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/workflow/nodes/webhook/test_webhook_file_conversion.py" = ["TID251"]
|
||||
"core/workflow/nodes/webhook/test_webhook_node.py" = ["TID251"]
|
||||
"core/workflow/test_form_input_serialization_compat.py" = ["ANN401", "TID251"]
|
||||
"core/workflow/test_human_input_adapter.py" = ["ARG005"]
|
||||
"core/workflow/test_node_factory.py" = ["ARG002"]
|
||||
"core/workflow/test_workflow_entry.py" = ["ARG001"]
|
||||
"enterprise/telemetry/test_enterprise_trace.py" = ["ARG002", "TID251"]
|
||||
"enterprise/telemetry/test_exporter.py" = ["ARG001"]
|
||||
"enterprise/telemetry/test_gateway.py" = ["ARG002"]
|
||||
"events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py" = ["ARG005"]
|
||||
"extensions/logstore/test_sql_escape.py" = ["ARG001", "ARG002"]
|
||||
"extensions/otel/decorators/handlers/test_generate_handler.py" = ["ARG001", "ARG002"]
|
||||
"extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py" = ["ARG001"]
|
||||
"extensions/otel/decorators/test_base.py" = ["ARG002"]
|
||||
"extensions/otel/decorators/test_handler.py" = ["ARG002"]
|
||||
"extensions/otel/test_retrieval_tracing.py" = ["ARG001"]
|
||||
"extensions/test_ext_request_logging.py" = ["ARG002"]
|
||||
"extensions/test_redis.py" = ["ARG001"]
|
||||
"factories/test_build_from_mapping.py" = ["ARG001"]
|
||||
"factories/test_file_factory.py" = ["ARG001"]
|
||||
"factories/test_variable_factory.py" = ["TID251"]
|
||||
"fields/test_file_fields.py" = ["ARG005"]
|
||||
"libs/_human_input/support.py" = ["TID251"]
|
||||
"libs/broadcast_channel/redis/test_channel_unit_tests.py" = ["ARG002", "ARG005"]
|
||||
"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"libs/test_cron_compatibility.py" = ["S110"]
|
||||
"libs/test_email_i18n.py" = ["ANN401", "TID251"]
|
||||
"libs/test_oauth_bearer_rate_limit_ordering.py" = ["ARG001"]
|
||||
"libs/test_pyrefly_type_coverage.py" = ["TID251"]
|
||||
"libs/test_schedule_utils_enhanced.py" = ["S110"]
|
||||
"libs/test_sendgrid_client.py" = ["ARG001", "TID251"]
|
||||
"libs/test_smtp_client.py" = ["TID251"]
|
||||
"models/test_dataset_models.py" = ["ARG005"]
|
||||
"models/test_plugin_entities.py" = ["TID251"]
|
||||
"models/test_snippet.py" = ["ARG001"]
|
||||
"oss/__mock/aliyun_oss.py" = ["ARG002"]
|
||||
"oss/__mock/baidu_obs.py" = ["ARG002"]
|
||||
"oss/__mock/base.py" = ["ARG002"]
|
||||
"oss/__mock/tencent_cos.py" = ["ARG002"]
|
||||
"oss/__mock/volcengine_tos.py" = ["ARG002"]
|
||||
"oss/aliyun_oss/aliyun_oss/test_aliyun_oss.py" = ["ARG002"]
|
||||
"oss/baidu_obs/test_baidu_obs.py" = ["ARG002"]
|
||||
"oss/opendal/test_opendal.py" = ["ARG002"]
|
||||
"oss/tencent_cos/test_tencent_cos.py" = ["ARG002"]
|
||||
"oss/volcengine_tos/test_volcengine_tos.py" = ["ARG002"]
|
||||
"services/agent/test_agent_observability_service.py" = ["ARG002", "ARG005"]
|
||||
"services/agent/test_agent_services.py" = ["ARG001", "ARG002", "ARG003", "ARG005"]
|
||||
"services/agent/test_composer_candidates.py" = ["ARG005"]
|
||||
"services/agent/test_prompt_mentions.py" = ["ARG005"]
|
||||
"services/agent/test_skill_tool_inference_service.py" = ["ARG001", "ARG005"]
|
||||
"services/auth/test_jina_auth_standalone_module.py" = ["TID251"]
|
||||
"services/controller_api.py" = ["ARG002"]
|
||||
"services/data_migration/test_import_service.py" = ["ARG002", "ARG005"]
|
||||
"services/dataset_service_test_helpers.py" = ["TID251"]
|
||||
"services/enterprise/test_account_deletion_sync.py" = ["ARG001"]
|
||||
"services/enterprise/test_rbac_service.py" = ["ARG002"]
|
||||
"services/enterprise/test_traceparent_propagation.py" = ["ARG002"]
|
||||
"services/hit_service.py" = ["TID251"]
|
||||
"services/plugin/test_plugin_parameter_service.py" = ["ARG002"]
|
||||
"services/rag_pipeline/pipeline_template/test_built_in_retrieval.py" = ["ARG001"]
|
||||
"services/rag_pipeline/test_rag_pipeline_dsl_service.py" = ["ARG001", "ARG005", "T201", "TID251"]
|
||||
"services/rag_pipeline/test_rag_pipeline_service.py" = ["ARG001", "ARG005"]
|
||||
"services/rag_pipeline/test_rag_pipeline_task_proxy.py" = ["ARG001", "ARG005"]
|
||||
"services/rag_pipeline/test_rag_pipeline_transform_service.py" = ["ARG001"]
|
||||
"services/recommend_app/test_remote_retrieval.py" = ["ARG002"]
|
||||
"services/retention/workflow_run/test_archive_download_preparation.py" = ["ARG002"]
|
||||
"services/retention/workflow_run/test_archive_log_service.py" = ["ARG001", "ARG002"]
|
||||
"services/retention/workflow_run/test_bundle_archive_maintenance.py" = ["TID251"]
|
||||
"services/retention/workflow_run/test_restore_archived_workflow_run.py" = ["ARG002"]
|
||||
"services/test_account_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_annotation_service.py" = ["ANN401", "TID251"]
|
||||
"services/test_api_token_service.py" = ["ARG002"]
|
||||
"services/test_app_generate_service.py" = ["ARG001", "ARG002", "ARG004"]
|
||||
"services/test_app_generate_service_streaming_integration.py" = ["ARG002", "TID251"]
|
||||
"services/test_archive_workflow_run_logs.py" = ["ARG002"]
|
||||
"services/test_audio_service.py" = ["ARG002", "TID251"]
|
||||
"services/test_batch_indexing_base.py" = ["ANN401", "TID251"]
|
||||
"services/test_billing_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_clear_free_plan_expired_workflow_run_logs.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"services/test_clear_free_plan_tenant_expired_logs.py" = ["ARG002", "ARG003"]
|
||||
"services/test_dataset_service_document.py" = ["ARG002"]
|
||||
"services/test_dataset_service_lock_not_owned.py" = ["ARG001", "ARG005"]
|
||||
"services/test_dataset_service_segment.py" = ["ARG002"]
|
||||
"services/test_datasource_provider_service.py" = ["ARG002"]
|
||||
"services/test_external_dataset_service.py" = ["ARG002", "TID251"]
|
||||
"services/test_feature_service_human_input_email_delivery.py" = ["ARG005"]
|
||||
"services/test_feedback_service.py" = ["ARG002"]
|
||||
"services/test_human_input_delivery_test_service.py" = ["ARG005"]
|
||||
"services/test_knowledge_service.py" = ["TID251"]
|
||||
"services/test_message_service.py" = ["ARG002"]
|
||||
"services/test_messages_clean_service.py" = ["TID251"]
|
||||
"services/test_model_load_balancing_service.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"services/test_model_provider_service.py" = ["ANN401", "TID251"]
|
||||
"services/test_model_provider_service_sanitization.py" = ["ARG002", "ARG005"]
|
||||
"services/test_oauth_server_service.py" = ["ARG002"]
|
||||
"services/test_operation_service.py" = ["TID251"]
|
||||
"services/test_rag_pipeline_task_proxy.py" = ["ARG002"]
|
||||
"services/test_recommended_app_service.py" = ["ARG001"]
|
||||
"services/test_schedule_service.py" = ["ANN401", "TID251"]
|
||||
"services/test_snippet_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_summary_index_service.py" = ["ARG001"]
|
||||
"services/test_telemetry_service.py" = ["ARG001", "ARG005"]
|
||||
"services/test_variable_truncator.py" = ["ARG002", "TID251"]
|
||||
"services/test_variable_truncator_additional.py" = ["ANN401", "TID251"]
|
||||
"services/test_vector_service.py" = ["ARG001", "TID251"]
|
||||
"services/test_webhook_service_additional.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"services/test_website_service.py" = ["TID251"]
|
||||
"services/test_workflow_comment_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_workflow_run_service.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"services/test_workflow_service.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"services/tools/test_builtin_tools_manage_service.py" = ["ARG001", "ARG002"]
|
||||
"services/tools/test_tools_manage_service.py" = ["ARG002"]
|
||||
"services/workflow/test_inspector_events.py" = ["ANN401", "TID251"]
|
||||
"services/workflow/test_node_output_inspector_service.py" = ["TID251"]
|
||||
"services/workflow/test_workflow_converter_additional.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"services/workflow/test_workflow_event_snapshot_service.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"services/workflow/test_workflow_event_snapshot_service_additional.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"tasks/test_agent_backend_session_cleanup_task.py" = ["ARG005"]
|
||||
"tasks/test_clean_dataset_task.py" = ["ARG002"]
|
||||
"tasks/test_clean_document_task.py" = ["ARG002"]
|
||||
"tasks/test_dataset_indexing_task.py" = ["ARG001", "ARG002"]
|
||||
"tasks/test_document_indexing_sync_task.py" = ["ARG002"]
|
||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_human_input_timeout_tasks.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"tasks/test_initialize_created_app_rbac_access_task.py" = ["ARG005"]
|
||||
"tasks/test_mail_send_task.py" = ["ARG002"]
|
||||
"tasks/test_ops_trace_task.py" = ["ARG004"]
|
||||
"tasks/test_process_tenant_plugin_autoupgrade_check_task.py" = ["ARG001"]
|
||||
"tasks/test_remove_app_and_related_data_task.py" = ["ARG002"]
|
||||
"tasks/test_trigger_processing_tasks.py" = ["ARG002"]
|
||||
"tasks/test_workflow_execute_task.py" = ["ARG005"]
|
||||
"test_app_factory.py" = ["ARG001"]
|
||||
"test_pytest_dify.py" = ["ARG001"]
|
||||
"tools/test_mcp_tool.py" = ["TID251"]
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."typing.Any"]
|
||||
msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead."
|
||||
@@ -85,6 +85,42 @@ def main_branch_rev(repo: Path) -> str:
|
||||
return git(repo, "rev-parse", "main")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_line", "rule_id"),
|
||||
[
|
||||
(
|
||||
"value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy",
|
||||
"no-new-getattr",
|
||||
),
|
||||
(
|
||||
"session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback",
|
||||
"no-new-controller-sqlalchemy",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_has_reasoned_guard_ignore_accepts_custom_rules(source_line: str, rule_id: str) -> None:
|
||||
module = load_guard_module()
|
||||
|
||||
assert module.has_reasoned_guard_ignore(source_line, rule_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_line", "rule_id"),
|
||||
[
|
||||
("value = getattr(module, name) # noqa: no-new-getattr legacy marker", "no-new-getattr"),
|
||||
("value = getattr(module, name) # guard-ignore: no-new-getattr", "no-new-getattr"),
|
||||
(
|
||||
"value = getattr(module, name) # guard-ignore: another-rule -- wrong rule",
|
||||
"no-new-getattr",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_has_reasoned_guard_ignore_rejects_invalid_markers(source_line: str, rule_id: str) -> None:
|
||||
module = load_guard_module()
|
||||
|
||||
assert not module.has_reasoned_guard_ignore(source_line, rule_id)
|
||||
|
||||
|
||||
def test_resolve_ast_grep_command_prefers_ast_grep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
module = load_guard_module()
|
||||
monkeypatch.setattr(
|
||||
@@ -776,7 +812,7 @@ def test_modified_hunk_with_increased_getattr_count_fails(tmp_path: Path) -> Non
|
||||
assert "net-new getattr" in result.stderr
|
||||
|
||||
|
||||
def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None:
|
||||
def test_inline_guard_ignore_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None:
|
||||
init_repo(tmp_path)
|
||||
write_repo_file(
|
||||
tmp_path,
|
||||
@@ -795,20 +831,20 @@ def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_p
|
||||
"pkg/existing.py",
|
||||
"""
|
||||
def read_value(obj):
|
||||
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr needed for plugin-defined attributes
|
||||
return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr -- plugin-defined attributes
|
||||
""",
|
||||
)
|
||||
commit_all(tmp_path, "add suppressed getattr")
|
||||
|
||||
result = run_script(tmp_path, "--base-rev", base_rev)
|
||||
|
||||
assert "no-new-getattr needed for plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text(
|
||||
assert "guard-ignore: no-new-getattr -- plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert result.returncode == 0, stderr_lines(result)
|
||||
|
||||
|
||||
def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None:
|
||||
def test_inline_guard_ignore_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None:
|
||||
init_repo(tmp_path)
|
||||
write_repo_file(
|
||||
tmp_path,
|
||||
@@ -827,10 +863,10 @@ def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path)
|
||||
"pkg/existing.py",
|
||||
"""
|
||||
def read_value(obj):
|
||||
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr
|
||||
return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr
|
||||
""",
|
||||
)
|
||||
commit_all(tmp_path, "add bare noqa getattr")
|
||||
commit_all(tmp_path, "add bare guard ignore getattr")
|
||||
|
||||
result = run_script(tmp_path, "--base-rev", base_rev)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -15,9 +16,12 @@ import pytest
|
||||
import sqlalchemy as sa
|
||||
from click.testing import CliRunner
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models import Dataset, DatasetPermission, DatasetPermissionEnum
|
||||
from models.account import Tenant
|
||||
from models.base import TypeBase
|
||||
from models.enums import CredentialSourceType
|
||||
from models.provider import ProviderModel
|
||||
from tests.helpers.legacy_model_type_migration import (
|
||||
@@ -59,6 +63,40 @@ def command_module():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rbac_session(sqlite_engine: sa.Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
|
||||
"""Bind RBAC command reads to persisted SQLite dataset rows."""
|
||||
|
||||
TypeBase.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[Dataset.__table__, DatasetPermission.__table__],
|
||||
)
|
||||
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr("commands.rbac.session_factory.create_session", factory)
|
||||
with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def _persist_dataset(
|
||||
session: Session,
|
||||
*,
|
||||
dataset_id: str = "dataset-1",
|
||||
tenant_id: str = "tenant-1",
|
||||
permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME,
|
||||
created_by: str = "creator-account-1",
|
||||
) -> Dataset:
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
tenant_id=tenant_id,
|
||||
name=f"Dataset {dataset_id}",
|
||||
permission=permission,
|
||||
created_by=created_by,
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
return dataset
|
||||
|
||||
|
||||
def _parse_json_lines(output: io.StringIO) -> list[dict[str, object]]:
|
||||
return [json.loads(line) for line in output.getvalue().splitlines() if line.strip()]
|
||||
|
||||
@@ -363,56 +401,35 @@ def test_dataset_permission_rbac_migration_maps_legacy_permissions_to_enum_scope
|
||||
|
||||
def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator(
|
||||
command_module,
|
||||
rbac_session: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
rbac_module = importlib.import_module("commands.rbac")
|
||||
dataset_row = SimpleNamespace(
|
||||
id="dataset-1",
|
||||
tenant_id="tenant-1",
|
||||
permission="only_me",
|
||||
created_by="creator-account-1",
|
||||
)
|
||||
execute_results = [[dataset_row], [], []]
|
||||
_persist_dataset(rbac_session)
|
||||
calls: list[dict[str, object]] = []
|
||||
session_closed = False
|
||||
|
||||
class FakeExecuteResult:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._rows
|
||||
|
||||
class FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
nonlocal session_closed
|
||||
session_closed = True
|
||||
pass
|
||||
|
||||
def execute(self, stmt):
|
||||
return FakeExecuteResult(execute_results.pop(0))
|
||||
|
||||
class FakeSessionFactory:
|
||||
@staticmethod
|
||||
def create_session() -> FakeSession:
|
||||
return FakeSession()
|
||||
read_transaction_ended = False
|
||||
|
||||
def fake_replace_whitelist(**kwargs):
|
||||
assert session_closed is True
|
||||
assert read_transaction_ended is True
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
|
||||
monkeypatch.setattr(rbac_module.RBACService.DatasetAccess, "replace_whitelist", fake_replace_whitelist)
|
||||
def _record_transaction_end(session: Session, transaction: object) -> None:
|
||||
nonlocal read_transaction_ended
|
||||
del transaction
|
||||
if session.get_bind() is rbac_session.get_bind():
|
||||
read_transaction_ended = True
|
||||
|
||||
command_module.migrate_dataset_permissions_to_rbac.callback(
|
||||
tenant_id=None,
|
||||
dataset_id=None,
|
||||
batch_size=500,
|
||||
dry_run=False,
|
||||
)
|
||||
sa.event.listen(Session, "after_transaction_end", _record_transaction_end)
|
||||
monkeypatch.setattr(rbac_module.RBACService.DatasetAccess, "replace_whitelist", fake_replace_whitelist)
|
||||
try:
|
||||
command_module.migrate_dataset_permissions_to_rbac.callback(
|
||||
tenant_id=None,
|
||||
dataset_id=None,
|
||||
batch_size=500,
|
||||
dry_run=False,
|
||||
)
|
||||
finally:
|
||||
sa.event.remove(Session, "after_transaction_end", _record_transaction_end)
|
||||
|
||||
assert calls[0]["tenant_id"] == "tenant-1"
|
||||
assert calls[0]["account_id"] == "creator-account-1"
|
||||
@@ -422,41 +439,19 @@ def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator(
|
||||
|
||||
def test_dataset_permission_rbac_migration_dry_run_outputs_structured_proposed_changes(
|
||||
command_module,
|
||||
rbac_session: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
rbac_module = importlib.import_module("commands.rbac")
|
||||
dataset_row = SimpleNamespace(
|
||||
id="dataset-1",
|
||||
tenant_id="tenant-1",
|
||||
permission="partial_members",
|
||||
created_by="creator-account-1",
|
||||
dataset = _persist_dataset(rbac_session, permission=DatasetPermissionEnum.PARTIAL_TEAM)
|
||||
rbac_session.add(
|
||||
DatasetPermission(
|
||||
dataset_id=dataset.id,
|
||||
account_id="member-account-1",
|
||||
tenant_id=dataset.tenant_id,
|
||||
)
|
||||
)
|
||||
permission_row = SimpleNamespace(dataset_id="dataset-1", account_id="member-account-1")
|
||||
execute_results = [[dataset_row], [permission_row], []]
|
||||
|
||||
class FakeExecuteResult:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._rows
|
||||
|
||||
class FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
pass
|
||||
|
||||
def execute(self, stmt):
|
||||
return FakeExecuteResult(execute_results.pop(0))
|
||||
|
||||
class FakeSessionFactory:
|
||||
@staticmethod
|
||||
def create_session() -> FakeSession:
|
||||
return FakeSession()
|
||||
|
||||
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
|
||||
rbac_session.commit()
|
||||
monkeypatch.setattr(
|
||||
rbac_module.RBACService.DatasetAccess,
|
||||
"replace_whitelist",
|
||||
@@ -1306,50 +1301,36 @@ def test_provider_models_processing_uses_same_plan_locking_and_transaction_entry
|
||||
begin_calls: list[str] = []
|
||||
configure_calls: list[str] = []
|
||||
|
||||
class _FakeBeginContext:
|
||||
def __init__(self, phase: str) -> None:
|
||||
self._phase = phase
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
begin_calls.append(current_phase["name"])
|
||||
|
||||
def __enter__(self) -> None:
|
||||
begin_calls.append(self._phase)
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, phase: str) -> None:
|
||||
self._phase = phase
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def begin(self) -> _FakeBeginContext:
|
||||
return _FakeBeginContext(self._phase)
|
||||
|
||||
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
|
||||
return _FakeSession(current_phase["name"])
|
||||
|
||||
def _fake_build_plan(self, session, candidate, *, lock_rows: bool):
|
||||
def _fake_build_plan(self, session: Session, candidate, *, lock_rows: bool):
|
||||
assert session.get_bind() is sqlite_engine
|
||||
lock_rows_seen.append((current_phase["name"], lock_rows))
|
||||
return SimpleNamespace(group_row_ids=[str(candidate.row.id)], winner=None, loser_rows=[])
|
||||
return migration_module._ProviderModelGroupPlan(
|
||||
group_row_ids=[str(candidate.row.id)],
|
||||
winner=None,
|
||||
loser_rows=[],
|
||||
)
|
||||
|
||||
def _fake_emit_plan(self, plan, *, session, tx_id: str, business_key: dict[str, object]) -> None:
|
||||
return None
|
||||
|
||||
def _fake_configure(self, session) -> None:
|
||||
def _fake_configure(self, session: Session) -> None:
|
||||
assert session.get_bind() is sqlite_engine
|
||||
configure_calls.append(current_phase["name"])
|
||||
|
||||
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
|
||||
monkeypatch.setattr(migration_module.Migration, "_build_provider_model_group_plan", _fake_build_plan)
|
||||
monkeypatch.setattr(migration_module.Migration, "_emit_provider_model_group_plan", _fake_emit_plan)
|
||||
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure)
|
||||
|
||||
dry_migration._process_provider_model_group(candidate, business_key)
|
||||
current_phase["name"] = "apply"
|
||||
apply_migration._process_provider_model_group(candidate, business_key)
|
||||
sa.event.listen(Session, "after_transaction_create", _record_begin)
|
||||
try:
|
||||
dry_migration._process_provider_model_group(candidate, business_key)
|
||||
current_phase["name"] = "apply"
|
||||
apply_migration._process_provider_model_group(candidate, business_key)
|
||||
finally:
|
||||
sa.event.remove(Session, "after_transaction_create", _record_begin)
|
||||
|
||||
assert [phase for phase, _ in lock_rows_seen] == ["dry", "apply"]
|
||||
assert lock_rows_seen[0][1] == lock_rows_seen[1][1]
|
||||
@@ -1392,6 +1373,22 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
sqlite_engine: sa.Engine,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
create_minimal_legacy_model_type_schema(sqlite_engine)
|
||||
created_at = datetime(2025, 1, 1, 12, 0, 0)
|
||||
_insert_load_balancing_model_config(
|
||||
sqlite_engine,
|
||||
row_id="40000000-0000-0000-0000-000000000001",
|
||||
tenant_id="tenant-1",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
model_type="text-generation",
|
||||
name="credential",
|
||||
encrypted_config="{}",
|
||||
credential_id="50000000-0000-0000-0000-000000000001",
|
||||
enabled=True,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
output = io.StringIO()
|
||||
migration = migration_module.Migration(
|
||||
tenant_id="tenant-1",
|
||||
@@ -1401,37 +1398,18 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
model_types=(ModelType.LLM,),
|
||||
orm_models=(migration_module.LoadBalancingModelConfig,),
|
||||
)
|
||||
candidate = migration_module._RowWithRawModelType(
|
||||
row=SimpleNamespace(id="lb-row-1"),
|
||||
raw_model_type="text-generation",
|
||||
canonical_model_type=ModelType.LLM,
|
||||
)
|
||||
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
|
||||
lock_timeout_exc = OperationalError("SELECT 1", {}, SimpleNamespace(pgcode="55P03"))
|
||||
transaction_begins = 0
|
||||
|
||||
class _FakeBeginContext:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def begin(self) -> _FakeBeginContext:
|
||||
return _FakeBeginContext()
|
||||
|
||||
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
|
||||
return _FakeSession()
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
nonlocal transaction_begins
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
transaction_begins += 1
|
||||
|
||||
def _fake_reload(self, session, original_candidate, *, lock_rows: bool):
|
||||
raise lock_timeout_exc
|
||||
|
||||
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
|
||||
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", lambda self, session: None)
|
||||
monkeypatch.setattr(
|
||||
migration_module.Migration,
|
||||
@@ -1439,17 +1417,22 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
_fake_reload,
|
||||
)
|
||||
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
sa.event.listen(Session, "after_transaction_create", _record_begin)
|
||||
try:
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
finally:
|
||||
sa.event.remove(Session, "after_transaction_create", _record_begin)
|
||||
|
||||
lines = _parse_json_lines(output)
|
||||
assert len(lines) == 1
|
||||
assert lines[0]["event"] == "lock_timeout_skipped"
|
||||
attrs = cast(dict[str, object], lines[0]["attrs"])
|
||||
assert attrs["table_name"] == "load_balancing_model_configs"
|
||||
assert attrs["id"] == "lb-row-1"
|
||||
assert attrs["id"] == str(candidate.row.id)
|
||||
assert attrs["error"] == str(lock_timeout_exc)
|
||||
assert isinstance(attrs["stacktrace"], str)
|
||||
assert "OperationalError" in attrs["stacktrace"]
|
||||
assert transaction_begins == 1
|
||||
|
||||
|
||||
def test_process_load_balancing_model_config_row_logs_update_after_sql_execution(
|
||||
@@ -1457,6 +1440,23 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
sqlite_engine: sa.Engine,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
create_minimal_legacy_model_type_schema(sqlite_engine)
|
||||
created_at = datetime(2025, 1, 1, 12, 0, 0)
|
||||
row_id = "40000000-0000-0000-0000-000000000002"
|
||||
_insert_load_balancing_model_config(
|
||||
sqlite_engine,
|
||||
row_id=row_id,
|
||||
tenant_id="tenant-1",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
model_type="text-generation",
|
||||
name="credential",
|
||||
encrypted_config="{}",
|
||||
credential_id="50000000-0000-0000-0000-000000000002",
|
||||
enabled=True,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
migration = migration_module.Migration(
|
||||
tenant_id="tenant-1",
|
||||
engine=sqlite_engine,
|
||||
@@ -1465,42 +1465,33 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
model_types=(ModelType.LLM,),
|
||||
orm_models=(migration_module.LoadBalancingModelConfig,),
|
||||
)
|
||||
candidate = migration_module._RowWithRawModelType(
|
||||
row=SimpleNamespace(id="lb-row-1"),
|
||||
raw_model_type="text-generation",
|
||||
canonical_model_type=ModelType.LLM,
|
||||
)
|
||||
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
|
||||
action_log: list[str] = []
|
||||
|
||||
class _FakeBeginContext:
|
||||
def __enter__(self) -> None:
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
action_log.append("begin")
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def begin(self) -> _FakeBeginContext:
|
||||
return _FakeBeginContext()
|
||||
|
||||
def execute(self, stmt) -> None:
|
||||
def _record_sql(
|
||||
connection: sa.Connection,
|
||||
cursor: object,
|
||||
statement: str,
|
||||
parameters: object,
|
||||
context: object,
|
||||
executemany: bool,
|
||||
) -> None:
|
||||
del connection, cursor, parameters, context, executemany
|
||||
if statement.lstrip().upper().startswith("UPDATE"):
|
||||
action_log.append("sql_execute")
|
||||
|
||||
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
|
||||
return _FakeSession()
|
||||
|
||||
def _fake_configure(self, session) -> None:
|
||||
action_log.append("configure_lock_timeout")
|
||||
|
||||
def _fake_reload(self, session, original_candidate, *, lock_rows: bool):
|
||||
original_reload = migration_module.Migration._reload_load_balancing_model_config_candidate
|
||||
|
||||
def _record_reload(self, session: Session, original_candidate, *, lock_rows: bool):
|
||||
action_log.append(f"reload_candidate:{lock_rows}")
|
||||
return candidate
|
||||
return original_reload(self, session, original_candidate, lock_rows=lock_rows)
|
||||
|
||||
def _fake_log_row_updated(self, *args, **kwargs) -> None:
|
||||
action_log.append("log_row_updated")
|
||||
@@ -1508,12 +1499,11 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
def _fake_cache_cleanup(self, *, row_id: str, tx_id: str) -> None:
|
||||
action_log.append("cache_cleanup")
|
||||
|
||||
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
|
||||
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure)
|
||||
monkeypatch.setattr(
|
||||
migration_module.Migration,
|
||||
"_reload_load_balancing_model_config_candidate",
|
||||
_fake_reload,
|
||||
_record_reload,
|
||||
)
|
||||
monkeypatch.setattr(migration_module.Migration, "_log_row_updated", _fake_log_row_updated)
|
||||
monkeypatch.setattr(
|
||||
@@ -1522,7 +1512,13 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
_fake_cache_cleanup,
|
||||
)
|
||||
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
sa.event.listen(Session, "after_transaction_create", _record_begin)
|
||||
sa.event.listen(sqlite_engine, "before_cursor_execute", _record_sql)
|
||||
try:
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
finally:
|
||||
sa.event.remove(sqlite_engine, "before_cursor_execute", _record_sql)
|
||||
sa.event.remove(Session, "after_transaction_create", _record_begin)
|
||||
|
||||
assert action_log == [
|
||||
"begin",
|
||||
@@ -1532,6 +1528,10 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
"log_row_updated",
|
||||
"cache_cleanup",
|
||||
]
|
||||
with Session(sqlite_engine) as session:
|
||||
persisted = session.get(migration_module.LoadBalancingModelConfig, row_id)
|
||||
assert persisted is not None
|
||||
assert persisted.model_type == ModelType.LLM
|
||||
|
||||
|
||||
def test_load_balancing_model_config_cache_delete_failure_logs_stacktrace(
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
from typing import Literal, cast
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from uuid import uuid4
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets import data_source as module
|
||||
from controllers.console.datasets.data_source import DataSourceApi, DataSourceNotionListApi
|
||||
from models import Account, DataSourceOauthBinding
|
||||
from models.engine import db
|
||||
|
||||
ControllerMethod = Callable[..., tuple[dict[str, object], int]]
|
||||
|
||||
@@ -22,10 +26,15 @@ def unwrap(func: object) -> ControllerMethod:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app() -> Flask:
|
||||
def flask_app() -> Iterator[Flask]:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
DataSourceOauthBinding.__table__.create(db.engine)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -35,9 +44,13 @@ def current_user() -> Account:
|
||||
return account
|
||||
|
||||
|
||||
def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) -> None:
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
BINDING_ID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
|
||||
def _add_binding(session: Session, *, disabled: bool) -> DataSourceOauthBinding:
|
||||
binding = DataSourceOauthBinding(
|
||||
tenant_id="tenant-1",
|
||||
tenant_id=TENANT_ID,
|
||||
access_token="token",
|
||||
provider="notion",
|
||||
source_info={
|
||||
@@ -55,24 +68,31 @@ def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) ->
|
||||
}
|
||||
],
|
||||
},
|
||||
disabled=disabled,
|
||||
)
|
||||
binding.id = "binding-1"
|
||||
binding.id = BINDING_ID
|
||||
binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC)
|
||||
binding.disabled = False
|
||||
session.add(binding)
|
||||
session.commit()
|
||||
return binding
|
||||
|
||||
with (
|
||||
flask_app.test_request_context("/"),
|
||||
patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [binding])),
|
||||
):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1")
|
||||
|
||||
def test_get_data_source_integrates_serializes_orm_binding(
|
||||
flask_app: Flask,
|
||||
) -> None:
|
||||
binding = _add_binding(db.session, disabled=False)
|
||||
expected_created_at = int(binding.created_at.timestamp())
|
||||
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID)
|
||||
|
||||
assert status == 200
|
||||
assert response == {
|
||||
"data": [
|
||||
{
|
||||
"id": "binding-1",
|
||||
"id": BINDING_ID,
|
||||
"provider": "notion",
|
||||
"created_at": 1779670923,
|
||||
"created_at": expected_created_at,
|
||||
"is_bound": True,
|
||||
"disabled": False,
|
||||
"source_info": {
|
||||
@@ -96,34 +116,75 @@ def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) ->
|
||||
}
|
||||
|
||||
|
||||
def test_get_data_source_integrates_preserves_empty_list_when_no_binding(flask_app: Flask) -> None:
|
||||
with (
|
||||
flask_app.test_request_context("/"),
|
||||
patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [])),
|
||||
):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1")
|
||||
def test_get_data_source_integrates_preserves_empty_list_when_no_binding(
|
||||
flask_app: Flask,
|
||||
) -> None:
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID)
|
||||
|
||||
assert status == 200
|
||||
assert response == {"data": []}
|
||||
|
||||
|
||||
def test_patch_data_source_binding_uses_injected_session(flask_app: Flask) -> None:
|
||||
binding = MagicMock(disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
@pytest.mark.parametrize(
|
||||
("disabled", "action", "expected_disabled"),
|
||||
[(True, "enable", False), (False, "disable", True)],
|
||||
)
|
||||
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
|
||||
def test_patch_data_source_binding_updates_state(
|
||||
flask_app: Flask,
|
||||
sqlite_session: Session,
|
||||
disabled: bool,
|
||||
action: Literal["enable", "disable"],
|
||||
expected_disabled: bool,
|
||||
) -> None:
|
||||
_add_binding(sqlite_session, disabled=disabled)
|
||||
sqlite_session.expunge_all()
|
||||
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().patch)(DataSourceApi(), session, "tenant-1", uuid4(), "enable")
|
||||
response, status = unwrap(DataSourceApi().patch)(
|
||||
DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action
|
||||
)
|
||||
|
||||
sqlite_session.flush()
|
||||
sqlite_session.expire_all()
|
||||
binding = sqlite_session.scalar(select(DataSourceOauthBinding).where(DataSourceOauthBinding.id == BINDING_ID))
|
||||
assert status == 200
|
||||
assert response == {"result": "success"}
|
||||
assert binding.disabled is False
|
||||
session.scalar.assert_called_once()
|
||||
session.add.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
assert binding is not None
|
||||
assert binding.disabled is expected_disabled
|
||||
|
||||
|
||||
def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask, current_user: Account) -> None:
|
||||
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
|
||||
def test_patch_data_source_binding_rejects_unknown_binding(
|
||||
flask_app: Flask,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
with flask_app.test_request_context("/"), pytest.raises(NotFound, match="Data source binding not found"):
|
||||
unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), "enable")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("disabled", "action"), [(False, "enable"), (True, "disable")])
|
||||
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
|
||||
def test_patch_data_source_binding_rejects_current_state(
|
||||
flask_app: Flask,
|
||||
sqlite_session: Session,
|
||||
disabled: bool,
|
||||
action: Literal["enable", "disable"],
|
||||
) -> None:
|
||||
_add_binding(sqlite_session, disabled=disabled)
|
||||
sqlite_session.expunge_all()
|
||||
|
||||
with flask_app.test_request_context("/"), pytest.raises(ValueError):
|
||||
unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_notion_pre_import_pages_serializes_frontend_list_shape(
|
||||
flask_app: Flask,
|
||||
current_user: Account,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
page = MagicMock(
|
||||
page_id="page-1",
|
||||
page_name="Page",
|
||||
@@ -145,8 +206,6 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask
|
||||
get_online_document_pages=MagicMock(return_value=iter([online_document_message])),
|
||||
datasource_provider_type=MagicMock(return_value="online_document"),
|
||||
)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
flask_app.test_request_context("/?credential_id=credential-1"),
|
||||
patch.object(
|
||||
@@ -158,7 +217,7 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask
|
||||
patch("core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", return_value=runtime),
|
||||
):
|
||||
response, status = unwrap(DataSourceNotionListApi().get)(
|
||||
DataSourceNotionListApi(), session, "tenant-1", current_user
|
||||
DataSourceNotionListApi(), sqlite_session, "tenant-1", current_user
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
@@ -183,3 +242,38 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask
|
||||
}
|
||||
runtime.get_online_document_pages.assert_called_once()
|
||||
assert runtime.get_online_document_pages.call_args.kwargs["datasource_parameters"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_notion_pre_import_pages_rejects_missing_credential(
|
||||
flask_app: Flask,
|
||||
current_user: Account,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
with (
|
||||
flask_app.test_request_context("/?credential_id=credential-1"),
|
||||
patch.object(module.DatasourceProviderService, "get_datasource_credentials", return_value=None),
|
||||
pytest.raises(NotFound, match="Credential not found"),
|
||||
):
|
||||
unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_notion_pre_import_pages_rejects_non_notion_dataset(
|
||||
flask_app: Flask,
|
||||
current_user: Account,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
dataset = MagicMock(data_source_type="other_type")
|
||||
|
||||
with (
|
||||
flask_app.test_request_context("/?credential_id=credential-1&dataset_id=dataset-1"),
|
||||
patch.object(
|
||||
module.DatasourceProviderService,
|
||||
"get_datasource_credentials",
|
||||
return_value={"token": "token"},
|
||||
),
|
||||
patch.object(module.DatasetService, "get_dataset", return_value=dataset),
|
||||
pytest.raises(ValueError, match="Dataset is not notion type"),
|
||||
):
|
||||
unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Unit tests for controllers.console.datasets.data_source Notion endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets.data_source import (
|
||||
DataSourceNotionDatasetSyncApi,
|
||||
DataSourceNotionDocumentSyncApi,
|
||||
DataSourceNotionIndexingEstimateApi,
|
||||
DataSourceNotionPreviewApi,
|
||||
)
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from models import Account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
account = Account(name="Test User", email="u1@example.com")
|
||||
account.id = "u1"
|
||||
return account
|
||||
|
||||
|
||||
class TestDataSourceNotionPreviewApi:
|
||||
def test_get_preview_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionPreviewApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")])
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"integration_secret": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.NotionExtractor",
|
||||
return_value=extractor,
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1", "p1", "page")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionIndexingEstimateApi:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_post_indexing_estimate_success(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionIndexingEstimateApi()
|
||||
method = inspect.unwrap(api.post)
|
||||
|
||||
empty_rules: dict[str, object] = {}
|
||||
payload: dict[str, object] = {
|
||||
"notion_info_list": [
|
||||
{
|
||||
"workspace_id": "w1",
|
||||
"credential_id": "c1",
|
||||
"pages": [{"page_id": "p1", "type": "page"}],
|
||||
}
|
||||
],
|
||||
"process_rule": {"rules": empty_rules},
|
||||
"doc_form": IndexStructureType.PARAGRAPH_INDEX,
|
||||
"doc_language": "English",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.estimate_args_validate",
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.IndexingRunner.indexing_estimate",
|
||||
return_value=MagicMock(model_dump=lambda: {"total_pages": 1}),
|
||||
),
|
||||
):
|
||||
response, status = method(api, sqlite_session, "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionDatasetSyncApi:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_success(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id",
|
||||
return_value=[MagicMock(id="d1")],
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, sqlite_session, "ds-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_dataset_not_found(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, sqlite_session, "ds-1")
|
||||
|
||||
|
||||
class TestDataSourceNotionDocumentSyncApi:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_success(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, sqlite_session, "ds-1", "doc-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_document_not_found(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, sqlite_session, "ds-1", "doc-1")
|
||||
@@ -15,12 +15,15 @@ Focus on:
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from controllers.service_api.app.error import NotChatAppError
|
||||
@@ -44,6 +47,14 @@ from services.errors.message import (
|
||||
from services.message_service import MessageService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orm_session(sqlite_engine: Engine) -> Iterator[Session]:
|
||||
"""Provide a real caller-owned session for MessageService interface tests."""
|
||||
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
|
||||
class TestMessageListQuery:
|
||||
"""Test suite for MessageListQuery Pydantic model."""
|
||||
|
||||
@@ -253,7 +264,7 @@ class TestMessageService:
|
||||
assert callable(MessageService.get_suggested_questions_after_answer)
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination):
|
||||
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination, orm_session: Session):
|
||||
"""Test pagination_by_first_id returns expected format."""
|
||||
mock_result = Mock()
|
||||
mock_result.data = []
|
||||
@@ -267,7 +278,7 @@ class TestMessageService:
|
||||
conversation_id=str(uuid.uuid4()),
|
||||
first_id=None,
|
||||
limit=20,
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
assert hasattr(result, "data")
|
||||
@@ -275,7 +286,7 @@ class TestMessageService:
|
||||
assert hasattr(result, "has_more")
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_raises_conversation_not_exists_error(self, mock_pagination):
|
||||
def test_pagination_raises_conversation_not_exists_error(self, mock_pagination, orm_session: Session):
|
||||
"""Test pagination raises ConversationNotExistsError."""
|
||||
import services.errors.conversation
|
||||
|
||||
@@ -288,11 +299,11 @@ class TestMessageService:
|
||||
conversation_id="invalid_id",
|
||||
first_id=None,
|
||||
limit=20,
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination):
|
||||
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination, orm_session: Session):
|
||||
"""Test pagination raises FirstMessageNotExistsError."""
|
||||
mock_pagination.side_effect = FirstMessageNotExistsError()
|
||||
|
||||
@@ -303,11 +314,11 @@ class TestMessageService:
|
||||
conversation_id=str(uuid.uuid4()),
|
||||
first_id="invalid_first_id",
|
||||
limit=20,
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "create_feedback")
|
||||
def test_create_feedback_with_rating_and_content(self, mock_create_feedback):
|
||||
def test_create_feedback_with_rating_and_content(self, mock_create_feedback, orm_session: Session):
|
||||
"""Test create_feedback with rating and content."""
|
||||
mock_create_feedback.return_value = None
|
||||
|
||||
@@ -317,13 +328,13 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
rating=FeedbackRating.LIKE,
|
||||
content="Great response!",
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
mock_create_feedback.assert_called_once()
|
||||
|
||||
@patch.object(MessageService, "create_feedback")
|
||||
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback):
|
||||
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback, orm_session: Session):
|
||||
"""Test create_feedback raises MessageNotExistsError."""
|
||||
mock_create_feedback.side_effect = MessageNotExistsError()
|
||||
|
||||
@@ -334,11 +345,11 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
rating=FeedbackRating.LIKE,
|
||||
content=None,
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "get_all_messages_feedbacks")
|
||||
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks):
|
||||
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks, orm_session: Session):
|
||||
"""Test get_all_messages_feedbacks returns list of feedbacks."""
|
||||
mock_feedbacks = [
|
||||
{"message_id": str(uuid.uuid4()), "rating": "like"},
|
||||
@@ -346,13 +357,15 @@ class TestMessageService:
|
||||
]
|
||||
mock_get_feedbacks.return_value = mock_feedbacks
|
||||
|
||||
result = MessageService.get_all_messages_feedbacks(app_model=Mock(spec=App), page=1, limit=20, session=Mock())
|
||||
result = MessageService.get_all_messages_feedbacks(
|
||||
app_model=Mock(spec=App), page=1, limit=20, session=orm_session
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["rating"] == "like"
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions):
|
||||
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions, orm_session: Session):
|
||||
"""Test get_suggested_questions_after_answer returns list of questions."""
|
||||
mock_questions = ["What about this aspect?", "Can you elaborate on that?", "How does this relate to...?"]
|
||||
mock_get_questions.return_value = mock_questions
|
||||
@@ -362,14 +375,14 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
assert len(result) == 3
|
||||
assert isinstance(result[0], str)
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions):
|
||||
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions, orm_session: Session):
|
||||
"""Test get_suggested_questions_after_answer raises SuggestedQuestionsAfterAnswerDisabledError."""
|
||||
mock_get_questions.side_effect = SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
@@ -379,11 +392,11 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions):
|
||||
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions, orm_session: Session):
|
||||
"""Test get_suggested_questions_after_answer raises MessageNotExistsError."""
|
||||
mock_get_questions.side_effect = MessageNotExistsError()
|
||||
|
||||
@@ -393,7 +406,7 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id="invalid_message_id",
|
||||
invoke_from=Mock(),
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+34
-16
@@ -24,6 +24,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
@@ -38,6 +39,7 @@ from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import (
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.account import Account
|
||||
from models.dataset import Dataset
|
||||
from services.errors.file import FileTooLargeError, UnsupportedFileTypeError
|
||||
from services.rag_pipeline.entity.pipeline_service_api_entities import (
|
||||
DatasourceNodeRunApiEntity,
|
||||
@@ -46,6 +48,20 @@ from services.rag_pipeline.entity.pipeline_service_api_entities import (
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
|
||||
def _persist_dataset(session: Session, *, tenant_id: str, dataset_id: str) -> Dataset:
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Pipeline dataset",
|
||||
created_by="account-1",
|
||||
data_source_type=None,
|
||||
indexing_technique=None,
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
return dataset
|
||||
|
||||
|
||||
class TestDatasourceNodeRunPayload:
|
||||
"""Test suite for DatasourceNodeRunPayload Pydantic model."""
|
||||
|
||||
@@ -550,13 +566,15 @@ class TestPipelineRunApiPost:
|
||||
)
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService")
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns")
|
||||
def test_post_success_streaming(self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app):
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_success_streaming(
|
||||
self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app, sqlite_session: Session
|
||||
):
|
||||
"""Test successful pipeline run with streaming response."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
|
||||
session = Mock()
|
||||
session.scalar.return_value = Mock()
|
||||
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
|
||||
mock_ns.payload = {
|
||||
"inputs": {"key": "val"},
|
||||
@@ -577,33 +595,33 @@ class TestPipelineRunApiPost:
|
||||
|
||||
with app.test_request_context("/datasets/test/pipeline/run", method="POST"):
|
||||
api = PipelineRunApi()
|
||||
response = api.post.__wrapped__(api, session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
response = api.post.__wrapped__(api, sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
|
||||
assert response == {"result": "ok"}
|
||||
mock_svc_cls.assert_called_once_with(session)
|
||||
mock_svc_cls.assert_called_once_with(sqlite_session)
|
||||
mock_gen_svc.generate.assert_called_once()
|
||||
|
||||
def test_post_not_found(self, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_not_found(self, app: Flask, sqlite_session: Session):
|
||||
"""Test NotFound when dataset check fails."""
|
||||
session = Mock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with app.test_request_context("/datasets/test/pipeline/run", method="POST"):
|
||||
api = PipelineRunApi()
|
||||
with pytest.raises(NotFound):
|
||||
api.post.__wrapped__(
|
||||
api,
|
||||
session,
|
||||
sqlite_session,
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
dataset_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user", new="not_account")
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns")
|
||||
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask, sqlite_session: Session):
|
||||
"""Test Forbidden when current_user is not an Account."""
|
||||
session = Mock()
|
||||
session.scalar.return_value = Mock()
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
mock_ns.payload = {
|
||||
"inputs": {},
|
||||
"datasource_type": "online_document",
|
||||
@@ -618,9 +636,9 @@ class TestPipelineRunApiPost:
|
||||
with pytest.raises(Forbidden):
|
||||
api.post.__wrapped__(
|
||||
api,
|
||||
session,
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
dataset_id=str(uuid.uuid4()),
|
||||
sqlite_session,
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ Unit tests for Service API wraps (authentication decorators)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
|
||||
|
||||
from controllers.service_api.wraps import (
|
||||
@@ -21,12 +24,11 @@ from controllers.service_api.wraps import (
|
||||
validate_dataset_token,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from models.account import TenantStatus
|
||||
from models.model import ApiToken
|
||||
from tests.unit_tests.conftest import (
|
||||
setup_mock_dataset_owner_execute_result,
|
||||
setup_mock_tenant_owner_execute_result,
|
||||
)
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
from models.account import TenantAccountRole
|
||||
from models.dataset import Dataset, RateLimitLog
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, AppMode, IconType
|
||||
|
||||
|
||||
def _configure_current_app_mock(mock_current_app):
|
||||
@@ -34,6 +36,51 @@ def _configure_current_app_mock(mock_current_app):
|
||||
mock_current_app._get_current_object = Mock(return_value=Mock())
|
||||
|
||||
|
||||
def _session_proxy(session: Session) -> MagicMock:
|
||||
"""Emulate Flask-SQLAlchemy's callable scoped-session proxy around a test session."""
|
||||
proxy = MagicMock(wraps=session)
|
||||
proxy.return_value = session
|
||||
return proxy
|
||||
|
||||
|
||||
def _api_token(*, tenant_id: str, app_id: str | None = None, token_type: ApiTokenType) -> ApiToken:
|
||||
return ApiToken(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
type=token_type,
|
||||
token="test_token",
|
||||
)
|
||||
|
||||
|
||||
def _persist_workspace(session: Session) -> tuple[Tenant, Account, TenantAccountJoin]:
|
||||
tenant = Tenant(name="Workspace")
|
||||
account = Account(name="Owner", email=f"owner-{uuid.uuid4()}@example.com")
|
||||
membership = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
current=True,
|
||||
role=TenantAccountRole.OWNER,
|
||||
)
|
||||
session.add_all([tenant, account, membership])
|
||||
session.commit()
|
||||
return tenant, account, membership
|
||||
|
||||
|
||||
def _app_model(*, tenant_id: str, enable_api: bool = True) -> App:
|
||||
return App(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
name="Service API App",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="chat",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=enable_api,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateAndGetApiToken:
|
||||
"""Test suite for validate_and_get_api_token function"""
|
||||
|
||||
@@ -70,21 +117,24 @@ class TestValidateAndGetApiToken:
|
||||
def test_valid_token_returns_api_token(self, mock_fetch_token, mock_cache_cls, mock_record_usage, app: Flask):
|
||||
"""Test that valid token returns the ApiToken object."""
|
||||
# Arrange
|
||||
mock_api_token = Mock(spec=ApiToken)
|
||||
mock_api_token.token = "valid_token_123"
|
||||
mock_api_token.type = "app"
|
||||
api_token = _api_token(
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
app_id=str(uuid.uuid4()),
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
api_token.token = "valid_token_123"
|
||||
|
||||
mock_cache_instance = Mock()
|
||||
mock_cache_instance.get.return_value = None # Cache miss
|
||||
mock_cache_cls.get = mock_cache_instance.get
|
||||
mock_fetch_token.return_value = mock_api_token
|
||||
mock_fetch_token.return_value = api_token
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer valid_token_123"}):
|
||||
result = validate_and_get_api_token("app")
|
||||
|
||||
# Assert
|
||||
assert result == mock_api_token
|
||||
assert result == api_token
|
||||
|
||||
@patch("controllers.service_api.wraps.record_token_usage")
|
||||
@patch("controllers.service_api.wraps.ApiTokenCache")
|
||||
@@ -117,116 +167,124 @@ class TestValidateAppToken:
|
||||
return app
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(App, ApiToken, Tenant, Account, TenantAccountJoin)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_valid_app_token_allows_access(
|
||||
self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app
|
||||
self,
|
||||
mock_current_app,
|
||||
mock_validate_token,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test that valid app token allows access to decorated view."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_api_token.tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.id = mock_api_token.app_id
|
||||
mock_app.status = "normal"
|
||||
mock_app.enable_api = True
|
||||
mock_app.tenant_id = mock_api_token.tenant_id
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
mock_tenant.id = mock_api_token.tenant_id
|
||||
|
||||
mock_account = Mock()
|
||||
mock_account.id = str(uuid.uuid4())
|
||||
|
||||
# Use side_effect to return app first, then tenant via session.get()
|
||||
mock_db.session.get.side_effect = [mock_app, mock_tenant]
|
||||
|
||||
# Mock the tenant owner execute result (execute(select(...)).one_or_none())
|
||||
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account)
|
||||
tenant, account, _ = _persist_workspace(sqlite_session)
|
||||
app_model = _app_model(tenant_id=tenant.id)
|
||||
api_token = _api_token(tenant_id=tenant.id, app_id=app_model.id, token_type=ApiTokenType.APP)
|
||||
sqlite_session.add_all([app_model, api_token])
|
||||
sqlite_session.commit()
|
||||
mock_validate_token.return_value = api_token
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(app_model):
|
||||
return {"success": True, "app_id": app_model.id}
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
with (
|
||||
app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
result = protected_view()
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
assert result["app_id"] == mock_app.id
|
||||
assert result["app_id"] == app_model.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_app_not_found_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_not_found_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
"""Test that Forbidden is raised when app no longer exists."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_db.session.get.return_value = None
|
||||
api_token = _api_token(
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
app_id=str(uuid.uuid4()),
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
mock_validate_token.return_value = api_token
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "no longer exists" in str(exc_info.value)
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
"""Test that Forbidden is raised when app status is abnormal."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.status = "abnormal"
|
||||
mock_db.session.get.return_value = mock_app
|
||||
app_model = _app_model(tenant_id=str(uuid.uuid4()))
|
||||
sqlite_session.add(app_model)
|
||||
sqlite_session.commit()
|
||||
app_model.status = "abnormal"
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "status is abnormal" in str(exc_info.value)
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
"""Test that Forbidden is raised when app API is disabled."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.status = "normal"
|
||||
mock_app.enable_api = False
|
||||
mock_db.session.get.return_value = mock_app
|
||||
app_model = _app_model(tenant_id=str(uuid.uuid4()), enable_api=False)
|
||||
sqlite_session.add(app_model)
|
||||
sqlite_session.commit()
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "API service has been disabled" in str(exc_info.value)
|
||||
@@ -468,26 +526,35 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.sessionmaker")
|
||||
@pytest.mark.parametrize("sqlite_session", [(RateLimitLog,)], indirect=True)
|
||||
def test_rejects_over_rate_limit(
|
||||
self, mock_sessionmaker, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask
|
||||
self,
|
||||
mock_get_rate_limit,
|
||||
mock_validate_token,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test that Forbidden is raised when over rate limit."""
|
||||
# Arrange
|
||||
mock_validate_token.return_value = Mock(tenant_id="tenant123")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=tenant_id,
|
||||
token_type=ApiTokenType.DATASET,
|
||||
)
|
||||
|
||||
mock_rate_limit = Mock()
|
||||
mock_rate_limit.enabled = True
|
||||
mock_rate_limit.limit = 10
|
||||
mock_rate_limit.subscription_plan = "pro"
|
||||
mock_get_rate_limit.return_value = mock_rate_limit
|
||||
rate_limit_log_session = MagicMock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.begin.return_value.__enter__.return_value = rate_limit_log_session
|
||||
mock_sessionmaker.return_value = session_factory
|
||||
|
||||
with patch("controllers.service_api.wraps.redis_client") as mock_redis:
|
||||
with (
|
||||
patch("controllers.service_api.wraps.redis_client") as mock_redis,
|
||||
patch(
|
||||
"controllers.service_api.wraps.db",
|
||||
SimpleNamespace(engine=sqlite_session.get_bind()),
|
||||
),
|
||||
):
|
||||
mock_redis.zcard.return_value = 15 # Over limit
|
||||
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
@@ -499,9 +566,12 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
knowledge_request()
|
||||
assert "rate limit" in str(exc_info.value)
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
|
||||
rate_limit_log_session.add.assert_called_once()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
persisted_logs = sqlite_session.scalars(select(RateLimitLog)).all()
|
||||
assert len(persisted_logs) == 1
|
||||
assert persisted_logs[0].tenant_id == tenant_id
|
||||
assert persisted_logs[0].subscription_plan == "pro"
|
||||
assert persisted_logs[0].operation == "knowledge"
|
||||
|
||||
|
||||
class TestValidateDatasetToken:
|
||||
@@ -515,65 +585,62 @@ class TestValidateDatasetToken:
|
||||
return app
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
def test_valid_dataset_token(self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app: Flask):
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Tenant, Account, TenantAccountJoin)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_valid_dataset_token(
|
||||
self,
|
||||
mock_current_app,
|
||||
mock_validate_token,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test that valid dataset token allows access."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = tenant_id
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.id = tenant_id
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
|
||||
mock_ta = Mock()
|
||||
mock_ta.account_id = str(uuid.uuid4())
|
||||
|
||||
mock_account = Mock()
|
||||
mock_account.id = mock_ta.account_id
|
||||
mock_account.current_tenant = mock_tenant
|
||||
|
||||
# Mock the tenant account join query (execute(select(...)).one_or_none())
|
||||
setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta)
|
||||
|
||||
# Mock the account lookup via session.get()
|
||||
mock_db.session.get.return_value = mock_account
|
||||
tenant, account, _ = _persist_workspace(sqlite_session)
|
||||
api_token = _api_token(tenant_id=tenant.id, token_type=ApiTokenType.DATASET)
|
||||
mock_validate_token.return_value = api_token
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(tenant_id):
|
||||
return {"success": True, "tenant_id": tenant_id}
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
with (
|
||||
app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
result = protected_view()
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
assert result["tenant_id"] == tenant_id
|
||||
assert result["tenant_id"] == tenant.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_dataset_not_found_raises_not_found(self, mock_validate_token, mock_db, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_dataset_not_found_raises_not_found(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
"""Test that NotFound is raised when dataset doesn't exist."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_db.session.scalar.return_value = None
|
||||
api_token = _api_token(tenant_id=str(uuid.uuid4()), token_type=ApiTokenType.DATASET)
|
||||
mock_validate_token.return_value = api_token
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(dataset_id=None, **kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with pytest.raises(NotFound) as exc_info:
|
||||
protected_view(dataset_id=str(uuid.uuid4()))
|
||||
assert "Dataset not found" in str(exc_info.value)
|
||||
|
||||
@@ -6,13 +6,19 @@ from unittest.mock import ANY, MagicMock, patch
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from jwt import InvalidTokenError
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
import services.errors.account
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from models.model import DifySetup
|
||||
from services.entities.auth_entities import LoginFailureReason
|
||||
|
||||
pytestmark = pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
|
||||
|
||||
def encode_code(code: str) -> str:
|
||||
return base64.b64encode(code.encode("utf-8")).decode()
|
||||
@@ -33,17 +39,27 @@ def app():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_wraps():
|
||||
def _patch_wraps(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
wraps_features = SimpleNamespace(enable_email_password_login=True)
|
||||
console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
||||
web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True)
|
||||
sqlite_session.add(DifySetup(version="test"))
|
||||
sqlite_session.commit()
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
session_registry = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False))
|
||||
monkeypatch.setattr(console_wraps.db, "session", session_registry)
|
||||
with (
|
||||
patch("controllers.console.wraps.db") as mock_db,
|
||||
patch("controllers.console.wraps.dify_config", console_dify),
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
|
||||
patch("controllers.web.login.dify_config", web_dify),
|
||||
):
|
||||
yield
|
||||
session_registry.remove()
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
|
||||
|
||||
class TestEmailCodeLoginSendEmailApi:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
|
||||
from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline
|
||||
@@ -54,6 +54,7 @@ from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import AppMode, EndUser
|
||||
from models.workflow import WorkflowAppLog
|
||||
from tests.workflow_test_utils import build_test_variable_pool
|
||||
|
||||
|
||||
@@ -193,7 +194,7 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert isinstance(responses[0], ValueError)
|
||||
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._graph_runtime_state = GraphRuntimeState(
|
||||
variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")),
|
||||
@@ -201,11 +202,10 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
)
|
||||
pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started"
|
||||
|
||||
@contextmanager
|
||||
def _fake_session():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.generate_task_pipeline.db",
|
||||
SimpleNamespace(engine=sqlite_engine),
|
||||
)
|
||||
monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None)
|
||||
|
||||
responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))
|
||||
@@ -339,19 +339,18 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert responses == ["finish"]
|
||||
|
||||
def test_save_workflow_app_log_created_from(self):
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_created_from(self, sqlite_session: Session):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
pipeline._user_id = "user"
|
||||
added: list[object] = []
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
|
||||
sqlite_session.flush()
|
||||
|
||||
class _Session:
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
|
||||
assert added
|
||||
saved_log = sqlite_session.scalar(select(WorkflowAppLog))
|
||||
assert saved_log is not None
|
||||
assert saved_log.workflow_run_id == "run-id"
|
||||
assert saved_log.created_from == "service-api"
|
||||
|
||||
def test_iteration_loop_and_human_input_handlers(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -674,35 +673,29 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
assert "Fails to get audio trunk, task_id: task" in caplog.messages
|
||||
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
|
||||
|
||||
def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_database_session_rolls_back_on_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch, sqlite_engine, sqlite_session: Session
|
||||
):
|
||||
pipeline = _make_pipeline()
|
||||
calls = {"enter": 0, "exit_exc": None}
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
pipeline._user_id = "user"
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.generate_task_pipeline.db",
|
||||
SimpleNamespace(engine=sqlite_engine),
|
||||
)
|
||||
|
||||
class _BeginContext:
|
||||
def __enter__(self):
|
||||
calls["enter"] += 1
|
||||
return MagicMock()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
calls["exit_exc"] = exc_type
|
||||
return False
|
||||
|
||||
class _Sessionmaker:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def begin(self):
|
||||
return _BeginContext()
|
||||
|
||||
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.sessionmaker", _Sessionmaker)
|
||||
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object()))
|
||||
|
||||
with pytest.raises(RuntimeError, match="db error"):
|
||||
with pipeline._database_session():
|
||||
def persist_then_fail() -> None:
|
||||
with pipeline._database_session() as session:
|
||||
pipeline._save_workflow_app_log(session=session, workflow_run_id="run-id")
|
||||
session.flush()
|
||||
raise RuntimeError("db error")
|
||||
|
||||
assert calls["enter"] == 1
|
||||
assert calls["exit_exc"] is RuntimeError
|
||||
with pytest.raises(RuntimeError, match="db error"):
|
||||
persist_then_fail()
|
||||
|
||||
sqlite_session.expire_all()
|
||||
assert sqlite_session.scalar(select(WorkflowAppLog)) is None
|
||||
|
||||
def test_node_retry_and_started_handlers_cover_none_and_value(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -862,31 +855,30 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["stopped"])
|
||||
assert list(pipeline._process_stream_response()) == ["stopped"]
|
||||
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self):
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self, sqlite_session: Session):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._user_id = "user-id"
|
||||
added: list[object] = []
|
||||
|
||||
class _Session:
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.EXPLORE
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "installed-app"
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "web-app"
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-2")
|
||||
sqlite_session.flush()
|
||||
saved_logs = sqlite_session.scalars(select(WorkflowAppLog).order_by(WorkflowAppLog.workflow_run_id)).all()
|
||||
assert [log.created_from for log in saved_logs] == ["installed-app", "web-app"]
|
||||
|
||||
count_before = len(added)
|
||||
count_before = len(saved_logs)
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert len(added) == count_before
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-3")
|
||||
sqlite_session.flush()
|
||||
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id=None)
|
||||
assert len(added) == count_before
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id=None)
|
||||
sqlite_session.flush()
|
||||
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
|
||||
|
||||
def test_save_output_for_event_writes_draft_variables(self):
|
||||
pipeline = _make_pipeline()
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.layers.trigger_post_layer import TriggerPostLayer
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
@@ -13,19 +17,63 @@ from graphon.graph_events import (
|
||||
GraphRunSucceededEvent,
|
||||
)
|
||||
from graphon.runtime import VariablePool
|
||||
from models.enums import WorkflowTriggerStatus
|
||||
from models.enums import AppTriggerType, CreatorUserRole, WorkflowTriggerStatus
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TriggerDatabase:
|
||||
session: Session
|
||||
statements: list[str]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def trigger_database(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[TriggerDatabase]:
|
||||
"""Create the trigger-log table and bind layer-owned sessions to SQLite."""
|
||||
WorkflowTriggerLog.metadata.create_all(sqlite_engine, tables=[WorkflowTriggerLog.__table__])
|
||||
sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker)
|
||||
statements: list[str] = []
|
||||
|
||||
def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
with sqlite_session_maker() as session:
|
||||
try:
|
||||
yield TriggerDatabase(session=session, statements=statements)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
|
||||
def _persist_trigger_log(database: TriggerDatabase, *, trigger_log_id: str = "log-1") -> WorkflowTriggerLog:
|
||||
trigger_log = WorkflowTriggerLog(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id=None,
|
||||
root_node_id=None,
|
||||
trigger_metadata="{}",
|
||||
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
|
||||
trigger_data="{}",
|
||||
inputs="{}",
|
||||
outputs=None,
|
||||
status=WorkflowTriggerStatus.RUNNING,
|
||||
error=None,
|
||||
queue_name="workflow",
|
||||
celery_task_id=None,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
)
|
||||
trigger_log.id = trigger_log_id
|
||||
database.session.add(trigger_log)
|
||||
database.session.commit()
|
||||
return trigger_log
|
||||
|
||||
|
||||
class TestTriggerPostLayer:
|
||||
def test_on_event_updates_trigger_log(self):
|
||||
trigger_log = SimpleNamespace(
|
||||
status=None,
|
||||
workflow_run_id=None,
|
||||
outputs=None,
|
||||
elapsed_time=None,
|
||||
total_tokens=None,
|
||||
finished_at=None,
|
||||
)
|
||||
def test_on_event_updates_trigger_log(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"answer": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -35,19 +83,10 @@ class TestTriggerPostLayer:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
|
||||
):
|
||||
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
|
||||
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = trigger_log
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
|
||||
@@ -57,25 +96,18 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent())
|
||||
|
||||
assert trigger_log.status == WorkflowTriggerStatus.SUCCEEDED
|
||||
assert trigger_log.workflow_run_id == "run-1"
|
||||
assert trigger_log.outputs is not None
|
||||
assert trigger_log.elapsed_time is not None
|
||||
assert trigger_log.total_tokens == 12
|
||||
assert trigger_log.finished_at is not None
|
||||
repo.update.assert_called_once_with(trigger_log)
|
||||
session.commit.assert_called_once()
|
||||
trigger_database.session.expire_all()
|
||||
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
|
||||
assert persisted_log is not None
|
||||
assert persisted_log.status == WorkflowTriggerStatus.SUCCEEDED
|
||||
assert persisted_log.workflow_run_id == "run-1"
|
||||
assert persisted_log.outputs == '{"answer":"ok"}'
|
||||
assert persisted_log.elapsed_time == 10
|
||||
assert persisted_log.total_tokens == 12
|
||||
assert persisted_log.finished_at is not None
|
||||
|
||||
def test_on_event_updates_trigger_log_for_aborted_event(self):
|
||||
trigger_log = SimpleNamespace(
|
||||
status=None,
|
||||
workflow_run_id=None,
|
||||
outputs=None,
|
||||
error=None,
|
||||
elapsed_time=None,
|
||||
total_tokens=None,
|
||||
finished_at=None,
|
||||
)
|
||||
def test_on_event_updates_trigger_log_for_aborted_event(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"partial": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -85,19 +117,10 @@ class TestTriggerPostLayer:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
|
||||
):
|
||||
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
|
||||
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = trigger_log
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
|
||||
@@ -107,17 +130,22 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunAbortedEvent(reason="timeout"))
|
||||
|
||||
assert trigger_log.status == WorkflowTriggerStatus.FAILED
|
||||
assert trigger_log.workflow_run_id == "run-1"
|
||||
assert trigger_log.outputs is not None
|
||||
assert trigger_log.error == "timeout"
|
||||
assert trigger_log.elapsed_time is not None
|
||||
assert trigger_log.total_tokens == 7
|
||||
assert trigger_log.finished_at is not None
|
||||
repo.update.assert_called_once_with(trigger_log)
|
||||
session.commit.assert_called_once()
|
||||
trigger_database.session.expire_all()
|
||||
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
|
||||
assert persisted_log is not None
|
||||
assert persisted_log.status == WorkflowTriggerStatus.FAILED
|
||||
assert persisted_log.workflow_run_id == "run-1"
|
||||
assert persisted_log.outputs == '{"partial":"ok"}'
|
||||
assert persisted_log.error == "timeout"
|
||||
assert persisted_log.elapsed_time == 10
|
||||
assert persisted_log.total_tokens == 7
|
||||
assert persisted_log.finished_at is not None
|
||||
|
||||
def test_on_event_handles_missing_trigger_log(self, caplog: pytest.LogCaptureFixture):
|
||||
def test_on_event_handles_missing_trigger_log(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
trigger_database: TriggerDatabase,
|
||||
):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -126,31 +154,20 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
):
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="missing",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = None
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="missing",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
|
||||
layer.on_event(GraphRunFailedEvent(error="boom"))
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
|
||||
layer.on_event(GraphRunFailedEvent(error="boom"))
|
||||
|
||||
assert any(record.levelno == logging.ERROR for record in caplog.records)
|
||||
session.commit.assert_not_called()
|
||||
assert trigger_database.session.get(WorkflowTriggerLog, "missing") is None
|
||||
|
||||
def test_on_event_ignores_non_status_events(self):
|
||||
def test_on_event_ignores_non_status_events(self, trigger_database: TriggerDatabase):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -159,14 +176,14 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
with patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory:
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="log-1",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="log-1",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
layer.on_event(Mock())
|
||||
trigger_database.statements.clear()
|
||||
layer.on_event(Mock())
|
||||
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
assert trigger_database.statements == []
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import types
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from contexts.wrapper import RecyclableContextVar
|
||||
from core.datasource import datasource_manager as datasource_manager_module
|
||||
from core.datasource.datasource_manager import DatasourceManager
|
||||
from core.datasource.entities.datasource_entities import DatasourceMessage, DatasourceProviderType
|
||||
from core.datasource.errors import DatasourceProviderNotFoundError
|
||||
@@ -12,6 +15,34 @@ from core.workflow.file_reference import parse_file_reference
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.node_events import StreamChunkEvent, StreamCompletedEvent
|
||||
from models.base import TypeBase
|
||||
from models.tools import ToolFile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_file_session(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
|
||||
"""Bind datasource-owned lookups to a SQLite ToolFile table."""
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[ToolFile.__tablename__]])
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(datasource_manager_module.session_factory, "create_session", session_maker)
|
||||
with session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def _persist_tool_file(session: Session, *, file_id: str, tenant_id: str) -> ToolFile:
|
||||
tool_file = ToolFile(
|
||||
user_id="user-1",
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_key="files/image.png",
|
||||
mimetype="image/png",
|
||||
name="image.png",
|
||||
size=10,
|
||||
)
|
||||
tool_file.id = file_id
|
||||
session.add(tool_file)
|
||||
session.commit()
|
||||
return tool_file
|
||||
|
||||
|
||||
def _gen_messages_text_only(text: str) -> Generator[DatasourceMessage, None, None]:
|
||||
@@ -373,7 +404,8 @@ def test_stream_node_events_emits_events_online_document(mocker: MockerFixture):
|
||||
assert events[-1].node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
|
||||
|
||||
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture):
|
||||
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture, tool_file_session: Session):
|
||||
_persist_tool_file(tool_file_session, file_id="tool_file_1", tenant_id="t1")
|
||||
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
|
||||
|
||||
def _transformed(**_kwargs):
|
||||
@@ -418,19 +450,6 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
|
||||
side_effect=_transformed,
|
||||
)
|
||||
|
||||
fake_tool_file = types.SimpleNamespace(mimetype="image/png")
|
||||
|
||||
class _Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return fake_tool_file
|
||||
|
||||
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
|
||||
mocker.patch("core.datasource.datasource_manager.get_file_type_by_mime_type", return_value=FileType.IMAGE)
|
||||
built = File(
|
||||
file_type=FileType.IMAGE,
|
||||
@@ -481,7 +500,8 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
|
||||
assert events[-1].node_run_result.outputs["x"] == 1
|
||||
|
||||
|
||||
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture):
|
||||
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture, tool_file_session: Session):
|
||||
_persist_tool_file(tool_file_session, file_id="missing", tenant_id="other-tenant")
|
||||
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
|
||||
|
||||
def _transformed(**_kwargs):
|
||||
@@ -496,18 +516,6 @@ def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture):
|
||||
side_effect=_transformed,
|
||||
)
|
||||
|
||||
class _Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return None
|
||||
|
||||
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
|
||||
|
||||
with pytest.raises(ValueError, match="ToolFile not found for file_id=missing, tenant_id=t1"):
|
||||
list(
|
||||
DatasourceManager.stream_node_events(
|
||||
|
||||
@@ -14,18 +14,64 @@ Tests follow the Arrange-Act-Assert pattern for clarity.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.datasource.entities.datasource_entities import DatasourceProviderType
|
||||
from core.datasource.online_document.online_document_provider import (
|
||||
OnlineDocumentDatasourcePluginProviderController,
|
||||
)
|
||||
from core.rag.extractor import notion_extractor as notion_extractor_module
|
||||
from core.rag.extractor.notion_extractor import NotionExtractor
|
||||
from core.rag.models.document import Document
|
||||
from models.base import TypeBase
|
||||
from models.dataset import Document as DocumentModel
|
||||
from models.enums import DataSourceType, DocumentCreatedFrom
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Database:
|
||||
"""Expose the real SQLite session used by the extractor update."""
|
||||
|
||||
session: Session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Database]:
|
||||
"""Bind a real session for Notion document metadata persistence."""
|
||||
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[DocumentModel.__table__])
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
database = _Database(session)
|
||||
monkeypatch.setattr(notion_extractor_module, "db", database)
|
||||
yield database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def persisted_document(database: _Database) -> DocumentModel:
|
||||
document = DocumentModel(
|
||||
id=str(uuid4()),
|
||||
tenant_id=str(uuid4()),
|
||||
dataset_id=str(uuid4()),
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info=json.dumps({"last_edited_time": "2024-01-01T00:00:00.000Z"}),
|
||||
batch="batch",
|
||||
name="Notion page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
database.session.add(document)
|
||||
database.session.commit()
|
||||
return document
|
||||
|
||||
|
||||
class TestNotionExtractorAuthentication:
|
||||
@@ -763,9 +809,14 @@ class TestNotionExtractorLastEditedTime:
|
||||
call_args = mock_request.call_args
|
||||
assert "databases/database-789" in call_args[0][1]
|
||||
|
||||
@patch("core.rag.extractor.notion_extractor.db")
|
||||
@patch("httpx.request")
|
||||
def test_update_last_edited_time(self, mock_request, mock_db, extractor_page, mock_document_model):
|
||||
def test_update_last_edited_time(
|
||||
self,
|
||||
mock_request: Mock,
|
||||
extractor_page: NotionExtractor,
|
||||
database: _Database,
|
||||
persisted_document: DocumentModel,
|
||||
):
|
||||
"""Test updating document model with last edited time."""
|
||||
# Arrange
|
||||
mock_response = Mock()
|
||||
@@ -777,11 +828,11 @@ class TestNotionExtractorLastEditedTime:
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
# Act
|
||||
extractor_page.update_last_edited_time(mock_document_model)
|
||||
extractor_page.update_last_edited_time(persisted_document)
|
||||
|
||||
# Assert
|
||||
assert mock_document_model.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
|
||||
mock_db.session.commit.assert_called_once()
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
|
||||
|
||||
def test_update_last_edited_time_no_document(self, extractor_page):
|
||||
"""Test update_last_edited_time with None document model."""
|
||||
@@ -807,9 +858,10 @@ class TestNotionExtractorIntegration:
|
||||
mock_doc.data_source_info_dict = {"last_edited_time": "2024-01-01T00:00:00.000Z"}
|
||||
return mock_doc
|
||||
|
||||
@patch("core.rag.extractor.notion_extractor.db")
|
||||
@patch("httpx.request")
|
||||
def test_extract_page_complete_workflow(self, mock_request, mock_db, mock_document_model):
|
||||
def test_extract_page_complete_workflow(
|
||||
self, mock_request: Mock, database: _Database, persisted_document: DocumentModel
|
||||
):
|
||||
"""Test complete page extraction workflow."""
|
||||
# Arrange
|
||||
extractor = NotionExtractor(
|
||||
@@ -818,7 +870,7 @@ class TestNotionExtractorIntegration:
|
||||
notion_page_type="page",
|
||||
tenant_id="tenant-789",
|
||||
notion_access_token="test-token",
|
||||
document_model=mock_document_model,
|
||||
document_model=persisted_document,
|
||||
)
|
||||
|
||||
# Mock last edited time request
|
||||
@@ -869,11 +921,18 @@ class TestNotionExtractorIntegration:
|
||||
assert isinstance(documents[0], Document)
|
||||
assert "# Test Page" in documents[0].page_content
|
||||
assert "Test content" in documents[0].page_content
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
|
||||
|
||||
@patch("core.rag.extractor.notion_extractor.db")
|
||||
@patch("httpx.post")
|
||||
@patch("httpx.request")
|
||||
def test_extract_database_complete_workflow(self, mock_request, mock_post, mock_db, mock_document_model):
|
||||
def test_extract_database_complete_workflow(
|
||||
self,
|
||||
mock_request: Mock,
|
||||
mock_post: Mock,
|
||||
database: _Database,
|
||||
persisted_document: DocumentModel,
|
||||
):
|
||||
"""Test complete database extraction workflow."""
|
||||
# Arrange
|
||||
extractor = NotionExtractor(
|
||||
@@ -882,7 +941,7 @@ class TestNotionExtractorIntegration:
|
||||
notion_page_type="database",
|
||||
tenant_id="tenant-789",
|
||||
notion_access_token="test-token",
|
||||
document_model=mock_document_model,
|
||||
document_model=persisted_document,
|
||||
)
|
||||
|
||||
# Mock last edited time request
|
||||
@@ -921,6 +980,8 @@ class TestNotionExtractorIntegration:
|
||||
assert isinstance(documents[0], Document)
|
||||
assert "Name:Item 1" in documents[0].page_content
|
||||
assert "Status:Active" in documents[0].page_content
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
|
||||
|
||||
def test_extract_invalid_page_type(self):
|
||||
"""Test extract with invalid page type."""
|
||||
|
||||
@@ -2,9 +2,21 @@ from types import SimpleNamespace
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import core.rag.extractor.excel_extractor as excel_module
|
||||
from core.rag.extractor.excel_extractor import ExcelExtractor
|
||||
from models.base import TypeBase
|
||||
from models.model import UploadFile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database_session_maker(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> sessionmaker[Session]:
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[UploadFile.__table__])
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(excel_module.session_factory, "create_session", session_maker)
|
||||
return session_maker
|
||||
|
||||
|
||||
class _FakeCell:
|
||||
@@ -58,82 +70,22 @@ class _FakeImage:
|
||||
return self._raw_data
|
||||
|
||||
|
||||
class _FieldExpression:
|
||||
def __eq__(self, other):
|
||||
return ("eq", other)
|
||||
|
||||
def in_(self, values):
|
||||
return ("in", tuple(values))
|
||||
|
||||
|
||||
class _SelectStub:
|
||||
def where(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
|
||||
class _FakeUploadFile:
|
||||
tenant_id = _FieldExpression()
|
||||
key = _FieldExpression()
|
||||
_i = 0
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
type(self)._i += 1
|
||||
self.id = f"u{self._i}"
|
||||
self.key = kwargs["key"]
|
||||
|
||||
|
||||
class _PersistentSession:
|
||||
def __init__(self, persisted):
|
||||
self._persisted = persisted
|
||||
self.added = []
|
||||
self.commit_count = 0
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return SimpleNamespace(all=lambda: list(self._persisted.values()))
|
||||
|
||||
def add_all(self, objects) -> None:
|
||||
self.added.extend(objects)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commit_count += 1
|
||||
for upload_file in self.added:
|
||||
self._persisted[upload_file.key] = upload_file
|
||||
self.added.clear()
|
||||
|
||||
|
||||
class _PersistentSessionFactory:
|
||||
def __init__(self):
|
||||
self.persisted = {}
|
||||
self.sessions = []
|
||||
|
||||
def create_session(self):
|
||||
session = _PersistentSession(self.persisted)
|
||||
self.sessions.append(session)
|
||||
return session
|
||||
|
||||
|
||||
def _patch_image_persistence(monkeypatch: pytest.MonkeyPatch):
|
||||
saves: list[tuple[str, bytes]] = []
|
||||
session_factory = _PersistentSessionFactory()
|
||||
|
||||
def save(key: str, data: bytes) -> None:
|
||||
saves.append((key, data))
|
||||
|
||||
_FakeUploadFile._i = 0
|
||||
monkeypatch.setattr(excel_module, "storage", SimpleNamespace(save=save))
|
||||
monkeypatch.setattr(excel_module, "session_factory", session_factory)
|
||||
monkeypatch.setattr(excel_module, "select", lambda *args, **kwargs: _SelectStub())
|
||||
monkeypatch.setattr(excel_module, "UploadFile", _FakeUploadFile)
|
||||
monkeypatch.setattr(excel_module.storage, "save", save)
|
||||
monkeypatch.setattr(excel_module.dify_config, "FILES_URL", "http://files.local", raising=False)
|
||||
monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False)
|
||||
|
||||
return saves, session_factory
|
||||
return saves
|
||||
|
||||
|
||||
def _get_upload_files(session_maker: sessionmaker[Session]) -> list[UploadFile]:
|
||||
with session_maker() as session:
|
||||
return list(session.scalars(select(UploadFile)).all())
|
||||
|
||||
|
||||
class TestExcelExtractor:
|
||||
@@ -160,7 +112,11 @@ class TestExcelExtractor:
|
||||
assert docs[1].page_content == '"Name":"";"Link":"123"'
|
||||
assert all(doc.metadata["source"] == "/tmp/sample.xlsx" for doc in docs)
|
||||
|
||||
def test_extract_xlsx_turns_embedded_images_into_markdown_links(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_extract_xlsx_turns_embedded_images_into_markdown_links(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nexcel-image"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -175,7 +131,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -184,23 +140,30 @@ class TestExcelExtractor:
|
||||
source_file_id="source-file-1",
|
||||
)
|
||||
docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
|
||||
assert workbook.closed is True
|
||||
assert len(docs) == 2
|
||||
assert len(upload_files) == 1
|
||||
assert docs[0].page_content == (
|
||||
'"Question":"Q1";"Answer":"A1";'
|
||||
'"Image":" '
|
||||
'"'
|
||||
f'"Image":" '
|
||||
f'"'
|
||||
)
|
||||
assert docs[1].page_content == '"Question":"Q2";"Answer":"A2";"Image":""'
|
||||
assert len(saves) == 1
|
||||
assert saves[0][0].startswith("image_files/tenant-1/source-file-1/")
|
||||
assert saves[0][0].endswith(".png")
|
||||
assert saves[0][1] == image_bytes
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1]
|
||||
assert upload_files[0].tenant_id == "tenant-1"
|
||||
assert upload_files[0].key == saves[0][0]
|
||||
assert upload_files[0].used is True
|
||||
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nimage-only-row"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -212,7 +175,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -221,17 +184,21 @@ class TestExcelExtractor:
|
||||
source_file_id="source-file-1",
|
||||
)
|
||||
docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
|
||||
assert workbook.closed is True
|
||||
assert len(docs) == 1
|
||||
assert len(upload_files) == 1
|
||||
assert docs[0].page_content == (
|
||||
'"Question":"";"Answer":"";"Image":""'
|
||||
f'"Question":"";"Answer":"";"Image":""'
|
||||
)
|
||||
assert len(saves) == 1
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1]
|
||||
|
||||
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nretry-safe-image"
|
||||
workbooks = [
|
||||
_FakeWorkbook(
|
||||
@@ -254,7 +221,7 @@ class TestExcelExtractor:
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbooks.pop(0))
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -264,16 +231,17 @@ class TestExcelExtractor:
|
||||
)
|
||||
first_docs = extractor.extract()
|
||||
second_docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
assert len(upload_files) == 1
|
||||
|
||||
expected_page_content = (
|
||||
'"Question":"Q1";"Answer":"A1";"Image":""'
|
||||
'"Question":"Q1";"Answer":"A1";'
|
||||
f'"Image":""'
|
||||
)
|
||||
|
||||
assert first_docs[0].page_content == expected_page_content
|
||||
assert second_docs[0].page_content == expected_page_content
|
||||
assert len(saves) == 1
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1, 0]
|
||||
|
||||
def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
|
||||
class FakeExcelFile:
|
||||
|
||||
@@ -2,7 +2,16 @@ from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import (
|
||||
AIModelEntity,
|
||||
FetchFrom,
|
||||
ModelFeature,
|
||||
ModelPropertyKey,
|
||||
ModelType,
|
||||
ParameterRule,
|
||||
ParameterType,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_model_reuses_single_model_assembly():
|
||||
@@ -47,3 +56,98 @@ def test_fetch_model_reuses_single_model_assembly():
|
||||
model_type=ModelType.LLM,
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
|
||||
def _make_model_schema_with_defaults() -> AIModelEntity:
|
||||
"""Return a minimal AIModelEntity whose parameter_rules carry defaults."""
|
||||
return AIModelEntity(
|
||||
model="qwen-max",
|
||||
label=I18nObject(en_US="Qwen Max"),
|
||||
model_type=ModelType.LLM,
|
||||
features=[ModelFeature.AGENT_THOUGHT, ModelFeature.MULTI_TOOL_CALL],
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={
|
||||
ModelPropertyKey.MODE: "chat",
|
||||
ModelPropertyKey.CONTEXT_SIZE: 32768,
|
||||
},
|
||||
parameter_rules=[
|
||||
ParameterRule(
|
||||
name="temperature",
|
||||
use_template="temperature",
|
||||
label=I18nObject(en_US="Temperature"),
|
||||
type=ParameterType.FLOAT,
|
||||
required=False,
|
||||
default=0.7,
|
||||
min=0.0,
|
||||
max=2.0,
|
||||
precision=2,
|
||||
),
|
||||
ParameterRule(
|
||||
name="max_tokens",
|
||||
use_template="max_tokens",
|
||||
label=I18nObject(en_US="Max Tokens"),
|
||||
type=ParameterType.INT,
|
||||
required=False,
|
||||
default=2048,
|
||||
min=1,
|
||||
max=32768,
|
||||
),
|
||||
ParameterRule(
|
||||
name="top_p",
|
||||
use_template="top_p",
|
||||
label=I18nObject(en_US="Top P"),
|
||||
type=ParameterType.FLOAT,
|
||||
required=False,
|
||||
default=1.0,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_extract_default_completion_params_collects_rule_defaults():
|
||||
"""_extract_default_completion_params should gather every rule.default."""
|
||||
schema = _make_model_schema_with_defaults()
|
||||
params = AgentRuntimeSupport._extract_default_completion_params(schema)
|
||||
assert params == {"temperature": 0.7, "max_tokens": 2048, "top_p": 1.0}
|
||||
|
||||
|
||||
def test_extract_default_completion_params_skips_rules_without_default():
|
||||
"""Rules whose default is None must not appear in the result."""
|
||||
schema = AIModelEntity(
|
||||
model="test-model",
|
||||
label=I18nObject(en_US="Test"),
|
||||
model_type=ModelType.LLM,
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={ModelPropertyKey.MODE: "chat"},
|
||||
parameter_rules=[
|
||||
ParameterRule(
|
||||
name="seed",
|
||||
label=I18nObject(en_US="Seed"),
|
||||
type=ParameterType.INT,
|
||||
required=False,
|
||||
default=None,
|
||||
),
|
||||
ParameterRule(
|
||||
name="temperature",
|
||||
label=I18nObject(en_US="Temperature"),
|
||||
type=ParameterType.FLOAT,
|
||||
required=False,
|
||||
default=0.5,
|
||||
),
|
||||
],
|
||||
)
|
||||
params = AgentRuntimeSupport._extract_default_completion_params(schema)
|
||||
assert params == {"temperature": 0.5}
|
||||
|
||||
|
||||
def test_extract_default_completion_params_empty_when_no_defaults():
|
||||
"""An empty parameter_rules list yields an empty dict."""
|
||||
schema = AIModelEntity(
|
||||
model="test-model",
|
||||
label=I18nObject(en_US="Test"),
|
||||
model_type=ModelType.LLM,
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={ModelPropertyKey.MODE: "chat"},
|
||||
parameter_rules=[],
|
||||
)
|
||||
assert AgentRuntimeSupport._extract_default_completion_params(schema) == {}
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id
|
||||
from libs.helper import OptionalTimestampField, alphanumeric, email, escape_like_pattern, extract_tenant_id
|
||||
from models.account import Account
|
||||
from models.model import EndUser
|
||||
|
||||
@@ -153,3 +153,47 @@ class TestEmailValidator:
|
||||
def test_invalid_email_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid email"):
|
||||
email("not-an-email")
|
||||
|
||||
|
||||
class TestAlphanumericValidator:
|
||||
"""Tests for the alphanumeric() validator — regression for #39666."""
|
||||
|
||||
def test_valid_alphanumeric_accepted(self):
|
||||
assert alphanumeric("tool_name") == "tool_name"
|
||||
assert alphanumeric("Tool123") == "Tool123"
|
||||
assert alphanumeric("_underscore_start") == "_underscore_start"
|
||||
assert alphanumeric("a") == "a"
|
||||
|
||||
def test_trailing_newline_rejected(self):
|
||||
# re.match with $ accepts a trailing \n in Python; re.fullmatch does not.
|
||||
# This was the pre-fix behaviour: alphanumeric("tool\n") returned "tool\n".
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\n")
|
||||
|
||||
def test_trailing_carriage_return_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\r")
|
||||
|
||||
def test_trailing_crlf_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\r\n")
|
||||
|
||||
def test_leading_newline_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("\ntool_name")
|
||||
|
||||
def test_embedded_whitespace_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool name")
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("")
|
||||
|
||||
def test_special_characters_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool-name")
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool.name")
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool/name")
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
preset = "strict"
|
||||
project-includes = ["."]
|
||||
search-path = ["../.."]
|
||||
python-platform = "linux"
|
||||
python-version = "3.12.0"
|
||||
infer-with-first-use = true
|
||||
min-severity = "warn"
|
||||
|
||||
# Existing strict-mode debt. Remove a file when bringing it under strict checking.
|
||||
project-excludes = [
|
||||
"clients/agent_backend/test_cleanup_composition_compositor_integration.py",
|
||||
"clients/agent_backend/test_client.py",
|
||||
"clients/agent_backend/test_event_adapter.py",
|
||||
"clients/agent_backend/test_fake_client.py",
|
||||
"clients/agent_backend/test_request_builder.py",
|
||||
"clients/agent_backend/test_session_cleanup.py",
|
||||
"commands/test_archive_workflow_runs.py",
|
||||
"commands/test_clean_expired_messages.py",
|
||||
"commands/test_data_migration_commands.py",
|
||||
"commands/test_data_migration_wizard.py",
|
||||
"commands/test_fix_app_site_missing.py",
|
||||
"commands/test_generate_swagger_markdown_docs.py",
|
||||
"commands/test_generate_swagger_specs.py",
|
||||
"commands/test_legacy_model_type_migration.py",
|
||||
"commands/test_lint_response_contracts.py",
|
||||
"commands/test_reset_encrypt_key_pair.py",
|
||||
"commands/test_upgrade_db.py",
|
||||
"configs/test_dify_config.py",
|
||||
"configs/test_env_consistency.py",
|
||||
"configs/test_nacos_http_client.py",
|
||||
"conftest.py",
|
||||
"controllers/common/test_agent_app_parameters.py",
|
||||
"controllers/common/test_app_access.py",
|
||||
"controllers/common/test_errors.py",
|
||||
"controllers/common/test_fields.py",
|
||||
"controllers/common/test_file_response.py",
|
||||
"controllers/common/test_helpers.py",
|
||||
"controllers/common/test_schema.py",
|
||||
"controllers/common/test_session.py",
|
||||
"controllers/console/agent/test_agent_controllers.py",
|
||||
"controllers/console/app/test_agent_app_sandbox.py",
|
||||
"controllers/console/app/test_agent_config_inspector.py",
|
||||
"controllers/console/app/test_agent_drive_inspector.py",
|
||||
"controllers/console/app/test_agent_manage_guard.py",
|
||||
"controllers/console/app/test_agent_skills.py",
|
||||
"controllers/console/app/test_annotation_api.py",
|
||||
"controllers/console/app/test_annotation_security.py",
|
||||
"controllers/console/app/test_app_apis.py",
|
||||
"controllers/console/app/test_app_import_api.py",
|
||||
"controllers/console/app/test_app_response_models.py",
|
||||
"controllers/console/app/test_audio.py",
|
||||
"controllers/console/app/test_conversation_api.py",
|
||||
"controllers/console/app/test_conversation_variables_api.py",
|
||||
"controllers/console/app/test_create_app_payload.py",
|
||||
"controllers/console/app/test_description_validation.py",
|
||||
"controllers/console/app/test_generator_api.py",
|
||||
"controllers/console/app/test_generator_api_missing.py",
|
||||
"controllers/console/app/test_mcp_server_response.py",
|
||||
"controllers/console/app/test_message_api.py",
|
||||
"controllers/console/app/test_model_config_api.py",
|
||||
"controllers/console/app/test_ops_trace_api.py",
|
||||
"controllers/console/app/test_statistic_api.py",
|
||||
"controllers/console/app/test_workflow.py",
|
||||
"controllers/console/app/test_workflow_app_log_api.py",
|
||||
"controllers/console/app/test_workflow_comment_api.py",
|
||||
"controllers/console/app/test_workflow_convert_api.py",
|
||||
"controllers/console/app/test_workflow_node_output_inspector.py",
|
||||
"controllers/console/app/test_workflow_pause_details_api.py",
|
||||
"controllers/console/app/test_workflow_run_api.py",
|
||||
"controllers/console/app/test_workflow_trigger_api.py",
|
||||
"controllers/console/app/test_wraps.py",
|
||||
"controllers/console/app/workflow_draft_variables_test.py",
|
||||
"controllers/console/auth/test_account_activation.py",
|
||||
"controllers/console/auth/test_authentication_security.py",
|
||||
"controllers/console/auth/test_data_source_bearer_auth.py",
|
||||
"controllers/console/auth/test_email_register.py",
|
||||
"controllers/console/auth/test_email_register_language.py",
|
||||
"controllers/console/auth/test_email_verification.py",
|
||||
"controllers/console/auth/test_forgot_password.py",
|
||||
"controllers/console/auth/test_login_logout.py",
|
||||
"controllers/console/auth/test_oauth.py",
|
||||
"controllers/console/auth/test_oauth_timezone.py",
|
||||
"controllers/console/auth/test_password_reset.py",
|
||||
"controllers/console/auth/test_token_refresh.py",
|
||||
"controllers/console/billing/test_billing.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_datasource_auth.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py",
|
||||
"controllers/console/datasets/test_data_source.py",
|
||||
"controllers/console/datasets/test_datasets.py",
|
||||
"controllers/console/datasets/test_datasets_document.py",
|
||||
"controllers/console/datasets/test_datasets_document_download.py",
|
||||
"controllers/console/datasets/test_datasets_segments.py",
|
||||
"controllers/console/datasets/test_external.py",
|
||||
"controllers/console/datasets/test_hit_testing.py",
|
||||
"controllers/console/datasets/test_hit_testing_base.py",
|
||||
"controllers/console/datasets/test_metadata.py",
|
||||
"controllers/console/datasets/test_website.py",
|
||||
"controllers/console/datasets/test_wraps.py",
|
||||
"controllers/console/explore/test_audio.py",
|
||||
"controllers/console/explore/test_banner.py",
|
||||
"controllers/console/explore/test_completion.py",
|
||||
"controllers/console/explore/test_installed_app.py",
|
||||
"controllers/console/explore/test_message.py",
|
||||
"controllers/console/explore/test_parameter.py",
|
||||
"controllers/console/explore/test_recommended_app.py",
|
||||
"controllers/console/explore/test_saved_message.py",
|
||||
"controllers/console/explore/test_trial.py",
|
||||
"controllers/console/explore/test_workflow.py",
|
||||
"controllers/console/explore/test_wraps.py",
|
||||
"controllers/console/snippets/test_snippet_workflow.py",
|
||||
"controllers/console/snippets/test_snippet_workflow_draft_variable.py",
|
||||
"controllers/console/tag/test_tags.py",
|
||||
"controllers/console/test_document_detail_api_data_source_info.py",
|
||||
"controllers/console/test_extension.py",
|
||||
"controllers/console/test_fastopenapi_ping.py",
|
||||
"controllers/console/test_fastopenapi_setup.py",
|
||||
"controllers/console/test_fastopenapi_version.py",
|
||||
"controllers/console/test_feature.py",
|
||||
"controllers/console/test_files.py",
|
||||
"controllers/console/test_files_security.py",
|
||||
"controllers/console/test_human_input_form.py",
|
||||
"controllers/console/test_knowledge_fs_proxy.py",
|
||||
"controllers/console/test_remote_files.py",
|
||||
"controllers/console/test_spec.py",
|
||||
"controllers/console/test_version.py",
|
||||
"controllers/console/test_workflow_run_archive.py",
|
||||
"controllers/console/test_workspace_account.py",
|
||||
"controllers/console/test_workspace_members.py",
|
||||
"controllers/console/test_wraps.py",
|
||||
"controllers/console/workspace/test_accounts.py",
|
||||
"controllers/console/workspace/test_agent_providers.py",
|
||||
"controllers/console/workspace/test_endpoint.py",
|
||||
"controllers/console/workspace/test_load_balancing_config.py",
|
||||
"controllers/console/workspace/test_members.py",
|
||||
"controllers/console/workspace/test_model_providers.py",
|
||||
"controllers/console/workspace/test_models.py",
|
||||
"controllers/console/workspace/test_plugin.py",
|
||||
"controllers/console/workspace/test_rbac.py",
|
||||
"controllers/console/workspace/test_snippets.py",
|
||||
"controllers/console/workspace/test_tool_providers.py",
|
||||
"controllers/console/workspace/test_workspace.py",
|
||||
"controllers/files/test_image_preview.py",
|
||||
"controllers/files/test_tool_files.py",
|
||||
"controllers/files/test_upload.py",
|
||||
"controllers/inner_api/app/test_dsl.py",
|
||||
"controllers/inner_api/plugin/test_agent_config.py",
|
||||
"controllers/inner_api/plugin/test_agent_drive.py",
|
||||
"controllers/inner_api/plugin/test_plugin.py",
|
||||
"controllers/inner_api/plugin/test_plugin_wraps.py",
|
||||
"controllers/inner_api/test_auth_wraps.py",
|
||||
"controllers/inner_api/test_knowledge_retrieval.py",
|
||||
"controllers/inner_api/test_mail.py",
|
||||
"controllers/inner_api/test_runtime_credentials.py",
|
||||
"controllers/inner_api/workspace/test_workspace.py",
|
||||
"controllers/mcp/test_mcp.py",
|
||||
"controllers/openapi/auth/test_composition.py",
|
||||
"controllers/openapi/auth/test_conditions.py",
|
||||
"controllers/openapi/auth/test_data.py",
|
||||
"controllers/openapi/auth/test_flow.py",
|
||||
"controllers/openapi/auth/test_pipeline.py",
|
||||
"controllers/openapi/auth/test_prepare.py",
|
||||
"controllers/openapi/auth/test_verify.py",
|
||||
"controllers/openapi/conftest.py",
|
||||
"controllers/openapi/test_account.py",
|
||||
"controllers/openapi/test_app_describe_builder.py",
|
||||
"controllers/openapi/test_app_list_query.py",
|
||||
"controllers/openapi/test_app_payloads.py",
|
||||
"controllers/openapi/test_app_run_dispatch.py",
|
||||
"controllers/openapi/test_app_run_rate_limit.py",
|
||||
"controllers/openapi/test_app_run_streaming.py",
|
||||
"controllers/openapi/test_apps_permitted_external_query.py",
|
||||
"controllers/openapi/test_audit_app_run.py",
|
||||
"controllers/openapi/test_contract.py",
|
||||
"controllers/openapi/test_cors.py",
|
||||
"controllers/openapi/test_device_approve_deny.py",
|
||||
"controllers/openapi/test_device_code.py",
|
||||
"controllers/openapi/test_device_lookup.py",
|
||||
"controllers/openapi/test_device_sso.py",
|
||||
"controllers/openapi/test_device_token.py",
|
||||
"controllers/openapi/test_error_contract.py",
|
||||
"controllers/openapi/test_health.py",
|
||||
"controllers/openapi/test_human_input_form.py",
|
||||
"controllers/openapi/test_input_schema.py",
|
||||
"controllers/openapi/test_meta_version.py",
|
||||
"controllers/openapi/test_models.py",
|
||||
"controllers/openapi/test_oauth_sso_claims.py",
|
||||
"controllers/openapi/test_oauth_sso_csrf.py",
|
||||
"controllers/openapi/test_oauth_sso_host_header.py",
|
||||
"controllers/openapi/test_pagination_envelope.py",
|
||||
"controllers/openapi/test_supported_app_type.py",
|
||||
"controllers/openapi/test_version_gate.py",
|
||||
"controllers/openapi/test_workflow_events_openapi.py",
|
||||
"controllers/openapi/test_workspaces.py",
|
||||
"controllers/openapi/test_workspaces_members.py",
|
||||
"controllers/service_api/app/test_annotation.py",
|
||||
"controllers/service_api/app/test_app.py",
|
||||
"controllers/service_api/app/test_audio.py",
|
||||
"controllers/service_api/app/test_chat_request_payload.py",
|
||||
"controllers/service_api/app/test_completion.py",
|
||||
"controllers/service_api/app/test_conversation.py",
|
||||
"controllers/service_api/app/test_file.py",
|
||||
"controllers/service_api/app/test_file_preview.py",
|
||||
"controllers/service_api/app/test_hitl_service_api.py",
|
||||
"controllers/service_api/app/test_human_input_form.py",
|
||||
"controllers/service_api/app/test_message.py",
|
||||
"controllers/service_api/app/test_workflow.py",
|
||||
"controllers/service_api/app/test_workflow_events.py",
|
||||
"controllers/service_api/conftest.py",
|
||||
"controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py",
|
||||
"controllers/service_api/dataset/test_dataset_segment.py",
|
||||
"controllers/service_api/dataset/test_document.py",
|
||||
"controllers/service_api/dataset/test_hit_testing.py",
|
||||
"controllers/service_api/dataset/test_metadata.py",
|
||||
"controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py",
|
||||
"controllers/service_api/dataset/test_rag_pipeline_route_registration.py",
|
||||
"controllers/service_api/test_index.py",
|
||||
"controllers/service_api/test_trace_session_id_parsing.py",
|
||||
"controllers/service_api/test_wraps.py",
|
||||
"controllers/test_compare_versions.py",
|
||||
"controllers/test_conversation_rename_payload.py",
|
||||
"controllers/test_swagger.py",
|
||||
"controllers/trigger/test_trigger.py",
|
||||
"controllers/trigger/test_webhook.py",
|
||||
"controllers/web/conftest.py",
|
||||
"controllers/web/test_app.py",
|
||||
"controllers/web/test_completion.py",
|
||||
"controllers/web/test_human_input_file_upload.py",
|
||||
"controllers/web/test_human_input_form.py",
|
||||
"controllers/web/test_message_endpoints.py",
|
||||
"controllers/web/test_message_list.py",
|
||||
"controllers/web/test_pydantic_models.py",
|
||||
"controllers/web/test_web_forgot_password.py",
|
||||
"controllers/web/test_web_login.py",
|
||||
"controllers/web/test_web_passport.py",
|
||||
"controllers/web/test_workflow.py",
|
||||
"controllers/web/test_wraps.py",
|
||||
"core/agent/conftest.py",
|
||||
"core/agent/output_parser/test_cot_output_parser.py",
|
||||
"core/agent/strategy/test_base.py",
|
||||
"core/agent/strategy/test_plugin.py",
|
||||
"core/agent/test_base_agent_runner.py",
|
||||
"core/agent/test_cot_agent_runner.py",
|
||||
"core/agent/test_cot_chat_agent_runner.py",
|
||||
"core/agent/test_cot_completion_agent_runner.py",
|
||||
"core/agent/test_fc_agent_runner.py",
|
||||
"core/agent/test_plugin_entities.py",
|
||||
"core/app/app_config/common/test_parameters_mapping.py",
|
||||
"core/app/app_config/common/test_sensitive_word_avoidance_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_agent_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_dataset_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_model_config_converter.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_model_config_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_variables_manager.py",
|
||||
"core/app/app_config/features/file_upload/test_manager.py",
|
||||
"core/app/app_config/features/test_additional_feature_managers.py",
|
||||
"core/app/app_config/test_base_app_config_manager.py",
|
||||
"core/app/app_config/test_entities.py",
|
||||
"core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py",
|
||||
"core/app/apps/advanced_chat/test_app_config_manager.py",
|
||||
"core/app/apps/advanced_chat/test_app_generator.py",
|
||||
"core/app/apps/advanced_chat/test_app_runner_conversation_variables.py",
|
||||
"core/app/apps/advanced_chat/test_app_runner_input_moderation.py",
|
||||
"core/app/apps/advanced_chat/test_generate_response_converter.py",
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline.py",
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline_core.py",
|
||||
"core/app/apps/agent_app/test_app_config_manager.py",
|
||||
"core/app/apps/agent_app/test_app_generator.py",
|
||||
"core/app/apps/agent_app/test_app_runner.py",
|
||||
"core/app/apps/agent_app/test_input_guards.py",
|
||||
"core/app/apps/agent_app/test_resolve_agent.py",
|
||||
"core/app/apps/agent_app/test_runtime_request_builder.py",
|
||||
"core/app/apps/agent_app/test_session_store.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_config_manager.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_generator.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_runner.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_generate_response_converter.py",
|
||||
"core/app/apps/chat/test_app_config_manager.py",
|
||||
"core/app/apps/chat/test_app_generator_and_runner.py",
|
||||
"core/app/apps/chat/test_base_app_runner_multimodal.py",
|
||||
"core/app/apps/chat/test_generate_response_converter.py",
|
||||
"core/app/apps/common/test_graph_runtime_state_support.py",
|
||||
"core/app/apps/common/test_workflow_response_converter.py",
|
||||
"core/app/apps/common/test_workflow_response_converter_human_input.py",
|
||||
"core/app/apps/common/test_workflow_response_converter_resumption.py",
|
||||
"core/app/apps/common/test_workflow_response_converter_truncation.py",
|
||||
"core/app/apps/completion/test_app_runner.py",
|
||||
"core/app/apps/completion/test_completion_app_config_manager.py",
|
||||
"core/app/apps/completion/test_completion_completion_app_generator.py",
|
||||
"core/app/apps/completion/test_completion_generate_response_converter.py",
|
||||
"core/app/apps/pipeline/test_pipeline_config_manager.py",
|
||||
"core/app/apps/pipeline/test_pipeline_generate_response_converter.py",
|
||||
"core/app/apps/pipeline/test_pipeline_generator.py",
|
||||
"core/app/apps/pipeline/test_pipeline_queue_manager.py",
|
||||
"core/app/apps/pipeline/test_pipeline_runner.py",
|
||||
"core/app/apps/test_advanced_chat_app_generator.py",
|
||||
"core/app/apps/test_base_app_generate_response_converter.py",
|
||||
"core/app/apps/test_base_app_generator.py",
|
||||
"core/app/apps/test_base_app_queue_manager.py",
|
||||
"core/app/apps/test_base_app_runner.py",
|
||||
"core/app/apps/test_exc.py",
|
||||
"core/app/apps/test_message_based_app_generator.py",
|
||||
"core/app/apps/test_message_based_app_queue_manager.py",
|
||||
"core/app/apps/test_message_generator.py",
|
||||
"core/app/apps/test_pause_resume.py",
|
||||
"core/app/apps/test_streaming_utils.py",
|
||||
"core/app/apps/test_trace_session_id_generate_extras.py",
|
||||
"core/app/apps/test_workflow_app_generator.py",
|
||||
"core/app/apps/test_workflow_app_runner_core.py",
|
||||
"core/app/apps/test_workflow_app_runner_notifications.py",
|
||||
"core/app/apps/test_workflow_app_runner_single_node.py",
|
||||
"core/app/apps/test_workflow_pause_events.py",
|
||||
"core/app/apps/workflow/test_active_workflow_tasks.py",
|
||||
"core/app/apps/workflow/test_app_config_manager.py",
|
||||
"core/app/apps/workflow/test_app_generator_extra.py",
|
||||
"core/app/apps/workflow/test_app_queue_manager.py",
|
||||
"core/app/apps/workflow/test_command_channels.py",
|
||||
"core/app/apps/workflow/test_errors.py",
|
||||
"core/app/apps/workflow/test_generate_response_converter.py",
|
||||
"core/app/apps/workflow/test_generate_task_pipeline.py",
|
||||
"core/app/apps/workflow/test_generate_task_pipeline_core.py",
|
||||
"core/app/entities/test_app_invoke_entities.py",
|
||||
"core/app/entities/test_queue_entities.py",
|
||||
"core/app/entities/test_rag_pipeline_invoke_entities.py",
|
||||
"core/app/entities/test_task_entities.py",
|
||||
"core/app/features/rate_limiting/conftest.py",
|
||||
"core/app/features/rate_limiting/test_rate_limit.py",
|
||||
"core/app/features/test_annotation_reply.py",
|
||||
"core/app/features/test_hosting_moderation.py",
|
||||
"core/app/layers/test_conversation_variable_persist_layer.py",
|
||||
"core/app/layers/test_pause_state_persist_layer.py",
|
||||
"core/app/layers/test_suspend_layer.py",
|
||||
"core/app/layers/test_timeslice_layer.py",
|
||||
"core/app/layers/test_trigger_post_layer.py",
|
||||
"core/app/task_pipeline/test_based_generate_task_pipeline.py",
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py",
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py",
|
||||
"core/app/task_pipeline/test_exc.py",
|
||||
"core/app/task_pipeline/test_message_cycle_manager_optimization.py",
|
||||
"core/app/test_easy_ui_model_config_manager.py",
|
||||
"core/app/test_invoke_from.py",
|
||||
"core/app/test_llm_quota.py",
|
||||
"core/app/workflow/layers/test_persistence.py",
|
||||
"core/app/workflow/layers/test_persistence_inspector_publish.py",
|
||||
"core/app/workflow/test_file_runtime.py",
|
||||
"core/app/workflow/test_node_factory.py",
|
||||
"core/app/workflow/test_observability_layer_extra.py",
|
||||
"core/app/workflow/test_persistence_layer.py",
|
||||
"core/base/test_app_generator_tts_publisher.py",
|
||||
"core/callback_handler/test_agent_tool_callback_handler.py",
|
||||
"core/callback_handler/test_index_tool_callback_handler.py",
|
||||
"core/callback_handler/test_workflow_tool_callback_handler.py",
|
||||
"core/datasource/__base/test_datasource_plugin.py",
|
||||
"core/datasource/__base/test_datasource_provider.py",
|
||||
"core/datasource/__base/test_datasource_runtime.py",
|
||||
"core/datasource/entities/test_api_entities.py",
|
||||
"core/datasource/entities/test_common_entities.py",
|
||||
"core/datasource/entities/test_datasource_entities.py",
|
||||
"core/datasource/local_file/test_local_file_plugin.py",
|
||||
"core/datasource/local_file/test_local_file_provider.py",
|
||||
"core/datasource/online_document/test_online_document_plugin.py",
|
||||
"core/datasource/online_document/test_online_document_provider.py",
|
||||
"core/datasource/online_drive/test_online_drive_plugin.py",
|
||||
"core/datasource/online_drive/test_online_drive_provider.py",
|
||||
"core/datasource/test_datasource_file_manager.py",
|
||||
"core/datasource/test_datasource_manager.py",
|
||||
"core/datasource/test_errors.py",
|
||||
"core/datasource/test_file_upload.py",
|
||||
"core/datasource/test_notion_provider.py",
|
||||
"core/datasource/test_website_crawl.py",
|
||||
"core/datasource/utils/test_message_transformer.py",
|
||||
"core/datasource/website_crawl/test_website_crawl_plugin.py",
|
||||
"core/datasource/website_crawl/test_website_crawl_provider.py",
|
||||
"core/entities/test_entities_mcp_provider.py",
|
||||
"core/entities/test_entities_provider_configuration.py",
|
||||
"core/extension/test_api_based_extension_requestor.py",
|
||||
"core/extension/test_extensible.py",
|
||||
"core/extension/test_extension.py",
|
||||
"core/external_data_tool/api/test_api.py",
|
||||
"core/external_data_tool/test_base.py",
|
||||
"core/external_data_tool/test_external_data_fetch.py",
|
||||
"core/external_data_tool/test_factory.py",
|
||||
"core/file/test_models.py",
|
||||
"core/file/test_remote_fetcher.py",
|
||||
"core/helper/code_executor/javascript/test_javascript_transformer.py",
|
||||
"core/helper/code_executor/jinja2/test_jinja2_sandbox.py",
|
||||
"core/helper/code_executor/python3/test_python3_transformer.py",
|
||||
"core/helper/code_executor/test_code_executor.py",
|
||||
"core/helper/code_executor/test_code_node_provider.py",
|
||||
"core/helper/code_executor/test_template_transformer.py",
|
||||
"core/helper/test_creators.py",
|
||||
"core/helper/test_credential_utils.py",
|
||||
"core/helper/test_csv_sanitizer.py",
|
||||
"core/helper/test_encrypter.py",
|
||||
"core/helper/test_ssrf_proxy.py",
|
||||
"core/helper/test_trace_id_helper.py",
|
||||
"core/llm_generator/output_parser/test_rule_config_generator.py",
|
||||
"core/llm_generator/output_parser/test_structured_output.py",
|
||||
"core/llm_generator/test_llm_generator.py",
|
||||
"core/llm_generator/test_llm_generator_missing.py",
|
||||
"core/logging/test_context.py",
|
||||
"core/logging/test_filters.py",
|
||||
"core/logging/test_structured_formatter.py",
|
||||
"core/logging/test_trace_helpers.py",
|
||||
"core/mcp/auth/test_auth_flow.py",
|
||||
"core/mcp/client/test_session.py",
|
||||
"core/mcp/client/test_sse.py",
|
||||
"core/mcp/client/test_streamable_http.py",
|
||||
"core/mcp/server/test_streamable_http.py",
|
||||
"core/mcp/session/test_base_session.py",
|
||||
"core/mcp/session/test_client_session.py",
|
||||
"core/mcp/test_auth_client_inheritance.py",
|
||||
"core/mcp/test_entities.py",
|
||||
"core/mcp/test_error.py",
|
||||
"core/mcp/test_mcp_client.py",
|
||||
"core/mcp/test_types.py",
|
||||
"core/mcp/test_utils.py",
|
||||
"core/memory/test_token_buffer_memory.py",
|
||||
"core/model_runtime/test_model_provider_factory.py",
|
||||
"core/moderation/api/test_api.py",
|
||||
"core/moderation/test_content_moderation.py",
|
||||
"core/moderation/test_input_moderation.py",
|
||||
"core/moderation/test_output_moderation.py",
|
||||
"core/moderation/test_sensitive_word_filter.py",
|
||||
"core/ops/test_base_trace_instance.py",
|
||||
"core/ops/test_config_entity.py",
|
||||
"core/ops/test_lookup_helpers.py",
|
||||
"core/ops/test_ops_trace_manager.py",
|
||||
"core/ops/test_trace_queue_manager.py",
|
||||
"core/ops/test_trace_session_metadata.py",
|
||||
"core/ops/test_utils.py",
|
||||
"core/plugin/impl/test_agent_client.py",
|
||||
"core/plugin/impl/test_asset_manager.py",
|
||||
"core/plugin/impl/test_base_client_impl.py",
|
||||
"core/plugin/impl/test_datasource_manager.py",
|
||||
"core/plugin/impl/test_debugging_client.py",
|
||||
"core/plugin/impl/test_endpoint_client_impl.py",
|
||||
"core/plugin/impl/test_exc_impl.py",
|
||||
"core/plugin/impl/test_model_client.py",
|
||||
"core/plugin/impl/test_model_runtime_factory.py",
|
||||
"core/plugin/impl/test_oauth_handler.py",
|
||||
"core/plugin/impl/test_tool_manager.py",
|
||||
"core/plugin/impl/test_trigger_client.py",
|
||||
"core/plugin/test_backwards_invocation_app.py",
|
||||
"core/plugin/test_backwards_invocation_model.py",
|
||||
"core/plugin/test_endpoint_client.py",
|
||||
"core/plugin/test_model_runtime_adapter.py",
|
||||
"core/plugin/test_plugin_entities.py",
|
||||
"core/plugin/test_plugin_manager.py",
|
||||
"core/plugin/test_plugin_runtime.py",
|
||||
"core/plugin/utils/test_chunk_merger.py",
|
||||
"core/plugin/utils/test_http_parser.py",
|
||||
"core/prompt/test_advanced_prompt_transform.py",
|
||||
"core/prompt/test_agent_history_prompt_transform.py",
|
||||
"core/prompt/test_extract_thread_messages.py",
|
||||
"core/prompt/test_prompt_message.py",
|
||||
"core/prompt/test_prompt_transform.py",
|
||||
"core/prompt/test_simple_prompt_transform.py",
|
||||
"core/rag/cleaner/test_clean_processor.py",
|
||||
"core/rag/data_post_processor/test_data_post_processor.py",
|
||||
"core/rag/datasource/keyword/jieba/test_jieba.py",
|
||||
"core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py",
|
||||
"core/rag/datasource/keyword/jieba/test_stopwords.py",
|
||||
"core/rag/datasource/keyword/test_keyword_base.py",
|
||||
"core/rag/datasource/keyword/test_keyword_factory.py",
|
||||
"core/rag/datasource/test_datasource_retrieval.py",
|
||||
"core/rag/datasource/vdb/test_field.py",
|
||||
"core/rag/datasource/vdb/test_vector_base.py",
|
||||
"core/rag/datasource/vdb/test_vector_factory.py",
|
||||
"core/rag/docstore/test_dataset_docstore.py",
|
||||
"core/rag/embedding/test_cached_embedding.py",
|
||||
"core/rag/embedding/test_embedding_base.py",
|
||||
"core/rag/embedding/test_embedding_service.py",
|
||||
"core/rag/extractor/blob/test_blob.py",
|
||||
"core/rag/extractor/firecrawl/test_firecrawl.py",
|
||||
"core/rag/extractor/test_csv_extractor.py",
|
||||
"core/rag/extractor/test_excel_extractor.py",
|
||||
"core/rag/extractor/test_extract_processor.py",
|
||||
"core/rag/extractor/test_extractor_base.py",
|
||||
"core/rag/extractor/test_helpers.py",
|
||||
"core/rag/extractor/test_html_extractor.py",
|
||||
"core/rag/extractor/test_jina_reader_extractor.py",
|
||||
"core/rag/extractor/test_markdown_extractor.py",
|
||||
"core/rag/extractor/test_notion_extractor.py",
|
||||
"core/rag/extractor/test_pdf_extractor.py",
|
||||
"core/rag/extractor/test_text_extractor.py",
|
||||
"core/rag/extractor/test_word_extractor.py",
|
||||
"core/rag/extractor/unstructured/test_unstructured_extractors.py",
|
||||
"core/rag/extractor/watercrawl/test_watercrawl.py",
|
||||
"core/rag/indexing/processor/test_paragraph_index_processor.py",
|
||||
"core/rag/indexing/processor/test_qa_index_processor.py",
|
||||
"core/rag/indexing/test_index_processor.py",
|
||||
"core/rag/indexing/test_index_processor_base.py",
|
||||
"core/rag/indexing/test_indexing_runner.py",
|
||||
"core/rag/pipeline/test_queue.py",
|
||||
"core/rag/rerank/test_reranker.py",
|
||||
"core/rag/retrieval/test_dataset_retrieval.py",
|
||||
"core/rag/retrieval/test_dataset_retrieval_methods.py",
|
||||
"core/rag/retrieval/test_multi_dataset_function_call_router.py",
|
||||
"core/rag/retrieval/test_multi_dataset_react_route.py",
|
||||
"core/rag/splitter/test_text_splitter.py",
|
||||
"core/repositories/test_celery_workflow_execution_repository.py",
|
||||
"core/repositories/test_celery_workflow_node_execution_repository.py",
|
||||
"core/repositories/test_factory.py",
|
||||
"core/repositories/test_human_input_form_repository_impl.py",
|
||||
"core/repositories/test_human_input_repository.py",
|
||||
"core/repositories/test_sqlalchemy_workflow_execution_repository.py",
|
||||
"core/repositories/test_sqlalchemy_workflow_node_execution_repository.py",
|
||||
"core/repositories/test_workflow_node_execution_conflict_handling.py",
|
||||
"core/repositories/test_workflow_node_execution_truncation.py",
|
||||
"core/schemas/test_registry.py",
|
||||
"core/schemas/test_resolver.py",
|
||||
"core/schemas/test_schema_manager.py",
|
||||
"core/telemetry/test_facade.py",
|
||||
"core/telemetry/test_gateway_integration.py",
|
||||
"core/test_file.py",
|
||||
"core/test_model_manager.py",
|
||||
"core/test_provider_configuration.py",
|
||||
"core/test_provider_manager.py",
|
||||
"core/test_trigger_debug_event_selectors.py",
|
||||
"core/tools/entities/test_api_entities.py",
|
||||
"core/tools/test_base_tool.py",
|
||||
"core/tools/test_builtin_tool_base.py",
|
||||
"core/tools/test_builtin_tool_provider.py",
|
||||
"core/tools/test_builtin_tools_extra.py",
|
||||
"core/tools/test_custom_tool.py",
|
||||
"core/tools/test_custom_tool_provider.py",
|
||||
"core/tools/test_dataset_retriever_tool.py",
|
||||
"core/tools/test_mcp_tool.py",
|
||||
"core/tools/test_mcp_tool_provider.py",
|
||||
"core/tools/test_plugin_tool.py",
|
||||
"core/tools/test_plugin_tool_provider.py",
|
||||
"core/tools/test_tool_engine.py",
|
||||
"core/tools/test_tool_entities.py",
|
||||
"core/tools/test_tool_file_manager.py",
|
||||
"core/tools/test_tool_label_manager.py",
|
||||
"core/tools/test_tool_manager.py",
|
||||
"core/tools/test_tool_parameter_type.py",
|
||||
"core/tools/test_tool_provider_controller.py",
|
||||
"core/tools/utils/test_configuration.py",
|
||||
"core/tools/utils/test_encryption.py",
|
||||
"core/tools/utils/test_message_transformer.py",
|
||||
"core/tools/utils/test_misc_utils_extra.py",
|
||||
"core/tools/utils/test_model_invocation_utils.py",
|
||||
"core/tools/utils/test_parser.py",
|
||||
"core/tools/utils/test_system_oauth_encryption.py",
|
||||
"core/tools/utils/test_tool_engine_serialization.py",
|
||||
"core/tools/utils/test_web_reader_tool.py",
|
||||
"core/tools/utils/test_workflow_configuration_sync.py",
|
||||
"core/tools/workflow_as_tool/test_provider.py",
|
||||
"core/tools/workflow_as_tool/test_tool.py",
|
||||
"core/trigger/conftest.py",
|
||||
"core/trigger/debug/test_debug_event_bus.py",
|
||||
"core/trigger/debug/test_debug_event_selectors.py",
|
||||
"core/trigger/test_provider.py",
|
||||
"core/trigger/test_trigger_manager.py",
|
||||
"core/trigger/utils/test_utils_encryption.py",
|
||||
"core/trigger/utils/test_utils_endpoint.py",
|
||||
"core/trigger/utils/test_utils_locks.py",
|
||||
"core/variables/test_segment.py",
|
||||
"core/variables/test_segment_type.py",
|
||||
"core/variables/test_segment_type_validation.py",
|
||||
"core/variables/test_variables.py",
|
||||
"core/workflow/context/test_execution_context.py",
|
||||
"core/workflow/context/test_flask_app_context.py",
|
||||
"core/workflow/entities/test_private_workflow_pause.py",
|
||||
"core/workflow/generator/test_prompts.py",
|
||||
"core/workflow/generator/test_runner.py",
|
||||
"core/workflow/generator/test_runner_missing.py",
|
||||
"core/workflow/generator/test_tool_catalogue.py",
|
||||
"core/workflow/graph_engine/layers/conftest.py",
|
||||
"core/workflow/graph_engine/layers/test_llm_quota.py",
|
||||
"core/workflow/graph_engine/layers/test_observability.py",
|
||||
"core/workflow/graph_engine/test_mock_config.py",
|
||||
"core/workflow/graph_engine/test_mock_factory.py",
|
||||
"core/workflow/graph_engine/test_mock_nodes.py",
|
||||
"core/workflow/graph_engine/test_parallel_human_input_join_resume.py",
|
||||
"core/workflow/graph_engine/test_table_runner.py",
|
||||
"core/workflow/graph_engine/test_tool_in_chatflow.py",
|
||||
"core/workflow/nodes/agent/test_message_transformer.py",
|
||||
"core/workflow/nodes/agent/test_runtime_support.py",
|
||||
"core/workflow/nodes/agent_v2/test_agent_node.py",
|
||||
"core/workflow/nodes/agent_v2/test_binding_resolver.py",
|
||||
"core/workflow/nodes/agent_v2/test_dify_tools_builder.py",
|
||||
"core/workflow/nodes/agent_v2/test_file_tenant_validator.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_adapter.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_failure_orchestrator.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_file_rebacker.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_type_checker.py",
|
||||
"core/workflow/nodes/agent_v2/test_runtime_request_builder.py",
|
||||
"core/workflow/nodes/agent_v2/test_session_cleanup_layer.py",
|
||||
"core/workflow/nodes/agent_v2/test_session_store.py",
|
||||
"core/workflow/nodes/agent_v2/test_validators.py",
|
||||
"core/workflow/nodes/answer/test_answer.py",
|
||||
"core/workflow/nodes/base/test_base_node.py",
|
||||
"core/workflow/nodes/base/test_get_node_type_classes_mapping.py",
|
||||
"core/workflow/nodes/code/code_node_spec.py",
|
||||
"core/workflow/nodes/datasource/test_datasource_node.py",
|
||||
"core/workflow/nodes/http_request/test_http_request_executor.py",
|
||||
"core/workflow/nodes/http_request/test_http_request_node.py",
|
||||
"core/workflow/nodes/human_input/test_dify_owned_contracts.py",
|
||||
"core/workflow/nodes/human_input/test_email_delivery_config.py",
|
||||
"core/workflow/nodes/human_input/test_entities.py",
|
||||
"core/workflow/nodes/human_input/test_human_input_form_filled_event.py",
|
||||
"core/workflow/nodes/iteration/test_iteration_child_engine_errors.py",
|
||||
"core/workflow/nodes/knowledge_index/test_knowledge_index_node.py",
|
||||
"core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py",
|
||||
"core/workflow/nodes/list_operator/node_spec.py",
|
||||
"core/workflow/nodes/llm/test_llm_utils.py",
|
||||
"core/workflow/nodes/llm/test_node.py",
|
||||
"core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py",
|
||||
"core/workflow/nodes/template_transform/template_transform_node_spec.py",
|
||||
"core/workflow/nodes/template_transform/test_template_transform_node.py",
|
||||
"core/workflow/nodes/test_base_node.py",
|
||||
"core/workflow/nodes/test_document_extractor_node.py",
|
||||
"core/workflow/nodes/test_if_else.py",
|
||||
"core/workflow/nodes/test_list_operator.py",
|
||||
"core/workflow/nodes/test_start_node_json_object.py",
|
||||
"core/workflow/nodes/tool/test_tool_node.py",
|
||||
"core/workflow/nodes/tool/test_tool_node_runtime.py",
|
||||
"core/workflow/nodes/webhook/test_entities.py",
|
||||
"core/workflow/nodes/webhook/test_exceptions.py",
|
||||
"core/workflow/nodes/webhook/test_webhook_file_conversion.py",
|
||||
"core/workflow/nodes/webhook/test_webhook_node.py",
|
||||
"core/workflow/test_enrich_pause_reasons.py",
|
||||
"core/workflow/test_form_input_serialization_compat.py",
|
||||
"core/workflow/test_graph_topology.py",
|
||||
"core/workflow/test_human_input_adapter.py",
|
||||
"core/workflow/test_human_input_callback.py",
|
||||
"core/workflow/test_human_input_forms.py",
|
||||
"core/workflow/test_node_factory.py",
|
||||
"core/workflow/test_node_mapping_bootstrap.py",
|
||||
"core/workflow/test_node_runtime.py",
|
||||
"core/workflow/test_system_variable.py",
|
||||
"core/workflow/test_variable_pool.py",
|
||||
"core/workflow/test_workflow_entry.py",
|
||||
"core/workflow/test_workflow_entry_helpers.py",
|
||||
"core/workflow/test_workflow_entry_redis_channel.py",
|
||||
"dev/test_generate_knowledge_fs_contract.py",
|
||||
"enterprise/telemetry/test_contracts.py",
|
||||
"enterprise/telemetry/test_draft_trace.py",
|
||||
"enterprise/telemetry/test_enterprise_trace.py",
|
||||
"enterprise/telemetry/test_event_handlers.py",
|
||||
"enterprise/telemetry/test_exporter.py",
|
||||
"enterprise/telemetry/test_gateway.py",
|
||||
"enterprise/telemetry/test_metric_handler.py",
|
||||
"enums/test_quota_type.py",
|
||||
"events/event_handlers/test_clean_when_document_deleted.py",
|
||||
"events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py",
|
||||
"events/test_app_event_signals.py",
|
||||
"events/test_events_package_compat.py",
|
||||
"events/test_update_provider_when_message_created.py",
|
||||
"extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py",
|
||||
"extensions/logstore/test_sql_escape.py",
|
||||
"extensions/otel/conftest.py",
|
||||
"extensions/otel/decorators/handlers/test_generate_handler.py",
|
||||
"extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py",
|
||||
"extensions/otel/decorators/test_base.py",
|
||||
"extensions/otel/decorators/test_handler.py",
|
||||
"extensions/otel/test_celery_sqlcommenter.py",
|
||||
"extensions/otel/test_context.py",
|
||||
"extensions/otel/test_retrieval_tracing.py",
|
||||
"extensions/otel/test_runtime.py",
|
||||
"extensions/storage/test_supabase_storage.py",
|
||||
"extensions/test_celery_ssl.py",
|
||||
"extensions/test_ext_blueprints_openapi.py",
|
||||
"extensions/test_ext_login.py",
|
||||
"extensions/test_ext_request_logging.py",
|
||||
"extensions/test_ext_socketio.py",
|
||||
"extensions/test_pubsub_channel.py",
|
||||
"extensions/test_redis.py",
|
||||
"extensions/test_set_secretkey.py",
|
||||
"extensions/test_workflow_warm_shutdown.py",
|
||||
"factories/test_build_from_mapping.py",
|
||||
"factories/test_file_factory.py",
|
||||
"factories/test_file_validation.py",
|
||||
"factories/test_variable_factory.py",
|
||||
"fields/test_dataset_fields.py",
|
||||
"fields/test_file_fields.py",
|
||||
"fields/test_message_fields.py",
|
||||
"libs/_human_input/support.py",
|
||||
"libs/_human_input/test_form_service.py",
|
||||
"libs/_human_input/test_models.py",
|
||||
"libs/broadcast_channel/redis/test_channel_unit_tests.py",
|
||||
"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py",
|
||||
"libs/test_api_token_cache.py",
|
||||
"libs/test_archive_storage.py",
|
||||
"libs/test_cron_compatibility.py",
|
||||
"libs/test_custom_inputs.py",
|
||||
"libs/test_datetime_utils.py",
|
||||
"libs/test_email.py",
|
||||
"libs/test_email_i18n.py",
|
||||
"libs/test_encryption.py",
|
||||
"libs/test_external_api.py",
|
||||
"libs/test_file_utils.py",
|
||||
"libs/test_flask_utils.py",
|
||||
"libs/test_helper.py",
|
||||
"libs/test_json_in_md_parser.py",
|
||||
"libs/test_jwt_imports.py",
|
||||
"libs/test_login.py",
|
||||
"libs/test_oauth_base.py",
|
||||
"libs/test_oauth_bearer.py",
|
||||
"libs/test_oauth_bearer_layer0_cache.py",
|
||||
"libs/test_oauth_bearer_rate_limit_ordering.py",
|
||||
"libs/test_oauth_bearer_require_scope.py",
|
||||
"libs/test_oauth_clients.py",
|
||||
"libs/test_orjson.py",
|
||||
"libs/test_pagination.py",
|
||||
"libs/test_pandas.py",
|
||||
"libs/test_passport.py",
|
||||
"libs/test_password.py",
|
||||
"libs/test_rate_limit_bearer.py",
|
||||
"libs/test_rate_limiter.py",
|
||||
"libs/test_rsa.py",
|
||||
"libs/test_schedule_utils_enhanced.py",
|
||||
"libs/test_sendgrid_client.py",
|
||||
"libs/test_smtp_client.py",
|
||||
"libs/test_time_parser.py",
|
||||
"libs/test_token.py",
|
||||
"libs/test_token_manager.py",
|
||||
"libs/test_uuid_utils.py",
|
||||
"libs/test_workspace_member_helper.py",
|
||||
"libs/test_workspace_permission.py",
|
||||
"libs/test_yarl.py",
|
||||
"migrations/test_agent_drive_skill_metadata_refactor.py",
|
||||
"migrations/test_uuidv7_pg18_migration.py",
|
||||
"models/test_account_models.py",
|
||||
"models/test_agent.py",
|
||||
"models/test_app_models.py",
|
||||
"models/test_base.py",
|
||||
"models/test_conversation_variable.py",
|
||||
"models/test_dataset_models.py",
|
||||
"models/test_end_user_type.py",
|
||||
"models/test_enums_creator_user_role.py",
|
||||
"models/test_model.py",
|
||||
"models/test_plugin_entities.py",
|
||||
"models/test_provider_models.py",
|
||||
"models/test_tool_models.py",
|
||||
"models/test_types.py",
|
||||
"models/test_workflow.py",
|
||||
"models/test_workflow_models.py",
|
||||
"models/test_workflow_node_execution_offload.py",
|
||||
"oss/__mock/aliyun_oss.py",
|
||||
"oss/__mock/baidu_obs.py",
|
||||
"oss/__mock/base.py",
|
||||
"oss/__mock/local.py",
|
||||
"oss/__mock/tencent_cos.py",
|
||||
"oss/__mock/volcengine_tos.py",
|
||||
"oss/aliyun_oss/aliyun_oss/test_aliyun_oss.py",
|
||||
"oss/baidu_obs/test_baidu_obs.py",
|
||||
"oss/opendal/test_opendal.py",
|
||||
"oss/tencent_cos/test_tencent_cos.py",
|
||||
"oss/volcengine_tos/test_volcengine_tos.py",
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py",
|
||||
"services/agent/test_agent_composer_entities.py",
|
||||
"services/agent/test_agent_dsl_service.py",
|
||||
"services/agent/test_agent_observability_service.py",
|
||||
"services/agent/test_agent_services.py",
|
||||
"services/agent/test_composer_candidates.py",
|
||||
"services/agent/test_composer_mention_validation.py",
|
||||
"services/agent/test_prompt_mentions.py",
|
||||
"services/agent/test_skill_package_service.py",
|
||||
"services/agent/test_skill_standardize_service.py",
|
||||
"services/agent/test_skill_tool_inference_service.py",
|
||||
"services/agent/test_workflow_publish_service.py",
|
||||
"services/auth/test_api_key_auth_base.py",
|
||||
"services/auth/test_api_key_auth_factory.py",
|
||||
"services/auth/test_api_key_auth_service.py",
|
||||
"services/auth/test_auth_type.py",
|
||||
"services/auth/test_firecrawl_auth.py",
|
||||
"services/auth/test_jina_auth.py",
|
||||
"services/auth/test_jina_auth_standalone_module.py",
|
||||
"services/auth/test_watercrawl_auth.py",
|
||||
"services/controller_api.py",
|
||||
"services/data_migration/test_dependency_discovery_service.py",
|
||||
"services/data_migration/test_entities.py",
|
||||
"services/data_migration/test_export_service.py",
|
||||
"services/data_migration/test_import_service.py",
|
||||
"services/data_migration/test_package_service.py",
|
||||
"services/data_migration/test_report_service.py",
|
||||
"services/dataset_service_test_helpers.py",
|
||||
"services/document_service_validation.py",
|
||||
"services/enterprise/test_account_deletion_sync.py",
|
||||
"services/enterprise/test_app_permitted_service.py",
|
||||
"services/enterprise/test_enterprise_service.py",
|
||||
"services/enterprise/test_plugin_manager_service.py",
|
||||
"services/enterprise/test_rbac_service.py",
|
||||
"services/enterprise/test_traceparent_propagation.py",
|
||||
"services/hit_service.py",
|
||||
"services/openapi/test_mint_policy.py",
|
||||
"services/plugin/conftest.py",
|
||||
"services/plugin/test_dependencies_analysis.py",
|
||||
"services/plugin/test_endpoint_service.py",
|
||||
"services/plugin/test_oauth_service.py",
|
||||
"services/plugin/test_plugin_migration.py",
|
||||
"services/plugin/test_plugin_parameter_service.py",
|
||||
"services/plugin/test_plugin_service.py",
|
||||
"services/plugin/test_plugin_service_installation.py",
|
||||
"services/rag_pipeline/pipeline_template/test_built_in_retrieval.py",
|
||||
"services/rag_pipeline/pipeline_template/test_pipeline_template_base.py",
|
||||
"services/rag_pipeline/test_pipeline_generate_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_dsl_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_task_proxy.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_transform_service.py",
|
||||
"services/recommend_app/test_buildin_retrieval.py",
|
||||
"services/recommend_app/test_category_order.py",
|
||||
"services/recommend_app/test_recommend_app_factory.py",
|
||||
"services/recommend_app/test_recommend_app_type.py",
|
||||
"services/recommend_app/test_remote_retrieval.py",
|
||||
"services/retention/test_messages_clean_policy.py",
|
||||
"services/retention/workflow_run/test_archive_download_preparation.py",
|
||||
"services/retention/workflow_run/test_archive_download_task_cache.py",
|
||||
"services/retention/workflow_run/test_archive_log_service.py",
|
||||
"services/retention/workflow_run/test_bundle_archive_maintenance.py",
|
||||
"services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py",
|
||||
"services/retention/workflow_run/test_restore_archived_workflow_run.py",
|
||||
"services/test_account_service.py",
|
||||
"services/test_agent_app_feature_service.py",
|
||||
"services/test_agent_app_sandbox_service.py",
|
||||
"services/test_agent_config_service.py",
|
||||
"services/test_agent_drive_service.py",
|
||||
"services/test_agent_file_request_service.py",
|
||||
"services/test_annotation_service.py",
|
||||
"services/test_api_token_service.py",
|
||||
"services/test_app_dsl_service.py",
|
||||
"services/test_app_generate_service.py",
|
||||
"services/test_app_generate_service_streaming_integration.py",
|
||||
"services/test_app_model_config_service.py",
|
||||
"services/test_app_service.py",
|
||||
"services/test_app_task_service.py",
|
||||
"services/test_archive_workflow_run_logs.py",
|
||||
"services/test_async_workflow_service.py",
|
||||
"services/test_audio_service.py",
|
||||
"services/test_billing_service.py",
|
||||
"services/test_clear_free_plan_expired_workflow_run_logs.py",
|
||||
"services/test_clear_free_plan_tenant_expired_logs.py",
|
||||
"services/test_code_based_extension_service.py",
|
||||
"services/test_conversation_service.py",
|
||||
"services/test_credential_permission_service.py",
|
||||
"services/test_credit_pool_service.py",
|
||||
"services/test_dataset_service_dataset.py",
|
||||
"services/test_dataset_service_document.py",
|
||||
"services/test_dataset_service_lock_not_owned.py",
|
||||
"services/test_dataset_service_segment.py",
|
||||
"services/test_datasource_provider_service.py",
|
||||
"services/test_document_indexing_task_proxy.py",
|
||||
"services/test_duplicate_document_indexing_task_proxy.py",
|
||||
"services/test_export_app_messages.py",
|
||||
"services/test_external_dataset_service.py",
|
||||
"services/test_feature_service_app_dsl_version.py",
|
||||
"services/test_feature_service_enable_app_deploy.py",
|
||||
"services/test_feature_service_human_input_email_delivery.py",
|
||||
"services/test_feature_service_learn_app.py",
|
||||
"services/test_feature_service_licensed_seats.py",
|
||||
"services/test_feature_service_trial_models.py",
|
||||
"services/test_feature_service_vector_space.py",
|
||||
"services/test_feature_service_webapp_public_access.py",
|
||||
"services/test_feedback_service.py",
|
||||
"services/test_file_service.py",
|
||||
"services/test_human_input_delivery_test_service.py",
|
||||
"services/test_human_input_file_upload_service.py",
|
||||
"services/test_human_input_service.py",
|
||||
"services/test_knowledge_retrieval_inner_service.py",
|
||||
"services/test_knowledge_service.py",
|
||||
"services/test_message_service.py",
|
||||
"services/test_messages_clean_service.py",
|
||||
"services/test_metadata_nullable_bug.py",
|
||||
"services/test_metadata_service_session_boundary.py",
|
||||
"services/test_model_load_balancing_service.py",
|
||||
"services/test_model_provider_service.py",
|
||||
"services/test_model_provider_service_sanitization.py",
|
||||
"services/test_oauth_device_flow.py",
|
||||
"services/test_oauth_server_service.py",
|
||||
"services/test_operation_service.py",
|
||||
"services/test_rag_pipeline_task_proxy.py",
|
||||
"services/test_schedule_service.py",
|
||||
"services/test_snippet_dsl_service.py",
|
||||
"services/test_snippet_generate_service.py",
|
||||
"services/test_snippet_service.py",
|
||||
"services/test_step_by_step_tour_service.py",
|
||||
"services/test_summary_index_service.py",
|
||||
"services/test_telemetry_service.py",
|
||||
"services/test_trigger_provider_service.py",
|
||||
"services/test_variable_truncator.py",
|
||||
"services/test_vector_service.py",
|
||||
"services/test_webhook_service.py",
|
||||
"services/test_webhook_service_additional.py",
|
||||
"services/test_website_service.py",
|
||||
"services/test_workflow_app_service_metadata.py",
|
||||
"services/test_workflow_collaboration_service.py",
|
||||
"services/test_workflow_comment_service.py",
|
||||
"services/test_workflow_generator_service.py",
|
||||
"services/test_workflow_node_execution_trace_service.py",
|
||||
"services/test_workflow_run_service.py",
|
||||
"services/test_workflow_run_service_pause.py",
|
||||
"services/test_workflow_service.py",
|
||||
"services/tools/test_api_tools_manage_service.py",
|
||||
"services/tools/test_builtin_tools_manage_service.py",
|
||||
"services/tools/test_mcp_tools_transform.py",
|
||||
"services/tools/test_tool_labels_service.py",
|
||||
"services/tools/test_tools_manage_service.py",
|
||||
"services/tools/test_tools_transform_service.py",
|
||||
"services/workflow/test_draft_var_loader_simple.py",
|
||||
"services/workflow/test_inspector_events.py",
|
||||
"services/workflow/test_node_output_inspector_service.py",
|
||||
"services/workflow/test_queue_dispatcher.py",
|
||||
"services/workflow/test_scheduler.py",
|
||||
"services/workflow/test_workflow_converter_additional.py",
|
||||
"services/workflow/test_workflow_draft_variable_service.py",
|
||||
"services/workflow/test_workflow_event_snapshot_service.py",
|
||||
"services/workflow/test_workflow_event_snapshot_service_additional.py",
|
||||
"services/workflow/test_workflow_human_input_delivery.py",
|
||||
"services/workflow/test_workflow_restore.py",
|
||||
"tasks/test_agent_backend_session_cleanup_task.py",
|
||||
"tasks/test_async_workflow_tasks.py",
|
||||
"tasks/test_batch_clean_document_task.py",
|
||||
"tasks/test_clean_dataset_task.py",
|
||||
"tasks/test_clean_document_task.py",
|
||||
"tasks/test_community_telemetry_task.py",
|
||||
"tasks/test_dataset_indexing_task.py",
|
||||
"tasks/test_document_indexing_sync_task.py",
|
||||
"tasks/test_document_indexing_update_task.py",
|
||||
"tasks/test_duplicate_document_indexing_task.py",
|
||||
"tasks/test_enable_segment_index_tasks.py",
|
||||
"tasks/test_enterprise_telemetry_task.py",
|
||||
"tasks/test_human_input_timeout_tasks.py",
|
||||
"tasks/test_initialize_created_app_rbac_access_task.py",
|
||||
"tasks/test_install_default_plugins_task.py",
|
||||
"tasks/test_mail_human_input_delivery_task.py",
|
||||
"tasks/test_mail_send_task.py",
|
||||
"tasks/test_ops_trace_task.py",
|
||||
"tasks/test_process_tenant_plugin_autoupgrade_check_task.py",
|
||||
"tasks/test_refresh_billing_vector_space_task.py",
|
||||
"tasks/test_remove_app_and_related_data_task.py",
|
||||
"tasks/test_resume_agent_app_task.py",
|
||||
"tasks/test_summary_queue_isolation.py",
|
||||
"tasks/test_trigger_processing_tasks.py",
|
||||
"tasks/test_workflow_execute_task.py",
|
||||
"test_app_factory.py",
|
||||
"test_makefile_backend_tests.py",
|
||||
"test_pytest_dify.py",
|
||||
"tools/test_api_tool.py",
|
||||
"tools/test_mcp_tool.py",
|
||||
"utils/encryption/test_system_encryption.py",
|
||||
"utils/http_parser/test_oauth_convert_request_to_raw_data.py",
|
||||
"utils/position_helper/test_position_helper.py",
|
||||
"utils/structured_output_parser/test_structured_output_parser.py",
|
||||
"utils/test_text_processing.py",
|
||||
"utils/yaml/test_yaml_utils.py",
|
||||
]
|
||||
|
||||
[errors]
|
||||
missing-override-decorator = "error"
|
||||
redundant-cast = true
|
||||
unannotated-return = true
|
||||
unnecessary-type-conversion = true
|
||||
unused-ignore = true
|
||||
@@ -1,89 +1,105 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import (
|
||||
TenantPluginAutoUpgradeCategory,
|
||||
TenantPluginAutoUpgradeMode,
|
||||
TenantPluginAutoUpgradeStrategy,
|
||||
TenantPluginAutoUpgradeStrategySetting,
|
||||
)
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
MODULE = "services.plugin.plugin_auto_upgrade_service"
|
||||
PLUGIN_CATEGORY = TenantPluginAutoUpgradeCategory.TOOL
|
||||
STRATEGY_MODELS = (TenantPluginAutoUpgradeStrategy,)
|
||||
|
||||
|
||||
def _patched_session():
|
||||
"""Return a mock SQLAlchemy session for service calls."""
|
||||
session = MagicMock()
|
||||
return session
|
||||
def _strategy(
|
||||
tenant_id: str,
|
||||
*,
|
||||
category: TenantPluginAutoUpgradeCategory = PLUGIN_CATEGORY,
|
||||
setting: TenantPluginAutoUpgradeStrategySetting = TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
mode: TenantPluginAutoUpgradeMode = TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude: list[str] | None = None,
|
||||
include: list[str] | None = None,
|
||||
upgrade_time: int = 0,
|
||||
) -> TenantPluginAutoUpgradeStrategy:
|
||||
return TenantPluginAutoUpgradeStrategy(
|
||||
tenant_id=tenant_id,
|
||||
category=category,
|
||||
strategy_setting=setting,
|
||||
upgrade_time_of_day=upgrade_time,
|
||||
upgrade_mode=mode,
|
||||
exclude_plugins=exclude or [],
|
||||
include_plugins=include or [],
|
||||
)
|
||||
|
||||
|
||||
class TestGetStrategy:
|
||||
def test_returns_strategy_when_found(self):
|
||||
session = _patched_session()
|
||||
strategy = MagicMock()
|
||||
session.scalar.return_value = strategy
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_returns_strategy_when_found(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
strategy = _strategy(tenant_id)
|
||||
sqlite_session.add(strategy)
|
||||
sqlite_session.commit()
|
||||
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session)
|
||||
result = PluginAutoUpgradeService.get_strategy(tenant_id, PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
assert result is strategy
|
||||
|
||||
def test_returns_none_when_not_found(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is None
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_returns_none_when_not_found(self, sqlite_session: Session) -> None:
|
||||
assert PluginAutoUpgradeService.get_strategy(str(uuid4()), PLUGIN_CATEGORY, session=sqlite_session) is None
|
||||
|
||||
|
||||
class TestChangeStrategy:
|
||||
def test_creates_new_strategy(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.return_value = MagicMock()
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
"t1",
|
||||
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
3,
|
||||
TenantPluginAutoUpgradeMode.ALL,
|
||||
[],
|
||||
[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
session.add.assert_called_once()
|
||||
|
||||
def test_updates_existing_strategy(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
session.scalar.return_value = existing
|
||||
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_new_strategy(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
"t1",
|
||||
tenant_id,
|
||||
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
3,
|
||||
TenantPluginAutoUpgradeMode.ALL,
|
||||
[],
|
||||
[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy))
|
||||
assert result is True
|
||||
assert strategy is not None
|
||||
assert strategy.tenant_id == tenant_id
|
||||
assert strategy.upgrade_time_of_day == 3
|
||||
assert strategy.upgrade_mode == TenantPluginAutoUpgradeMode.ALL
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_updates_existing_strategy(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id)
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
tenant_id,
|
||||
TenantPluginAutoUpgradeStrategySetting.LATEST,
|
||||
5,
|
||||
TenantPluginAutoUpgradeMode.PARTIAL,
|
||||
["p1"],
|
||||
["p2"],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
assert result is True
|
||||
assert existing.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
|
||||
assert existing.upgrade_time_of_day == 5
|
||||
@@ -93,157 +109,115 @@ class TestChangeStrategy:
|
||||
|
||||
|
||||
class TestExcludePlugin:
|
||||
def test_creates_default_strategy_when_none_exists(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_default_strategy_when_none_exists(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.select"),
|
||||
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy"),
|
||||
):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin(
|
||||
"t1",
|
||||
"plugin-1",
|
||||
PLUGIN_CATEGORY,
|
||||
session=session,
|
||||
)
|
||||
result = PluginAutoUpgradeService.exclude_plugin(tenant_id, "plugin-1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy))
|
||||
assert result is True
|
||||
session.add.assert_called_once()
|
||||
assert strategy is not None
|
||||
assert strategy.exclude_plugins == ["plugin-1"]
|
||||
|
||||
def test_appends_to_exclude_list_in_exclude_mode(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
existing.exclude_plugins = ["p-existing"]
|
||||
session.scalar.return_value = existing
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_appends_to_exclude_list_in_exclude_mode(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, exclude=["p-existing"])
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p-new", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.exclude_plugins == ["p-existing", "p-new"]
|
||||
|
||||
def test_removes_from_include_list_in_partial_mode(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.PARTIAL
|
||||
existing.include_plugins = ["p1", "p2"]
|
||||
session.scalar.return_value = existing
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_removes_from_include_list_in_partial_mode(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.PARTIAL, include=["p1", "p2"])
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.include_plugins == ["p2"]
|
||||
|
||||
def test_switches_to_exclude_mode_from_all(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.ALL
|
||||
session.scalar.return_value = existing
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_switches_to_exclude_mode_from_all(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.ALL)
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
def test_no_duplicate_in_exclude_list(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
existing.exclude_plugins = ["p1"]
|
||||
session.scalar.return_value = existing
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_no_duplicate_in_exclude_list(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, exclude=["p1"])
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
|
||||
class TestBackfillStrategyCategories:
|
||||
def test_creates_default_missing_categories_without_fetching_daemon(self):
|
||||
session = _patched_session()
|
||||
tool_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeCategory.TOOL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude_plugins=[],
|
||||
include_plugins=[],
|
||||
)
|
||||
session.scalars.return_value.all.return_value = [tool_strategy]
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_default_missing_categories_without_fetching_daemon(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
tool_strategy = _strategy(tenant_id)
|
||||
sqlite_session.add(tool_strategy)
|
||||
sqlite_session.commit()
|
||||
installer = MagicMock()
|
||||
|
||||
with patch(f"{MODULE}.PluginInstaller", return_value=installer):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session)
|
||||
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
|
||||
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
|
||||
|
||||
strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all())
|
||||
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 1
|
||||
assert result.normalized is False
|
||||
installer.list_plugins.assert_not_called()
|
||||
assert len(strategies) == len(TenantPluginAutoUpgradeCategory)
|
||||
assert tool_strategy.upgrade_time_of_day == expected_time
|
||||
created_strategies = [call.args[0] for call in session.add.call_args_list]
|
||||
model_strategy = next(
|
||||
strategy for strategy in created_strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
|
||||
strategy for strategy in strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
|
||||
)
|
||||
assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
|
||||
assert model_strategy.upgrade_time_of_day == expected_time
|
||||
|
||||
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
|
||||
|
||||
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self) -> None:
|
||||
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day(str(uuid4()))
|
||||
assert default_time % (15 * 60) == 0
|
||||
assert 0 <= default_time < 24 * 60 * 60
|
||||
|
||||
def test_creates_missing_categories_and_splits_known_plugins(self, caplog: pytest.LogCaptureFixture):
|
||||
session = _patched_session()
|
||||
tool_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeCategory.TOOL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include_plugins=["model-plugin", "tool-plugin"],
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_missing_categories_and_splits_known_plugins(
|
||||
self, sqlite_session: Session, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
tool_strategy = _strategy(
|
||||
tenant_id,
|
||||
exclude=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include=["model-plugin", "tool-plugin"],
|
||||
)
|
||||
model_strategy = SimpleNamespace(
|
||||
model_strategy = _strategy(
|
||||
tenant_id,
|
||||
category=TenantPluginAutoUpgradeCategory.MODEL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include_plugins=["model-plugin", "tool-plugin"],
|
||||
exclude=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include=["model-plugin", "tool-plugin"],
|
||||
)
|
||||
session.scalars.return_value.all.return_value = [tool_strategy, model_strategy]
|
||||
|
||||
sqlite_session.add_all([tool_strategy, model_strategy])
|
||||
sqlite_session.commit()
|
||||
installed_plugins = [
|
||||
SimpleNamespace(
|
||||
plugin_id="tool-plugin",
|
||||
@@ -261,18 +235,17 @@ class TestBackfillStrategyCategories:
|
||||
patch(f"{MODULE}.PluginInstaller", return_value=installer),
|
||||
caplog.at_level(logging.WARNING, logger=MODULE),
|
||||
):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session)
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
|
||||
|
||||
strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all())
|
||||
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 2
|
||||
assert result.normalized is True
|
||||
assert session.add.call_count == len(TenantPluginAutoUpgradeCategory) - 2
|
||||
assert len(strategies) == len(TenantPluginAutoUpgradeCategory)
|
||||
assert tool_strategy.exclude_plugins == ["tool-plugin"]
|
||||
assert tool_strategy.include_plugins == ["tool-plugin"]
|
||||
assert model_strategy.exclude_plugins == ["model-plugin"]
|
||||
assert model_strategy.include_plugins == ["model-plugin"]
|
||||
assert (
|
||||
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
|
||||
"tenant_id=t1, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
|
||||
f"tenant_id={tenant_id}, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
|
||||
)
|
||||
|
||||
@@ -7,28 +7,19 @@ import pytest
|
||||
import zstandard
|
||||
from pydantic import TypeAdapter
|
||||
from redis import RedisError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCacheType
|
||||
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
|
||||
from models.provider import Provider, ProviderCredential, ProviderType, TenantPreferredModelProvider
|
||||
|
||||
MODULE = "core.plugin.plugin_service"
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.execute = Mock()
|
||||
self.scalars = Mock(return_value=SimpleNamespace(all=Mock(return_value=[])))
|
||||
|
||||
def __enter__(self) -> "_FakeSession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
return None
|
||||
|
||||
def begin(self) -> "_FakeSession":
|
||||
return self
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
USER_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
def _build_provider_entity(provider: str = "openai") -> ProviderEntity:
|
||||
@@ -1166,19 +1157,72 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
assert result is True
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(self) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session", [(Provider, ProviderCredential, TenantPreferredModelProvider)], indirect=True
|
||||
)
|
||||
def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(
|
||||
self, sqlite_session: Session
|
||||
) -> None:
|
||||
"""Successful uninstall with plugin metadata also invalidates the mutated tenant provider cache."""
|
||||
plugin_id = "langgenius/openai"
|
||||
provider_name = f"{plugin_id}/openai"
|
||||
plugin = SimpleNamespace(
|
||||
installation_id="installation-1",
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_id=plugin_id,
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||
)
|
||||
session = _FakeSession()
|
||||
credential = ProviderCredential(
|
||||
tenant_id=TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
credential_name="Target credential",
|
||||
encrypted_config="{}",
|
||||
user_id=USER_ID,
|
||||
)
|
||||
other_credential = ProviderCredential(
|
||||
tenant_id=OTHER_TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
credential_name="Other credential",
|
||||
encrypted_config="{}",
|
||||
user_id=USER_ID,
|
||||
)
|
||||
sqlite_session.add_all([credential, other_credential])
|
||||
sqlite_session.flush()
|
||||
provider = Provider(
|
||||
tenant_id=TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
provider_type=ProviderType.CUSTOM,
|
||||
credential_id=credential.id,
|
||||
)
|
||||
other_provider = Provider(
|
||||
tenant_id=OTHER_TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
provider_type=ProviderType.CUSTOM,
|
||||
credential_id=other_credential.id,
|
||||
)
|
||||
preferred_provider = TenantPreferredModelProvider(
|
||||
tenant_id=TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
)
|
||||
other_preferred_provider = TenantPreferredModelProvider(
|
||||
tenant_id=OTHER_TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
)
|
||||
sqlite_session.add_all([provider, other_provider, preferred_provider, other_preferred_provider])
|
||||
sqlite_session.commit()
|
||||
credential_id = credential.id
|
||||
other_credential_id = other_credential.id
|
||||
provider_id = provider.id
|
||||
other_provider_id = other_provider.id
|
||||
preferred_provider_id = preferred_provider.id
|
||||
other_preferred_provider_id = other_preferred_provider.id
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.db", SimpleNamespace(engine=object())),
|
||||
patch(f"{MODULE}.db", SimpleNamespace(engine=sqlite_session.get_bind())),
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.Session", return_value=session),
|
||||
patch(f"{MODULE}.ProviderCredentialsCache") as credentials_cache,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
@@ -1188,8 +1232,26 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.uninstall("tenant-1", "installation-1")
|
||||
result = PluginService.uninstall(TENANT_ID, "installation-1")
|
||||
|
||||
assert result is True
|
||||
installer.uninstall.assert_called_once_with("tenant-1", "installation-1")
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
installer.uninstall.assert_called_once_with(TENANT_ID, "installation-1")
|
||||
invalidate_cache.assert_called_once_with(TENANT_ID)
|
||||
credentials_cache.assert_called_once_with(
|
||||
tenant_id=TENANT_ID,
|
||||
identity_id=provider_id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
credentials_cache.return_value.delete.assert_called_once_with()
|
||||
|
||||
sqlite_session.expunge_all()
|
||||
assert sqlite_session.get(ProviderCredential, credential_id) is None
|
||||
persisted_provider = sqlite_session.get(Provider, provider_id)
|
||||
assert persisted_provider is not None
|
||||
assert persisted_provider.credential_id is None
|
||||
assert sqlite_session.get(TenantPreferredModelProvider, preferred_provider_id) is None
|
||||
assert sqlite_session.get(ProviderCredential, other_credential_id) is not None
|
||||
persisted_other_provider = sqlite_session.get(Provider, other_provider_id)
|
||||
assert persisted_other_provider is not None
|
||||
assert persisted_other_provider.credential_id == other_credential_id
|
||||
assert sqlite_session.get(TenantPreferredModelProvider, other_preferred_provider_id) is not None
|
||||
|
||||
@@ -8,6 +8,7 @@ verification, marketplace upgrade flows, and uninstall with credential cleanup.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -19,7 +20,6 @@ from sqlalchemy.orm import Session
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginVerification
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from models import ProviderType
|
||||
from models.engine import db
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
@@ -27,20 +27,16 @@ from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import (
|
||||
PluginInstallationPermissionModel,
|
||||
PluginInstallationScope,
|
||||
SystemFeatureModel,
|
||||
)
|
||||
|
||||
|
||||
def _make_features(
|
||||
def _make_permission(
|
||||
restrict_to_marketplace: bool = False,
|
||||
scope: PluginInstallationScope = PluginInstallationScope.ALL,
|
||||
) -> SystemFeatureModel:
|
||||
return SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
plugin_installation_permission=PluginInstallationPermissionModel(
|
||||
restrict_to_marketplace_only=restrict_to_marketplace,
|
||||
plugin_installation_scope=scope,
|
||||
),
|
||||
) -> PluginInstallationPermissionModel:
|
||||
return PluginInstallationPermissionModel(
|
||||
restrict_to_marketplace_only=restrict_to_marketplace,
|
||||
plugin_installation_scope=scope,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,22 +115,31 @@ class TestFetchLatestPluginVersion:
|
||||
class TestCheckMarketplaceOnlyPermission:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_raises_when_restricted(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=True)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=True)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_marketplace_only_permission()
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_passes_when_not_restricted(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=False)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=False)
|
||||
|
||||
PluginService._check_marketplace_only_permission() # should not raise
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_raises_when_scope_denies_all(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError, match="not allowed"):
|
||||
PluginService._check_marketplace_only_permission()
|
||||
|
||||
|
||||
class TestCheckPluginInstallationScope:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_allows_langgenius(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_ONLY
|
||||
)
|
||||
verification = MagicMock()
|
||||
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
|
||||
|
||||
@@ -142,14 +147,16 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_rejects_third_party(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_ONLY
|
||||
)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_allows_partner(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
)
|
||||
verification = MagicMock()
|
||||
@@ -159,7 +166,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_rejects_none(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
)
|
||||
|
||||
@@ -168,7 +175,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_none_scope_always_raises(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.NONE)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
|
||||
verification = MagicMock()
|
||||
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
|
||||
|
||||
@@ -177,10 +184,19 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_all_scope_passes_any(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.ALL)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.ALL)
|
||||
|
||||
PluginService._check_plugin_installation_scope(None) # should not raise
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_unknown_scope_always_raises(self, mock_fs):
|
||||
permission = _make_permission()
|
||||
permission.plugin_installation_scope = cast(PluginInstallationScope, "unknown-scope")
|
||||
mock_fs.get_plugin_installation_permission.return_value = permission
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError, match="policy is invalid"):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
|
||||
class TestGetPluginIconUrl:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
@@ -248,7 +264,7 @@ class TestUpgradePluginWithMarketplace:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_skips_download_when_already_installed(self, mock_config, mock_installer_cls, mock_fs, mock_marketplace):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
installer.upgrade_plugin.return_value = MagicMock()
|
||||
@@ -264,7 +280,7 @@ class TestUpgradePluginWithMarketplace:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_installed(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
mock_download.return_value = b"pkg-bytes"
|
||||
@@ -283,7 +299,7 @@ class TestUpgradePluginWithGithub:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls: MagicMock, mock_fs: MagicMock):
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.upgrade_plugin.return_value = MagicMock()
|
||||
|
||||
@@ -298,7 +314,7 @@ class TestUploadPkg:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_runs_permission_and_scope_checks(self, mock_installer_cls: MagicMock, mock_fs: MagicMock):
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
upload_resp = MagicMock()
|
||||
upload_resp.verification = None
|
||||
mock_installer_cls.return_value.upload_pkg.return_value = upload_resp
|
||||
@@ -322,7 +338,7 @@ class TestInstallFromMarketplacePkg:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_cached(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
mock_download.return_value = b"pkg"
|
||||
@@ -344,7 +360,7 @@ class TestInstallFromMarketplacePkg:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls: MagicMock, mock_fs: MagicMock):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
decode_resp = MagicMock()
|
||||
|
||||
+213
-270
@@ -10,14 +10,19 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, create_autospec, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import Column, Integer, MetaData, String, Table
|
||||
from sqlalchemy import Column, Engine, Integer, MetaData, String, Table, delete, event, func, select
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from libs.archive_storage import ArchiveStorageNotConfiguredError
|
||||
from models.enums import CreatorUserRole
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
from models.workflow import (
|
||||
WorkflowAppLog,
|
||||
@@ -28,6 +33,7 @@ from models.workflow import (
|
||||
WorkflowPauseReason,
|
||||
WorkflowRun,
|
||||
)
|
||||
from services.retention.workflow_run import restore_archived_workflow_run as restore_module
|
||||
from services.retention.workflow_run.restore_archived_workflow_run import (
|
||||
SCHEMA_MAPPERS,
|
||||
TABLE_MODELS,
|
||||
@@ -36,24 +42,49 @@ from services.retention.workflow_run.restore_archived_workflow_run import (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Database:
|
||||
"""Explicit SQLite engine, caller session, and real service-owned session factory."""
|
||||
|
||||
engine: Engine
|
||||
session: Session
|
||||
session_maker: sessionmaker[Session]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
|
||||
WorkflowRun.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[WorkflowRun.__table__, WorkflowAppLog.__table__, WorkflowArchiveLog.__table__],
|
||||
)
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with session_maker() as session:
|
||||
database = Database(engine=sqlite_engine, session=session, session_maker=session_maker)
|
||||
monkeypatch.setattr(restore_module, "db", database)
|
||||
# Production constructs PostgreSQL's equivalent statement; SQLite's
|
||||
# dialect keeps the conflict behavior executable in these tests.
|
||||
monkeypatch.setattr(restore_module, "pg_insert", sqlite_insert)
|
||||
yield database
|
||||
|
||||
|
||||
class WorkflowRunRestoreTestDataFactory:
|
||||
"""
|
||||
Factory for creating test data and mock objects.
|
||||
Factory for creating persisted-model-compatible test data.
|
||||
|
||||
Provides reusable methods to create consistent mock objects for testing
|
||||
workflow run restore operations.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_run_mock(
|
||||
def create_workflow_run(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
) -> WorkflowRun:
|
||||
"""
|
||||
Create a mock WorkflowRun object.
|
||||
Create a concrete WorkflowRun object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -63,27 +94,44 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock WorkflowRun object with specified attributes
|
||||
WorkflowRun object with specified attributes
|
||||
"""
|
||||
run = create_autospec(WorkflowRun, instance=True)
|
||||
run.id = run_id
|
||||
run.tenant_id = tenant_id
|
||||
run.app_id = app_id
|
||||
run.created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
|
||||
for key, value in kwargs.items():
|
||||
setattr(run, key, value)
|
||||
attrs = {
|
||||
"id": run_id,
|
||||
"tenant_id": tenant_id,
|
||||
"app_id": app_id,
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"graph": None,
|
||||
"inputs": None,
|
||||
"status": "succeeded",
|
||||
"outputs": "{}",
|
||||
"error": None,
|
||||
"elapsed_time": 0,
|
||||
"total_tokens": 0,
|
||||
"total_steps": 0,
|
||||
"created_by_role": CreatorUserRole.ACCOUNT,
|
||||
"created_by": "user-123",
|
||||
"created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
|
||||
"finished_at": None,
|
||||
"exceptions_count": 0,
|
||||
}
|
||||
attrs.update(kwargs)
|
||||
run = WorkflowRun(**attrs)
|
||||
return run
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_archive_log_mock(
|
||||
def create_workflow_archive_log(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
) -> WorkflowArchiveLog:
|
||||
"""
|
||||
Create a mock WorkflowArchiveLog object.
|
||||
Create a concrete WorkflowArchiveLog object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -93,16 +141,32 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock WorkflowArchiveLog object with specified attributes
|
||||
WorkflowArchiveLog object with specified attributes
|
||||
"""
|
||||
archive_log = create_autospec(WorkflowArchiveLog, instance=True)
|
||||
archive_log.workflow_run_id = run_id
|
||||
archive_log.tenant_id = tenant_id
|
||||
archive_log.app_id = app_id
|
||||
archive_log.run_created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
|
||||
for key, value in kwargs.items():
|
||||
setattr(archive_log, key, value)
|
||||
return archive_log
|
||||
attrs = {
|
||||
"tenant_id": tenant_id,
|
||||
"app_id": app_id,
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": run_id,
|
||||
"created_by_role": CreatorUserRole.ACCOUNT,
|
||||
"created_by": "user-123",
|
||||
"log_id": None,
|
||||
"log_created_at": None,
|
||||
"log_created_from": None,
|
||||
"run_version": "1",
|
||||
"run_status": "succeeded",
|
||||
"run_triggered_from": "app-run",
|
||||
"run_error": None,
|
||||
"run_elapsed_time": 0,
|
||||
"run_total_tokens": 0,
|
||||
"run_total_steps": 0,
|
||||
"run_created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
|
||||
"run_finished_at": None,
|
||||
"run_exceptions_count": 0,
|
||||
"trigger_metadata": None,
|
||||
}
|
||||
attrs.update(kwargs)
|
||||
return WorkflowArchiveLog(**attrs)
|
||||
|
||||
@staticmethod
|
||||
def create_archive_zip_mock(
|
||||
@@ -137,7 +201,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -151,7 +215,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "app",
|
||||
"created_from": "service-api",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -161,7 +225,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "app",
|
||||
"created_from": "service-api",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -225,14 +289,10 @@ class TestGetWorkflowRunRepo:
|
||||
"""Tests for WorkflowRunRestore._get_workflow_run_repo method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.DifyAPIRepositoryFactory")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.db")
|
||||
def test_first_call_creates_repo(self, mock_db, mock_sessionmaker, mock_factory):
|
||||
def test_first_call_creates_repo(self, mock_factory, database: Database):
|
||||
"""First call should create and cache repository."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
mock_repo = Mock()
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
@@ -240,8 +300,9 @@ class TestGetWorkflowRunRepo:
|
||||
|
||||
assert result is mock_repo
|
||||
assert restore.workflow_run_repo is mock_repo
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(mock_session)
|
||||
session_maker = mock_factory.create_api_workflow_run_repository.call_args.args[0]
|
||||
assert isinstance(session_maker, sessionmaker)
|
||||
assert session_maker.kw["bind"] is database.engine
|
||||
|
||||
def test_cached_repo_returned(self):
|
||||
"""Subsequent calls should return cached repository."""
|
||||
@@ -492,47 +553,27 @@ class TestGetModelColumnInfo:
|
||||
class TestRestoreTableRecords:
|
||||
"""Tests for WorkflowRunRestore._restore_table_records method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.TABLE_MODELS")
|
||||
def test_unknown_table_returns_zero(self, mock_table_models, caplog: pytest.LogCaptureFixture):
|
||||
def test_unknown_table_returns_zero(self, database: Database, caplog: pytest.LogCaptureFixture):
|
||||
"""Should return 0 for unknown table."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_table_models.get.return_value = None
|
||||
|
||||
mock_session = Mock()
|
||||
records = [{"id": "test"}]
|
||||
caplog.set_level(logging.WARNING, logger="services.retention.workflow_run.restore_archived_workflow_run")
|
||||
|
||||
result = restore._restore_table_records(mock_session, "unknown_table", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(database.session, "unknown_table", records, schema_version="1.0")
|
||||
|
||||
assert result == 0
|
||||
assert "Unknown table: unknown_table" in caplog.messages
|
||||
|
||||
def test_empty_records_returns_zero(self):
|
||||
def test_empty_records_returns_zero(self, database: Database):
|
||||
"""Should return 0 for empty records list."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_session = Mock()
|
||||
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", [], schema_version="1.0")
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", [], schema_version="1.0")
|
||||
assert result == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
|
||||
def test_successful_restore(self, mock_cast, mock_pg_insert):
|
||||
def test_successful_restore(self, database: Database):
|
||||
"""Should successfully restore records."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
# Mock session and execution
|
||||
mock_session = Mock()
|
||||
mock_result = Mock()
|
||||
mock_result.rowcount = 2
|
||||
mock_session.execute.return_value = mock_result
|
||||
mock_cast.return_value = mock_result
|
||||
|
||||
# Mock insert statement
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_pg_insert.return_value = mock_stmt
|
||||
|
||||
records = [
|
||||
{
|
||||
"id": "test1",
|
||||
@@ -540,7 +581,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -552,7 +593,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -560,38 +601,20 @@ class TestRestoreTableRecords:
|
||||
},
|
||||
]
|
||||
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
|
||||
assert result == 2
|
||||
mock_session.execute.assert_called_once()
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 2
|
||||
assert restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0") == 0
|
||||
|
||||
def test_missing_required_columns_raises_error(self):
|
||||
def test_missing_required_columns_raises_error(self, database: Database):
|
||||
"""Should raise ValueError for missing required columns."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
# Use a dedicated mock model to isolate required-column validation behavior.
|
||||
mock_model = Mock()
|
||||
records = [{"id": "test"}]
|
||||
|
||||
# Mock a required column
|
||||
required_column = Mock()
|
||||
required_column.key = "required_field"
|
||||
required_column.nullable = False
|
||||
required_column.default = None
|
||||
required_column.server_default = None
|
||||
required_column.autoincrement = False
|
||||
required_column.type = Mock()
|
||||
|
||||
# Mock the __table__ attribute properly
|
||||
mock_table = Mock()
|
||||
mock_table.columns = [required_column]
|
||||
mock_model.__table__ = mock_table
|
||||
|
||||
records = [{"name": "test"}] # Missing required 'required_field'
|
||||
|
||||
with patch.dict(TABLE_MODELS, {"test_table": mock_model}):
|
||||
with pytest.raises(ValueError, match="Missing required columns for test_table"):
|
||||
restore._restore_table_records(mock_session, "test_table", records, schema_version="1.0")
|
||||
with pytest.raises(ValueError, match="Missing required columns for workflow_runs"):
|
||||
restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -603,38 +626,38 @@ class TestRestoreFromRun:
|
||||
"""Tests for WorkflowRunRestore._restore_from_run method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_archive_storage_not_configured(self, mock_get_storage):
|
||||
def test_archive_storage_not_configured(self, mock_get_storage, database: Database):
|
||||
"""Should handle ArchiveStorageNotConfiguredError."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("Storage not configured")
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is False
|
||||
assert "Storage not configured" in result.error
|
||||
assert result.elapsed_time > 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_archive_bundle_not_found(self, mock_get_storage):
|
||||
def test_archive_bundle_not_found(self, mock_get_storage, database: Database):
|
||||
"""Should handle FileNotFoundError when archive bundle is missing."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_storage = Mock()
|
||||
mock_storage.get_object.side_effect = FileNotFoundError("Bundle not found")
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is False
|
||||
assert "Archive bundle not found" in result.error
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_dry_run_mode(self, mock_get_storage):
|
||||
def test_dry_run_mode(self, mock_get_storage, database: Database):
|
||||
"""Should handle dry run mode correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -644,23 +667,16 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
# Create a proper mock session with context manager support
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts["workflow_runs"] == 1
|
||||
assert result.restored_counts["workflow_app_logs"] == 2
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
|
||||
def test_successful_restore(self, mock_cast, mock_pg_insert, mock_get_storage):
|
||||
def test_successful_restore(self, mock_get_storage, database: Database):
|
||||
"""Should successfully restore from archive."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -670,53 +686,57 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
# Mock session with context manager support
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
def session_maker():
|
||||
return mock_session
|
||||
|
||||
# Mock database execution to return integer counts
|
||||
mock_result_workflow_runs = Mock()
|
||||
mock_result_workflow_runs.rowcount = 1
|
||||
mock_result_app_logs = Mock()
|
||||
mock_result_app_logs.rowcount = 2
|
||||
|
||||
# Configure session.execute to return different results based on the table
|
||||
def mock_execute(stmt):
|
||||
if "workflow_runs" in str(stmt):
|
||||
return mock_result_workflow_runs
|
||||
else:
|
||||
return mock_result_app_logs
|
||||
|
||||
mock_session.execute.side_effect = mock_execute
|
||||
mock_cast.return_value = mock_result_workflow_runs
|
||||
|
||||
# Mock insert statement
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_pg_insert.return_value = mock_stmt
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
|
||||
# Mock repository methods
|
||||
with patch.object(restore, "_get_workflow_run_repo") as mock_get_repo:
|
||||
mock_repo = Mock()
|
||||
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
|
||||
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
|
||||
)
|
||||
mock_get_repo.return_value = mock_repo
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts["workflow_runs"] == 1
|
||||
assert result.restored_counts["workflow_app_logs"] >= 1 # Just check it's restored
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_repo.delete_archive_log_by_run_id.assert_called_once_with(mock_session, run.id)
|
||||
assert result.restored_counts["workflow_app_logs"] == 2
|
||||
database.session.expire_all()
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
|
||||
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 2
|
||||
assert database.session.scalar(select(func.count(WorkflowArchiveLog.id))) == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_invalid_archive_bundle(self, mock_get_storage):
|
||||
def test_insert_failure_rolls_back_all_tables(self, mock_get_storage, database: Database):
|
||||
"""A later table failure must roll back earlier restored rows."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_storage = Mock()
|
||||
mock_storage.get_object.return_value = WorkflowRunRestoreTestDataFactory.create_archive_zip_mock()
|
||||
mock_get_storage.return_value = mock_storage
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
def fail_app_log_insert(_connection, _cursor, statement, _parameters, _context, _executemany):
|
||||
if statement.startswith("INSERT INTO workflow_app_logs"):
|
||||
raise RuntimeError("forced app-log insert failure")
|
||||
|
||||
event.listen(database.engine, "before_cursor_execute", fail_app_log_insert)
|
||||
try:
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
finally:
|
||||
event.remove(database.engine, "before_cursor_execute", fail_app_log_insert)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "forced app-log insert failure"
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 0
|
||||
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_invalid_archive_bundle(self, mock_get_storage, database: Database):
|
||||
"""Should handle invalid archive bundle."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -725,22 +745,17 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = b"invalid zip data"
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
# Create proper mock session
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is False
|
||||
# The error message comes from zipfile.BadZipFile which says "File is not a zip file"
|
||||
assert "File is not a zip file" in result.error
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_workflow_archive_log_input(self, mock_get_storage):
|
||||
def test_workflow_archive_log_input(self, mock_get_storage, database: Database):
|
||||
"""Should handle WorkflowArchiveLog input correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -750,14 +765,11 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
|
||||
# Create proper mock session
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
result = restore._restore_from_run(archive_log, session_maker=lambda: mock_session)
|
||||
result = restore._restore_from_run(archive_log, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.run_id == archive_log.workflow_run_id
|
||||
@@ -772,39 +784,29 @@ class TestRestoreFromRun:
|
||||
class TestRestoreBatch:
|
||||
"""Tests for WorkflowRunRestore.restore_batch method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_empty_tenant_ids_returns_empty(self, mock_sessionmaker):
|
||||
def test_empty_tenant_ids_returns_empty(self, database: Database):
|
||||
"""Should return empty list when tenant_ids is empty list."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
result = restore.restore_batch(
|
||||
tenant_ids=[],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
result = restore.restore_batch(
|
||||
tenant_ids=[],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_successful_batch_restore(self, mock_executor):
|
||||
def test_successful_batch_restore(self, mock_executor, database: Database):
|
||||
"""Should successfully restore batch of workflow runs."""
|
||||
restore = WorkflowRunRestore(workers=2)
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
# Mock repository and archive logs
|
||||
mock_repo = Mock()
|
||||
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-2")
|
||||
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-2")
|
||||
database.session.add_all([archive_log1, archive_log2])
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log1, archive_log2]
|
||||
|
||||
# Mock restore results
|
||||
@@ -821,38 +823,25 @@ class TestRestoreBatch:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", side_effect=[result1, result2]):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].run_id == "run-1"
|
||||
assert results[1].run_id == "run-2"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_dry_run_batch_restore(self, mock_executor):
|
||||
def test_dry_run_batch_restore(self, mock_executor, database: Database):
|
||||
"""Should handle dry run mode for batch restore."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log]
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
|
||||
@@ -867,18 +856,11 @@ class TestRestoreBatch:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].success is True
|
||||
@@ -907,16 +889,14 @@ class TestRestoreByRunId:
|
||||
assert "not found" in result.error
|
||||
assert result.run_id == "nonexistent-run"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_successful_restore_by_id(self, mock_sessionmaker):
|
||||
def test_successful_restore_by_id(self, database: Database):
|
||||
"""Should successfully restore by run ID."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={})
|
||||
@@ -924,24 +904,19 @@ class TestRestoreByRunId:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_dry_run_restore_by_id(self, mock_sessionmaker):
|
||||
def test_dry_run_restore_by_id(self, database: Database):
|
||||
"""Should handle dry run mode for restore by ID."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
|
||||
@@ -949,10 +924,7 @@ class TestRestoreByRunId:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
@@ -1038,8 +1010,7 @@ class TestIntegration:
|
||||
"""Integration tests combining multiple components."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_full_restore_flow(self, mock_executor, mock_get_storage):
|
||||
def test_full_restore_flow(self, mock_get_storage, database: Database):
|
||||
"""Test complete restore flow with all components."""
|
||||
restore = WorkflowRunRestore(workers=1)
|
||||
|
||||
@@ -1059,7 +1030,7 @@ class TestIntegration:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -1072,48 +1043,20 @@ class TestIntegration:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.rowcount = 1
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
# Mock repository
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
# Mock ThreadPoolExecutor (not actually used in restore_by_run_id but needed for patch)
|
||||
mock_executor_instance = Mock()
|
||||
mock_executor_instance.__enter__ = Mock(return_value=mock_executor_instance)
|
||||
mock_executor_instance.__exit__ = Mock(return_value=None)
|
||||
mock_executor_instance.map = Mock(return_value=[])
|
||||
mock_executor.return_value = mock_executor_instance
|
||||
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
|
||||
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
|
||||
)
|
||||
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert") as mock_insert:
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_insert.return_value = mock_stmt
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.cast") as mock_cast:
|
||||
mock_cast.return_value = mock_result
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
result = restore.restore_by_run_id("run-123")
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
|
||||
result = restore.restore_by_run_id("run-123")
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts.get("workflow_runs") == 1
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
|
||||
|
||||
@@ -7,14 +7,16 @@ update_features persists those flags as a new app_model_config version without
|
||||
touching model / prompt / agent_mode.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode, AppModelConfig
|
||||
from services.agent_app_feature_service import AgentAppFeatureConfigService
|
||||
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
APP_ID = "22222222-2222-2222-2222-222222222222"
|
||||
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
class TestValidateFeatures:
|
||||
@@ -71,45 +73,47 @@ class TestValidateFeatures:
|
||||
AgentAppFeatureConfigService.validate_features(TENANT_ID, {"suggested_questions": "nope"})
|
||||
|
||||
|
||||
class _FakeWriteSession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[Any] = []
|
||||
self.flushed = 0
|
||||
self.committed = 0
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed += 1
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed += 1
|
||||
|
||||
|
||||
class TestUpdateFeatures:
|
||||
def test_persists_new_app_model_config_version(self):
|
||||
session = _FakeWriteSession()
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id=TENANT_ID, id="app-1", app_model_config_id=None, updated_by=None, updated_at=None
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, App, AppModelConfig)], indirect=True)
|
||||
def test_persists_new_app_model_config_version(self, sqlite_session: Session):
|
||||
app_model = App(
|
||||
id=APP_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
name="Agent App",
|
||||
description="",
|
||||
mode=AppMode.AGENT,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
account = SimpleNamespace(id="acct-1")
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
account.id = ACCOUNT_ID
|
||||
sqlite_session.add_all([account, app_model])
|
||||
sqlite_session.commit()
|
||||
|
||||
new_config = AgentAppFeatureConfigService.update_features(
|
||||
app_model=app_model, # type: ignore[arg-type]
|
||||
account=account, # type: ignore[arg-type]
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
config={"opening_statement": "Hi!", "suggested_questions_after_answer": {"enabled": True}},
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
)
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
# New row carries the features but no Soul-owned model/prompt/agent_mode.
|
||||
assert new_config.app_id == "app-1"
|
||||
assert new_config.app_id == APP_ID
|
||||
assert new_config.opening_statement == "Hi!"
|
||||
assert new_config.model is None
|
||||
assert new_config.agent_mode is None
|
||||
# App is repointed at the new version and the write is committed.
|
||||
assert app_model.app_model_config_id == new_config.id
|
||||
assert app_model.updated_by == "acct-1"
|
||||
assert new_config in session.added
|
||||
assert session.flushed == 1
|
||||
assert session.committed == 1
|
||||
assert app_model.updated_by == ACCOUNT_ID
|
||||
sqlite_session.expunge_all()
|
||||
persisted_config = sqlite_session.get(AppModelConfig, new_config.id)
|
||||
persisted_app = sqlite_session.get(App, APP_ID)
|
||||
assert persisted_config is not None
|
||||
assert persisted_config.opening_statement == "Hi!"
|
||||
assert persisted_config.model is None
|
||||
assert persisted_config.agent_mode is None
|
||||
assert persisted_app is not None
|
||||
assert persisted_app.app_model_config_id == new_config.id
|
||||
assert persisted_app.updated_by == ACCOUNT_ID
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from services import feature_service as feature_service_module
|
||||
from services.feature_service import FeatureService, PluginInstallationScope, SystemFeatureModel
|
||||
|
||||
|
||||
def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False)
|
||||
|
||||
permission = FeatureService.get_plugin_installation_permission()
|
||||
|
||||
assert permission.plugin_installation_scope is PluginInstallationScope.ALL
|
||||
assert permission.restrict_to_marketplace_only is False
|
||||
|
||||
|
||||
def test_get_plugin_installation_permission_parses_enterprise_policy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
feature_service_module.EnterpriseService,
|
||||
"get_info",
|
||||
staticmethod(
|
||||
lambda: {
|
||||
"PluginInstallationPermission": {
|
||||
"pluginInstallationScope": "official_only",
|
||||
"restrictToMarketplaceOnly": True,
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
permission = FeatureService.get_plugin_installation_permission()
|
||||
|
||||
assert permission.plugin_installation_scope is PluginInstallationScope.OFFICIAL_ONLY
|
||||
assert permission.restrict_to_marketplace_only is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_permission",
|
||||
[
|
||||
{
|
||||
"pluginInstallationScope": "unknown-scope",
|
||||
"restrictToMarketplaceOnly": False,
|
||||
},
|
||||
{
|
||||
"pluginInstallationScope": "all",
|
||||
"restrictToMarketplaceOnly": "false",
|
||||
},
|
||||
],
|
||||
ids=["unknown_scope", "non_boolean_marketplace_restriction"],
|
||||
)
|
||||
def test_invalid_enterprise_policy_denies_all_plugin_installations(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
invalid_permission: dict[str, object],
|
||||
) -> None:
|
||||
with caplog.at_level(logging.ERROR, logger="services.feature_service"):
|
||||
permission = FeatureService._resolve_plugin_installation_permission(
|
||||
{"PluginInstallationPermission": invalid_permission}
|
||||
)
|
||||
|
||||
assert permission.plugin_installation_scope is PluginInstallationScope.NONE
|
||||
assert permission.restrict_to_marketplace_only is True
|
||||
assert "denying all plugin installations" in caplog.text
|
||||
|
||||
|
||||
def test_system_features_exposes_only_validated_plugin_installation_policy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
feature_service_module.EnterpriseService,
|
||||
"get_info",
|
||||
staticmethod(
|
||||
lambda: {
|
||||
"PluginInstallationPermission": {
|
||||
"pluginInstallationScope": "unknown-scope",
|
||||
"restrictToMarketplaceOnly": False,
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE)
|
||||
|
||||
FeatureService._fulfill_params_from_enterprise(features)
|
||||
|
||||
assert features.plugin_installation_permission.plugin_installation_scope is PluginInstallationScope.NONE
|
||||
assert features.plugin_installation_permission.restrict_to_marketplace_only is True
|
||||
@@ -1,6 +1,8 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -9,6 +11,8 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.base import TypeBase
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import Account, EndUser, UploadFile
|
||||
from services.errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError
|
||||
@@ -17,31 +21,54 @@ from services.file_service import FileService
|
||||
|
||||
class TestFileService:
|
||||
@pytest.fixture
|
||||
def mock_db_session(self):
|
||||
session = MagicMock(spec=Session)
|
||||
# Mock context manager behavior
|
||||
session.__enter__.return_value = session
|
||||
return session
|
||||
def sqlite_session_maker(self, sqlite_engine: Engine) -> sessionmaker[Session]:
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[UploadFile.__tablename__]])
|
||||
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_maker(self, mock_db_session):
|
||||
maker = MagicMock(spec=sessionmaker)
|
||||
maker.return_value = mock_db_session
|
||||
return maker
|
||||
def db_session(self, sqlite_session_maker: sessionmaker[Session]) -> Iterator[Session]:
|
||||
with sqlite_session_maker() as session:
|
||||
yield session
|
||||
|
||||
@pytest.fixture
|
||||
def file_service(self, mock_session_maker):
|
||||
return FileService(session_factory=mock_session_maker)
|
||||
def file_service(self, sqlite_session_maker: sessionmaker[Session]) -> FileService:
|
||||
return FileService(session_factory=sqlite_session_maker)
|
||||
|
||||
def test_init_with_engine(self):
|
||||
engine = MagicMock(spec=Engine)
|
||||
service = FileService(session_factory=engine)
|
||||
@staticmethod
|
||||
def _persist_upload_file(
|
||||
session: Session,
|
||||
*,
|
||||
file_id: str = "file_id",
|
||||
tenant_id: str = "tenant_id",
|
||||
extension: str = "txt",
|
||||
mime_type: str = "text/plain",
|
||||
key: str = "key",
|
||||
) -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id,
|
||||
storage_type=StorageType.LOCAL,
|
||||
key=key,
|
||||
name=f"test.{extension}",
|
||||
size=10,
|
||||
extension=extension,
|
||||
mime_type=mime_type,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="user_id",
|
||||
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
used=False,
|
||||
)
|
||||
upload_file.id = file_id
|
||||
session.add(upload_file)
|
||||
session.commit()
|
||||
return upload_file
|
||||
|
||||
def test_init_with_engine(self, sqlite_engine: Engine):
|
||||
service = FileService(session_factory=sqlite_engine)
|
||||
assert isinstance(service._session_maker, sessionmaker)
|
||||
|
||||
def test_init_with_sessionmaker(self):
|
||||
maker = MagicMock(spec=sessionmaker)
|
||||
service = FileService(session_factory=maker)
|
||||
assert service._session_maker == maker
|
||||
def test_init_with_sessionmaker(self, sqlite_session_maker: sessionmaker[Session]):
|
||||
service = FileService(session_factory=sqlite_session_maker)
|
||||
assert service._session_maker == sqlite_session_maker
|
||||
|
||||
def test_init_invalid_factory(self):
|
||||
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
|
||||
@@ -52,11 +79,11 @@ class TestFileService:
|
||||
@patch("services.file_service.extract_tenant_id")
|
||||
@patch("services.file_service.file_helpers.get_signed_file_url")
|
||||
def test_upload_file_success(
|
||||
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, mock_db_session
|
||||
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, db_session: Session
|
||||
):
|
||||
# Setup
|
||||
mock_tenant_id.return_value = "tenant_id"
|
||||
mock_now.return_value = "2024-01-01"
|
||||
mock_now.return_value = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
mock_get_url.return_value = "http://signed-url"
|
||||
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -81,8 +108,9 @@ class TestFileService:
|
||||
assert result.source_url == "http://signed-url"
|
||||
|
||||
mock_storage.save.assert_called_once()
|
||||
mock_db_session.add.assert_called_once_with(result)
|
||||
mock_db_session.commit.assert_called_once()
|
||||
persisted = db_session.get(UploadFile, result.id)
|
||||
assert persisted is not None
|
||||
assert persisted.hash == result.hash
|
||||
|
||||
def test_upload_file_uses_explicit_resource_tenant(self, file_service: FileService):
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -109,7 +137,7 @@ class TestFileService:
|
||||
with pytest.raises(ValueError, match="Filename contains invalid characters"):
|
||||
file_service.upload_file(filename="invalid/file.txt", content=b"", mimetype="text/plain", user=MagicMock())
|
||||
|
||||
def test_upload_file_long_filename(self, file_service: FileService, mock_db_session):
|
||||
def test_upload_file_long_filename(self, file_service: FileService, db_session: Session):
|
||||
# Setup
|
||||
long_name = "a" * 210 + ".txt"
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -124,6 +152,7 @@ class TestFileService:
|
||||
result = file_service.upload_file(filename=long_name, content=b"test", mimetype="text/plain", user=user)
|
||||
assert len(result.name) <= 205 # 200 + . + extension
|
||||
assert result.name.endswith(".txt")
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_upload_file_blocked_extension(self, file_service):
|
||||
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe"):
|
||||
@@ -145,7 +174,7 @@ class TestFileService:
|
||||
with pytest.raises(FileTooLargeError):
|
||||
file_service.upload_file(filename="test.jpg", content=content, mimetype="image/jpeg", user=MagicMock())
|
||||
|
||||
def test_upload_file_end_user(self, file_service: FileService, mock_db_session):
|
||||
def test_upload_file_end_user(self, file_service: FileService, db_session: Session):
|
||||
user = MagicMock(spec=EndUser)
|
||||
user.id = "end_user_id"
|
||||
|
||||
@@ -157,6 +186,7 @@ class TestFileService:
|
||||
mock_tenant.return_value = "tenant"
|
||||
result = file_service.upload_file(filename="test.txt", content=b"test", mimetype="text/plain", user=user)
|
||||
assert result.created_by_role == CreatorUserRole.END_USER
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_is_file_size_within_limit(self):
|
||||
with (
|
||||
@@ -181,12 +211,8 @@ class TestFileService:
|
||||
assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True
|
||||
assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False
|
||||
|
||||
def test_get_file_base64_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "test_key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_base64_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, key="test_key")
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load_once.return_value = b"test content"
|
||||
@@ -198,16 +224,17 @@ class TestFileService:
|
||||
assert result == base64.b64encode(b"test content").decode()
|
||||
mock_storage.load_once.assert_called_once_with("test_key")
|
||||
|
||||
def test_get_file_base64_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_file_base64_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_base64("non_existent")
|
||||
|
||||
def test_get_file_presigned_url_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.key = "upload_files/tenant_id/icon.png"
|
||||
upload_file.mime_type = "image/png"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_presigned_url_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(
|
||||
db_session,
|
||||
extension="png",
|
||||
mime_type="image/png",
|
||||
key="upload_files/tenant_id/icon.png",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(dify_config, "FILES_ACCESS_TIMEOUT", 300),
|
||||
@@ -224,13 +251,11 @@ class TestFileService:
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
def test_get_file_presigned_url_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
|
||||
def test_get_file_presigned_url_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id")
|
||||
|
||||
def test_upload_text_success(self, file_service: FileService, mock_db_session):
|
||||
def test_upload_text_success(self, file_service: FileService, db_session: Session):
|
||||
# Setup
|
||||
text = "sample text"
|
||||
text_name = "test.txt"
|
||||
@@ -249,21 +274,17 @@ class TestFileService:
|
||||
assert result.used is True
|
||||
assert result.extension == "txt"
|
||||
mock_storage.save.assert_called_once()
|
||||
mock_db_session.add.assert_called_once()
|
||||
mock_db_session.commit.assert_called_once()
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_upload_text_long_name(self, file_service: FileService, mock_db_session):
|
||||
def test_upload_text_long_name(self, file_service: FileService, db_session: Session):
|
||||
long_name = "a" * 210
|
||||
with patch("services.file_service.storage"):
|
||||
result = file_service.upload_text("text", long_name, "user", "tenant")
|
||||
assert len(result.name) == 200
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_get_file_preview_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "pdf"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_preview_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="pdf", mime_type="application/pdf")
|
||||
|
||||
with patch("services.file_service.ExtractProcessor.load_from_upload_file") as mock_extract:
|
||||
mock_extract.return_value = "Extracted text content"
|
||||
@@ -274,27 +295,17 @@ class TestFileService:
|
||||
# Assert
|
||||
assert result == "Extracted text content"
|
||||
|
||||
def test_get_file_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_file_preview_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_preview("non_existent", "tenant_id")
|
||||
|
||||
def test_get_file_preview_unsupported_type(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "exe"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="exe", mime_type="application/octet-stream")
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_file_preview("file_id", "tenant_id")
|
||||
|
||||
def test_get_image_preview_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "jpg"
|
||||
upload_file.mime_type = "image/jpeg"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_image_preview_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="jpg", mime_type="image/jpeg")
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_image_signature") as mock_verify,
|
||||
@@ -316,28 +327,21 @@ class TestFileService:
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_image_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_image_preview_not_found(self, file_service: FileService):
|
||||
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
|
||||
mock_verify.return_value = True
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_image_preview_unsupported_type(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "txt"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_image_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
|
||||
mock_verify.return_value = True
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_file_generator_by_file_id_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_generator_by_file_id_success(self, file_service: FileService, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session)
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_file_signature") as mock_verify,
|
||||
@@ -348,7 +352,8 @@ class TestFileService:
|
||||
|
||||
gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
assert list(gen) == [b"chunk"]
|
||||
assert file == upload_file
|
||||
assert file.id == upload_file.id
|
||||
assert file.key == upload_file.key
|
||||
|
||||
def test_get_file_generator_by_file_id_invalid_sig(self, file_service):
|
||||
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
|
||||
@@ -356,20 +361,14 @@ class TestFileService:
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_file_generator_by_file_id_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_file_generator_by_file_id_not_found(self, file_service: FileService):
|
||||
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
|
||||
mock_verify.return_value = True
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_public_image_preview_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "png"
|
||||
upload_file.mime_type = "image/png"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_public_image_preview_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="png", mime_type="image/png")
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = b"image content"
|
||||
@@ -377,66 +376,56 @@ class TestFileService:
|
||||
assert gen == b"image content"
|
||||
assert mime == "image/png"
|
||||
|
||||
def test_get_public_image_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_public_image_preview_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_public_image_preview("file_id")
|
||||
|
||||
def test_get_public_image_preview_unsupported_type(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "txt"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_public_image_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_public_image_preview("file_id")
|
||||
|
||||
def test_get_file_content_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_content_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = b"hello world"
|
||||
result = file_service.get_file_content("file_id")
|
||||
assert result == "hello world"
|
||||
|
||||
def test_get_file_content_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_file_content_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_content("file_id")
|
||||
|
||||
def test_delete_file_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "key"
|
||||
# For session.scalar(select(...))
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_delete_file_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
file_service.delete_file("file_id")
|
||||
mock_storage.delete.assert_called_once_with("key")
|
||||
mock_db_session.delete.assert_called_once_with(upload_file)
|
||||
db_session.expire_all()
|
||||
assert db_session.get(UploadFile, "file_id") is None
|
||||
|
||||
def test_delete_file_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_delete_file_not_found(self, file_service: FileService):
|
||||
file_service.delete_file("file_id")
|
||||
# Should return without doing anything
|
||||
|
||||
def test_get_upload_files_by_ids_empty(self):
|
||||
session = MagicMock()
|
||||
result = FileService.get_upload_files_by_ids("tenant_id", [], session=session)
|
||||
def test_get_upload_files_by_ids_empty(self, db_session: Session):
|
||||
result = FileService.get_upload_files_by_ids("tenant_id", [], session=db_session)
|
||||
assert result == {}
|
||||
|
||||
def test_get_upload_files_by_ids(self):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
upload_file.tenant_id = "tenant_id"
|
||||
session = MagicMock()
|
||||
session.scalars().all.return_value = [upload_file]
|
||||
def test_get_upload_files_by_ids(self, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session, file_id="550e8400-e29b-41d4-a716-446655440000")
|
||||
self._persist_upload_file(
|
||||
db_session,
|
||||
file_id="550e8400-e29b-41d4-a716-446655440001",
|
||||
tenant_id="other-tenant",
|
||||
)
|
||||
|
||||
result = FileService.get_upload_files_by_ids(
|
||||
"tenant_id", ["550e8400-e29b-41d4-a716-446655440000"], session=session
|
||||
"tenant_id",
|
||||
["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001"],
|
||||
session=db_session,
|
||||
)
|
||||
assert result["550e8400-e29b-41d4-a716-446655440000"] == upload_file
|
||||
|
||||
@@ -453,10 +442,8 @@ class TestFileService:
|
||||
used.add("a (1).txt")
|
||||
assert FileService._dedupe_zip_entry_name("a.txt", used) == "a (2).txt"
|
||||
|
||||
def test_build_upload_files_zip_tempfile(self):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.name = "test.txt"
|
||||
upload_file.key = "key"
|
||||
def test_build_upload_files_zip_tempfile(self, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session)
|
||||
|
||||
with (
|
||||
patch("services.file_service.storage") as mock_storage,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -42,14 +41,13 @@ from services.human_input_service import (
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session_factory(sqlite_engine: Engine) -> Iterator[tuple[sessionmaker[Session], Session]]:
|
||||
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with factory() as session:
|
||||
yield factory, session
|
||||
def unbound_session_factory() -> sessionmaker[Session]:
|
||||
"""Supply the required constructor dependency without enabling database access."""
|
||||
return sessionmaker()
|
||||
|
||||
|
||||
def _persist_app(sqlite_session: Session, mode: AppMode) -> App:
|
||||
app = App(
|
||||
def _make_app(mode: AppMode) -> App:
|
||||
return App(
|
||||
id="app-id",
|
||||
tenant_id="tenant-id",
|
||||
name="Test App",
|
||||
@@ -60,9 +58,6 @@ def _persist_app(sqlite_session: Session, mode: AppMode) -> App:
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
sqlite_session.add(app)
|
||||
sqlite_session.commit()
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -97,14 +92,11 @@ def sample_form_record():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -116,7 +108,8 @@ def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
_persist_app(sqlite_session, AppMode.WORKFLOW)
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.WORKFLOW))
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -128,10 +121,9 @@ def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
|
||||
|
||||
def test_ensure_form_active_respects_global_timeout(
|
||||
monkeypatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
monkeypatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
expired_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
created_at=naive_utc_now() - timedelta(hours=2),
|
||||
@@ -143,14 +135,11 @@ def test_ensure_form_active_respects_global_timeout(
|
||||
service.ensure_form_active(Form(expired_record))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -162,7 +151,8 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
_persist_app(sqlite_session, AppMode.ADVANCED_CHAT)
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.ADVANCED_CHAT))
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -173,14 +163,11 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -192,7 +179,8 @@ def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
_persist_app(sqlite_session, AppMode.COMPLETION)
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.COMPLETION))
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -202,14 +190,13 @@ def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_for_console_uses_repository(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
console_record = dataclasses.replace(sample_form_record, recipient_type=RecipientType.CONSOLE)
|
||||
repo.get_by_token.return_value = console_record
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
form = service.get_form_definition_by_token_for_console("token")
|
||||
|
||||
repo.get_by_token.assert_called_once_with("token")
|
||||
@@ -245,9 +232,8 @@ def _build_resumption_context_state(*, options: list[str], workflow_run_id: str)
|
||||
|
||||
|
||||
def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
configured_input = SelectInputConfig(
|
||||
output_variable_name="decision",
|
||||
option_source=StringListSource(
|
||||
@@ -272,7 +258,7 @@ def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
"services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
resolved_inputs = service.resolve_form_inputs(Form(record))
|
||||
|
||||
@@ -284,13 +270,12 @@ def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
|
||||
|
||||
def test_submit_form_by_token_calls_repository_and_enqueue(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
repo.mark_submitted.return_value = sample_form_record
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -313,11 +298,10 @@ def test_submit_form_by_token_calls_repository_and_enqueue(
|
||||
|
||||
|
||||
def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
sample_form_record, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
# ENG-635: a conversation-owned (Agent v2 chat) form routes to the chat
|
||||
# resume, not the workflow resume.
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
conversation_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
@@ -326,7 +310,7 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
)
|
||||
repo.get_by_token.return_value = conversation_record
|
||||
repo.mark_submitted.return_value = conversation_record
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
workflow_enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
chat_enqueue_spy = mocker.patch.object(service, "enqueue_agent_app_resume")
|
||||
|
||||
@@ -343,9 +327,8 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
|
||||
|
||||
def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
test_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
@@ -354,7 +337,7 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
)
|
||||
repo.get_by_token.return_value = test_record
|
||||
repo.mark_submitted.return_value = test_record
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -368,13 +351,12 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
|
||||
|
||||
def test_submit_form_by_token_passes_submission_user_id(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
repo.mark_submitted.return_value = sample_form_record
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -391,11 +373,10 @@ def test_submit_form_by_token_passes_submission_user_id(
|
||||
enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id)
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -409,8 +390,7 @@ def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormR
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
|
||||
definition_with_input = FormDefinition(
|
||||
@@ -422,7 +402,7 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
|
||||
)
|
||||
form_with_input = dataclasses.replace(sample_form_record, definition=definition_with_input)
|
||||
repo.get_by_token.return_value = form_with_input
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -436,42 +416,6 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_human_input_submission_accepts_select_file_and_file_list(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
"form_content": "Pick one and upload files",
|
||||
"inputs": [
|
||||
{
|
||||
"type": "select",
|
||||
"output_variable_name": "decision",
|
||||
"option_source": {
|
||||
"type": "constant",
|
||||
"value": ["approve", "reject"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"output_variable_name": "attachment",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
},
|
||||
{
|
||||
"type": "file-list",
|
||||
"output_variable_name": "attachments",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
"number_limits": 3,
|
||||
},
|
||||
],
|
||||
"user_actions": [{"id": "submit", "title": "Submit"}],
|
||||
"rendered_content": "<p>Pick one and upload files</p>",
|
||||
"expiration_time": naive_utc_now() + timedelta(hours=1),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_definition", "submitted_value", "expected_message"),
|
||||
[
|
||||
@@ -522,12 +466,11 @@ def test_validate_human_input_submission_accepts_select_file_and_file_list(sqlit
|
||||
)
|
||||
def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads(
|
||||
sample_form_record,
|
||||
sqlite_session_factory,
|
||||
unbound_session_factory,
|
||||
input_definition,
|
||||
submitted_value,
|
||||
expected_message,
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
@@ -539,7 +482,7 @@ def test_validate_human_input_submission_rejects_invalid_select_and_file_payload
|
||||
}
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -569,7 +512,7 @@ def test_form_properties(sample_form_record: HumanInputFormRecord):
|
||||
|
||||
def test_form_submitted_error_init():
|
||||
error = FormSubmittedError(form_id="test-form")
|
||||
assert "form_id=test-form" in error.description
|
||||
assert error.description == "This form has already been submitted by another user, form_id=test-form"
|
||||
assert error.code == 412
|
||||
|
||||
|
||||
@@ -580,61 +523,55 @@ def test_human_input_service_init_with_engine(sqlite_engine: Engine):
|
||||
assert service._session_factory.kw["bind"] is sqlite_engine
|
||||
|
||||
|
||||
def test_get_form_by_token_none(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_get_form_by_token_none(unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
assert service.get_form_by_token("invalid") is None
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
# RecipientType mismatch
|
||||
assert service.get_form_definition_by_token(RecipientType.CONSOLE, "token") is None
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
form = service.get_form_definition_by_token(RecipientType.STANDALONE_WEB_APP, "token")
|
||||
assert form is not None
|
||||
assert form.id == sample_form_record.form_id
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_for_console_mismatch(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record # is STANDALONE_WEB_APP
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
assert service.get_form_definition_by_token_for_console("token") is None
|
||||
|
||||
|
||||
def test_submit_form_by_token_delivery_not_enabled(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_submit_form_by_token_delivery_not_enabled(unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
with pytest.raises(human_input_service_module.WebAppDeliveryNotEnabledError):
|
||||
service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "action", {})
|
||||
|
||||
|
||||
def test_submit_form_by_token_no_workflow_run_id(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
@@ -642,16 +579,15 @@ def test_submit_form_by_token_no_workflow_run_id(
|
||||
result_record = dataclasses.replace(sample_form_record, workflow_run_id=None)
|
||||
repo.mark_submitted.return_value = result_record
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "submit", {})
|
||||
enqueue_spy.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
# Submitted
|
||||
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
|
||||
@@ -671,18 +607,16 @@ def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, sql
|
||||
service.ensure_form_active(Form(expired_time_record))
|
||||
|
||||
|
||||
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
|
||||
|
||||
with pytest.raises(human_input_service_module.FormSubmittedError):
|
||||
service._ensure_not_submitted(Form(submitted_record))
|
||||
|
||||
|
||||
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
workflow_run_repo = MagicMock()
|
||||
workflow_run_repo.get_workflow_run_by_id_without_tenant.return_value = None
|
||||
@@ -696,15 +630,12 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, sqlite_session
|
||||
assert "WorkflowRun not found" in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_app_not_found(
|
||||
mocker,
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -715,26 +646,31 @@ def test_enqueue_resume_app_not_found(
|
||||
"services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="services.human_input_service"):
|
||||
service.enqueue_resume("workflow-run-id")
|
||||
assert any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||
|
||||
assert (
|
||||
"services.human_input_service",
|
||||
logging.ERROR,
|
||||
"App not found for WorkflowRun, workflow_run_id=workflow-run-id, app_id=app-id",
|
||||
) in caplog.record_tuples
|
||||
resume_task.apply_async.assert_not_called()
|
||||
|
||||
|
||||
def test_is_globally_expired_zero_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
|
||||
assert service._is_globally_expired(Form(sample_form_record)) is False
|
||||
|
||||
|
||||
def test_submit_form_by_token_normalizes_select_and_files(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -753,7 +689,7 @@ def test_submit_form_by_token_normalizes_select_and_files(
|
||||
form_with_inputs = dataclasses.replace(sample_form_record, definition=definition)
|
||||
repo.get_by_token.return_value = form_with_inputs
|
||||
repo.mark_submitted.return_value = form_with_inputs
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
single_file = File(
|
||||
file_id="file-1",
|
||||
@@ -815,9 +751,8 @@ def test_submit_form_by_token_normalizes_select_and_files(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_select_value(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -832,7 +767,7 @@ def test_submit_form_by_token_invalid_select_value(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"):
|
||||
service.submit_form_by_token(
|
||||
@@ -844,9 +779,8 @@ def test_submit_form_by_token_invalid_select_value(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_file_list_item(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -856,7 +790,7 @@ def test_submit_form_by_token_invalid_file_list_item(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(
|
||||
InvalidFormDataError,
|
||||
@@ -871,9 +805,8 @@ def test_submit_form_by_token_invalid_file_list_item(
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -883,7 +816,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
mocker.patch("services.human_input_service.build_from_mapping", side_effect=ValueError("Invalid upload file"))
|
||||
|
||||
with pytest.raises(InvalidFormDataError, match="Invalid value for file input 'attachment'"):
|
||||
@@ -904,9 +837,8 @@ def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file_list(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -916,7 +848,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file_list(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file"))
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
+42
-32
@@ -1,16 +1,20 @@
|
||||
"""Testcontainers integration tests for OAuthServerService."""
|
||||
"""Unit tests for OAuthServerService with SQLite-backed database access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from models.engine import db
|
||||
from models.model import OAuthProviderApp
|
||||
from services.oauth_server import (
|
||||
OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
@@ -23,10 +27,24 @@ from services.oauth_server import (
|
||||
)
|
||||
|
||||
|
||||
class TestOAuthServerServiceGetProviderApp:
|
||||
"""DB-backed tests for get_oauth_provider_app."""
|
||||
@pytest.fixture
|
||||
def oauth_db() -> Iterator[Session]:
|
||||
"""Provide the production database extension with an isolated SQLite provider table."""
|
||||
app = Flask(__name__)
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
db.init_app(app)
|
||||
|
||||
def _create_oauth_provider_app(self, db_session_with_containers: Session, *, client_id: str) -> OAuthProviderApp:
|
||||
with app.app_context():
|
||||
OAuthProviderApp.__table__.create(db.engine)
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
|
||||
class TestOAuthServerServiceGetProviderApp:
|
||||
"""Verify provider lookup against a real SQLAlchemy database."""
|
||||
|
||||
def test_get_oauth_provider_app_returns_app_when_exists(self, oauth_db: Session) -> None:
|
||||
client_id = f"client-{uuid4()}"
|
||||
app = OAuthProviderApp(
|
||||
app_icon="icon.png",
|
||||
client_id=client_id,
|
||||
@@ -35,35 +53,30 @@ class TestOAuthServerServiceGetProviderApp:
|
||||
redirect_uris=["https://example.com/callback"],
|
||||
scope="read",
|
||||
)
|
||||
db_session_with_containers.add(app)
|
||||
db_session_with_containers.commit()
|
||||
return app
|
||||
|
||||
def test_get_oauth_provider_app_returns_app_when_exists(self, db_session_with_containers: Session):
|
||||
client_id = f"client-{uuid4()}"
|
||||
created = self._create_oauth_provider_app(db_session_with_containers, client_id=client_id)
|
||||
oauth_db.add(app)
|
||||
oauth_db.commit()
|
||||
|
||||
result = OAuthServerService.get_oauth_provider_app(client_id)
|
||||
|
||||
assert result is not None
|
||||
assert result.client_id == client_id
|
||||
assert result.id == created.id
|
||||
assert result.id == app.id
|
||||
|
||||
def test_get_oauth_provider_app_returns_none_when_not_exists(self, db_session_with_containers: Session):
|
||||
def test_get_oauth_provider_app_returns_none_when_not_exists(self, oauth_db: Session) -> None:
|
||||
result = OAuthServerService.get_oauth_provider_app(f"nonexistent-{uuid4()}")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestOAuthServerServiceTokenOperations:
|
||||
"""Redis-backed tests for token sign/validate operations."""
|
||||
"""Verify Redis-backed token signing and validation branches."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self):
|
||||
with patch("services.oauth_server.redis_client") as mock:
|
||||
yield mock
|
||||
|
||||
def test_sign_authorization_code_stores_and_returns_code(self, mock_redis):
|
||||
def test_sign_authorization_code_stores_and_returns_code(self, mock_redis) -> None:
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000111")
|
||||
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
|
||||
code = OAuthServerService.sign_oauth_authorization_code("client-1", "user-1")
|
||||
@@ -75,7 +88,7 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=600,
|
||||
)
|
||||
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis):
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis) -> None:
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
with pytest.raises(BadRequest, match="invalid code"):
|
||||
@@ -85,14 +98,13 @@ class TestOAuthServerServiceTokenOperations:
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis):
|
||||
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis) -> None:
|
||||
token_uuids = [
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000201"),
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000202"),
|
||||
]
|
||||
with patch("services.oauth_server.uuid.uuid4", side_effect=token_uuids):
|
||||
mock_redis.get.return_value = b"user-1"
|
||||
|
||||
access_token, refresh_token = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.AUTHORIZATION_CODE,
|
||||
code="code-1",
|
||||
@@ -114,7 +126,7 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis):
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis) -> None:
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
with pytest.raises(BadRequest, match="invalid refresh token"):
|
||||
@@ -124,11 +136,10 @@ class TestOAuthServerServiceTokenOperations:
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis):
|
||||
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis) -> None:
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000301")
|
||||
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
|
||||
mock_redis.get.return_value = b"user-1"
|
||||
|
||||
access_token, returned_refresh = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.REFRESH_TOKEN,
|
||||
refresh_token="refresh-1",
|
||||
@@ -138,14 +149,14 @@ class TestOAuthServerServiceTokenOperations:
|
||||
assert access_token == str(deterministic_uuid)
|
||||
assert returned_refresh == "refresh-1"
|
||||
|
||||
def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis):
|
||||
def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis) -> None:
|
||||
grant_type = cast(OAuthGrantType, "invalid-grant-type")
|
||||
|
||||
result = OAuthServerService.sign_oauth_access_token(grant_type=grant_type, client_id="client-1")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis):
|
||||
def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis) -> None:
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000401")
|
||||
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
|
||||
refresh_token = OAuthServerService._sign_oauth_refresh_token("client-2", "user-2")
|
||||
@@ -157,22 +168,21 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, db_session_with_containers: Session):
|
||||
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, sqlite_engine: Engine) -> None:
|
||||
mock_redis.get.return_value = None
|
||||
session = MagicMock()
|
||||
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", db_session_with_containers)
|
||||
with Session(sqlite_engine) as session:
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", session)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_validate_access_token_loads_user_when_exists(self, mock_redis, db_session_with_containers: Session):
|
||||
def test_validate_access_token_loads_user_when_exists(self, mock_redis, sqlite_engine: Engine) -> None:
|
||||
mock_redis.get.return_value = b"user-88"
|
||||
expected_user = MagicMock()
|
||||
|
||||
with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load:
|
||||
result = OAuthServerService.validate_oauth_access_token(
|
||||
"client-1", "access-token", db_session_with_containers
|
||||
)
|
||||
with Session(sqlite_engine) as session:
|
||||
with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load:
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "access-token", session)
|
||||
mock_load.assert_called_once_with("user-88", session)
|
||||
|
||||
assert result is expected_user
|
||||
mock_load.assert_called_once_with("user-88", db_session_with_containers)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Unit tests for workflow app log views and trigger metadata helpers."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.enums import AppTriggerType, CreatorUserRole
|
||||
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom
|
||||
from services.workflow_app_service import LogView, WorkflowAppService
|
||||
|
||||
|
||||
class TestLogView:
|
||||
def test_details_and_proxy_attributes(self) -> None:
|
||||
log = WorkflowAppLog(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="run-1",
|
||||
created_from=WorkflowAppLogCreatedFrom.WEB_APP,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
)
|
||||
log.id = "log-1"
|
||||
|
||||
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
|
||||
|
||||
assert view.details == {"trigger_metadata": {"type": "plugin"}}
|
||||
assert view.id == "log-1"
|
||||
|
||||
|
||||
class TestHandleTriggerMetadata:
|
||||
def test_returns_empty_dict_when_metadata_missing(self) -> None:
|
||||
assert WorkflowAppService().handle_trigger_metadata("tenant-1", None) == {}
|
||||
|
||||
def test_enriches_plugin_icons(self) -> None:
|
||||
metadata = {
|
||||
"type": AppTriggerType.TRIGGER_PLUGIN.value,
|
||||
"icon_filename": "light.png",
|
||||
"icon_dark_filename": "dark.png",
|
||||
}
|
||||
with patch(
|
||||
"services.workflow_app_service.PluginService.get_plugin_icon_url",
|
||||
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
|
||||
) as mock_icon:
|
||||
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
|
||||
|
||||
assert result["icon"] == "https://cdn/light.png"
|
||||
assert result["icon_dark"] == "https://cdn/dark.png"
|
||||
assert mock_icon.call_count == 2
|
||||
|
||||
def test_non_plugin_metadata_without_icon_lookup(self) -> None:
|
||||
metadata = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
|
||||
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
|
||||
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
|
||||
|
||||
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
|
||||
mock_icon.assert_not_called()
|
||||
|
||||
|
||||
class TestSafeJsonLoads:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
('{"k":"v"}', {"k": "v"}),
|
||||
("not-json", None),
|
||||
({"raw": True}, {"raw": True}),
|
||||
],
|
||||
)
|
||||
def test_handles_various_inputs(self, value, expected) -> None:
|
||||
assert WorkflowAppService._safe_json_loads(value) == expected
|
||||
|
||||
|
||||
class TestSafeParseUuid:
|
||||
def test_returns_none_for_short_or_invalid_values(self) -> None:
|
||||
assert WorkflowAppService._safe_parse_uuid("short") is None
|
||||
assert WorkflowAppService._safe_parse_uuid("x" * 40) is None
|
||||
|
||||
def test_returns_uuid_for_valid_string(self) -> None:
|
||||
raw = str(uuid.uuid4())
|
||||
|
||||
result = WorkflowAppService._safe_parse_uuid(raw)
|
||||
|
||||
assert result is not None
|
||||
assert str(result) == raw
|
||||
@@ -1,178 +1,57 @@
|
||||
"""Comprehensive unit tests for WorkflowRunService class.
|
||||
"""Tests for the session lifecycle owned by ``WorkflowRunService``."""
|
||||
|
||||
This test suite covers all pause state management operations including:
|
||||
- Retrieving pause state for workflow runs
|
||||
- Saving pause state with file uploads
|
||||
- Marking paused workflows as resumed
|
||||
- Error handling and edge cases
|
||||
- Database transaction management
|
||||
- Repository-based approach testing
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, create_autospec, patch
|
||||
from unittest.mock import create_autospec, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy import Engine, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models.workflow import WorkflowPause
|
||||
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import _PrivateWorkflowPauseEntity
|
||||
from services.workflow_run_service import (
|
||||
WorkflowRunService,
|
||||
)
|
||||
from services.workflow_run_service import WorkflowRunService
|
||||
|
||||
|
||||
class TestDataFactory:
|
||||
"""Factory class for creating test data objects."""
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_run_mock(
|
||||
id: str = "workflow-run-123",
|
||||
tenant_id: str = "tenant-456",
|
||||
app_id: str = "app-789",
|
||||
workflow_id: str = "workflow-101",
|
||||
status: str | WorkflowExecutionStatus = "paused",
|
||||
**kwargs,
|
||||
) -> MagicMock:
|
||||
"""Create a mock WorkflowRun object."""
|
||||
mock_run = MagicMock()
|
||||
mock_run.id = id
|
||||
mock_run.tenant_id = tenant_id
|
||||
mock_run.app_id = app_id
|
||||
mock_run.workflow_id = workflow_id
|
||||
mock_run.status = status
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(mock_run, key, value)
|
||||
|
||||
return mock_run
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_pause_mock(
|
||||
id: str = "pause-123",
|
||||
tenant_id: str = "tenant-456",
|
||||
app_id: str = "app-789",
|
||||
workflow_id: str = "workflow-101",
|
||||
workflow_execution_id: str = "workflow-execution-123",
|
||||
state_file_id: str = "file-456",
|
||||
resumed_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> MagicMock:
|
||||
"""Create a mock WorkflowPauseModel object."""
|
||||
mock_pause = MagicMock(spec=WorkflowPause)
|
||||
mock_pause.id = id
|
||||
mock_pause.tenant_id = tenant_id
|
||||
mock_pause.app_id = app_id
|
||||
mock_pause.workflow_id = workflow_id
|
||||
mock_pause.workflow_execution_id = workflow_execution_id
|
||||
mock_pause.state_file_id = state_file_id
|
||||
mock_pause.resumed_at = resumed_at
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(mock_pause, key, value)
|
||||
|
||||
return mock_pause
|
||||
|
||||
@staticmethod
|
||||
def create_pause_entity_mock(
|
||||
pause_model: MagicMock | None = None,
|
||||
) -> _PrivateWorkflowPauseEntity:
|
||||
"""Create a mock _PrivateWorkflowPauseEntity object."""
|
||||
if pause_model is None:
|
||||
pause_model = TestDataFactory.create_workflow_pause_mock()
|
||||
|
||||
return _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[])
|
||||
@pytest.fixture
|
||||
def sqlite_session_factory(sqlite_engine: Engine) -> sessionmaker[Session]:
|
||||
"""Return a real factory whose sessions are bound to the isolated SQLite engine."""
|
||||
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class TestWorkflowRunService:
|
||||
"""Comprehensive unit tests for WorkflowRunService class."""
|
||||
@pytest.fixture
|
||||
def workflow_run_repository():
|
||||
"""Keep the repository boundary mocked while exercising real session construction."""
|
||||
return create_autospec(APIWorkflowRunRepository)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_factory(self):
|
||||
"""Create a mock session factory with proper session management."""
|
||||
mock_session = create_autospec(Session)
|
||||
|
||||
# Create a mock context manager for the session
|
||||
mock_session_cm = MagicMock()
|
||||
mock_session_cm.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_session_cm.__exit__ = MagicMock(return_value=None)
|
||||
def test_init_with_session_factory(
|
||||
sqlite_session_factory: sessionmaker[Session], workflow_run_repository: APIWorkflowRunRepository
|
||||
) -> None:
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as repository_factory:
|
||||
repository_factory.create_api_workflow_run_repository.return_value = workflow_run_repository
|
||||
|
||||
# Create a mock context manager for the transaction
|
||||
mock_transaction_cm = MagicMock()
|
||||
mock_transaction_cm.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_transaction_cm.__exit__ = MagicMock(return_value=None)
|
||||
service = WorkflowRunService(sqlite_session_factory)
|
||||
|
||||
mock_session.begin = MagicMock(return_value=mock_transaction_cm)
|
||||
assert service._session_factory is sqlite_session_factory
|
||||
repository_factory.create_api_workflow_run_repository.assert_called_once_with(sqlite_session_factory)
|
||||
with service._session_factory() as session:
|
||||
assert session.scalar(text("SELECT 1")) == 1
|
||||
|
||||
# Create mock factory that returns the context manager
|
||||
mock_factory = MagicMock(spec=sessionmaker)
|
||||
mock_factory.return_value = mock_session_cm
|
||||
|
||||
return mock_factory, mock_session
|
||||
def test_init_with_engine_creates_bound_session_factory(
|
||||
sqlite_engine: Engine, workflow_run_repository: APIWorkflowRunRepository
|
||||
) -> None:
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as repository_factory:
|
||||
repository_factory.create_api_workflow_run_repository.return_value = workflow_run_repository
|
||||
|
||||
@pytest.fixture
|
||||
def mock_workflow_run_repository(self):
|
||||
"""Create a mock APIWorkflowRunRepository."""
|
||||
mock_repo = create_autospec(APIWorkflowRunRepository)
|
||||
return mock_repo
|
||||
service = WorkflowRunService(sqlite_engine)
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_run_service(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Create WorkflowRunService instance with mocked dependencies."""
|
||||
session_factory, _ = mock_session_factory
|
||||
assert service._session_factory.kw["bind"] is sqlite_engine
|
||||
assert service._session_factory.kw["expire_on_commit"] is False
|
||||
repository_factory.create_api_workflow_run_repository.assert_called_once_with(service._session_factory)
|
||||
with service._session_factory() as session:
|
||||
assert session.scalar(text("SELECT 1")) == 1
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
service = WorkflowRunService(session_factory)
|
||||
return service
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_run_service_with_engine(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Create WorkflowRunService instance with Engine input."""
|
||||
mock_engine = create_autospec(Engine)
|
||||
session_factory, _ = mock_session_factory
|
||||
def test_init_with_default_repository_dependencies(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
service = WorkflowRunService(sqlite_session_factory)
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
service = WorkflowRunService(mock_engine)
|
||||
return service
|
||||
|
||||
# ==================== Initialization Tests ====================
|
||||
|
||||
def test_init_with_session_factory(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Test WorkflowRunService initialization with session_factory."""
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
service = WorkflowRunService(session_factory)
|
||||
|
||||
assert service._session_factory == session_factory
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory)
|
||||
|
||||
def test_init_with_engine(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Test WorkflowRunService initialization with Engine (should convert to sessionmaker)."""
|
||||
mock_engine = create_autospec(Engine)
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
with patch(
|
||||
"services.workflow_run_service.sessionmaker", return_value=session_factory, autospec=True
|
||||
) as mock_sessionmaker:
|
||||
service = WorkflowRunService(mock_engine)
|
||||
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_engine, expire_on_commit=False)
|
||||
assert service._session_factory == session_factory
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory)
|
||||
|
||||
def test_init_with_default_dependencies(self, mock_session_factory):
|
||||
"""Test WorkflowRunService initialization with default dependencies."""
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
service = WorkflowRunService(session_factory)
|
||||
|
||||
assert service._session_factory == session_factory
|
||||
assert service._session_factory is sqlite_session_factory
|
||||
|
||||
@@ -135,6 +135,19 @@ class TestMCPToolInvoke:
|
||||
values = {m.message.variable_name: m.message.variable_value for m in var_msgs}
|
||||
assert values == {"a": 1, "b": "x"}
|
||||
|
||||
def test_invoke_yields_json_when_structured_content_has_no_output_schema(self, orm_session: Session) -> None:
|
||||
tool = _make_mcp_tool()
|
||||
result = CallToolResult(content=[], structuredContent={"a": 1, "b": "x"})
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
assert len(messages) == 1
|
||||
msg = messages[0]
|
||||
assert msg.type == ToolInvokeMessage.MessageType.JSON
|
||||
assert isinstance(msg.message, ToolInvokeMessage.JsonMessage)
|
||||
assert msg.message.json_object == {"a": 1, "b": "x"}
|
||||
|
||||
|
||||
class TestMCPToolUsageExtraction:
|
||||
"""Test usage metadata extraction from MCP tool results."""
|
||||
|
||||
Generated
+7
-6
@@ -1281,7 +1281,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { editable = "../dify-agent" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1331,7 +1331,7 @@ docs = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
@@ -1738,7 +1738,7 @@ storage = [
|
||||
]
|
||||
tools = [
|
||||
{ name = "cloudscraper", specifier = ">=1.2.71,<2.0.0" },
|
||||
{ name = "nltk", specifier = ">=3.9.1,<4.0.0" },
|
||||
{ name = "nltk", specifier = ">=3.10.0,<4.0.0" },
|
||||
]
|
||||
trace-aliyun = [{ name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }]
|
||||
trace-all = [
|
||||
@@ -4113,17 +4113,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nltk"
|
||||
version = "3.9.4"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "joblib" },
|
||||
{ name = "regex" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
"channel": "alpha",
|
||||
"compat": {
|
||||
"minDify": "1.16.0",
|
||||
"maxDify": "1.16.0"
|
||||
"maxDify": "1.16.1"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
|
||||
@@ -7,6 +7,7 @@ REPO_ROOT="$SCRIPT_DIR/.."
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
EXCLUDES_FILE="api/pyrefly-local-excludes.txt"
|
||||
UNIT_TESTS_CONFIG="tests/unit_tests/pyrefly.toml"
|
||||
TEST_CONTAINERS_DIR="tests/test_containers_integration_tests"
|
||||
TEST_CONTAINERS_CONFIG="$TEST_CONTAINERS_DIR/pyrefly.toml"
|
||||
|
||||
@@ -74,6 +75,19 @@ fi
|
||||
run_pyrefly "${pyrefly_command[@]}" || status=$?
|
||||
|
||||
if (( ${#target_paths[@]} == 0 )); then
|
||||
unit_tests_args=(
|
||||
"--summary=none"
|
||||
"--use-ignore-files=false"
|
||||
"--config=$UNIT_TESTS_CONFIG"
|
||||
)
|
||||
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
|
||||
unit_tests_args+=("--output-format=github")
|
||||
fi
|
||||
run_pyrefly \
|
||||
uv run --directory api --dev pyrefly check \
|
||||
"${unit_tests_args[@]}" \
|
||||
|| status=$?
|
||||
|
||||
test_containers_args=(
|
||||
"--summary=none"
|
||||
"--use-ignore-files=false"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<4.0"
|
||||
|
||||
@@ -115,7 +115,7 @@ def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name) # noqa: no-new-getattr lazy export proxy
|
||||
value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name) # noqa: no-new-getattr lazy export proxy
|
||||
value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -581,7 +581,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -157,9 +157,6 @@ ENABLE_WEBSITE_JINAREADER=true
|
||||
ENABLE_WEBSITE_FIRECRAWL=true
|
||||
ENABLE_WEBSITE_WATERCRAWL=true
|
||||
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
# Enable preview features still in development (currently the /create and
|
||||
# /refine slash commands in the "Go to Anything" command palette).
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2=true
|
||||
EXPERIMENTAL_ENABLE_VINEXT=false
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -271,7 +271,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -297,7 +297,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -347,7 +347,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -380,7 +380,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.0
|
||||
image: langgenius/dify-web:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
@@ -542,7 +542,7 @@ services:
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/local-sandbox.env
|
||||
@@ -651,7 +651,7 @@ services:
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
image: langgenius/dify-agent-backend:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
|
||||
@@ -226,7 +226,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -277,7 +277,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -303,7 +303,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -353,7 +353,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -386,7 +386,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.0
|
||||
image: langgenius/dify-web:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
@@ -548,7 +548,7 @@ services:
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/local-sandbox.env
|
||||
@@ -657,7 +657,7 @@ services:
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
image: langgenius/dify-agent-backend:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
|
||||
@@ -363,14 +363,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/sections.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
|
||||
@@ -245,13 +245,13 @@ def extract_meta_variables(raw_match: dict[str, Any]) -> dict[str, str]:
|
||||
return result
|
||||
|
||||
|
||||
def has_reasoned_noqa(source_line: str, rule_id: str) -> bool:
|
||||
pattern = re.compile(rf"# noqa: {re.escape(rule_id)}(?:\s+(?P<reason>\S.*))?\s*$")
|
||||
def has_reasoned_guard_ignore(source_line: str, rule_id: str) -> bool:
|
||||
pattern = re.compile(rf"# guard-ignore: {re.escape(rule_id)} -- (?P<reason>\S.*)\s*$")
|
||||
match = pattern.search(source_line)
|
||||
if not match:
|
||||
return False
|
||||
reason = match.group("reason")
|
||||
return reason is not None and bool(reason.strip())
|
||||
return bool(reason.strip())
|
||||
|
||||
|
||||
def collect_hunk_violations(
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from ast_grep_guard import Match, has_reasoned_noqa, rule_path, run_guard
|
||||
from ast_grep_guard import Match, has_reasoned_guard_ignore, rule_path, run_guard
|
||||
|
||||
|
||||
RULE_ID = "no-new-controller-sqlalchemy"
|
||||
@@ -40,7 +40,7 @@ def is_flask_session_get(match: Match) -> bool:
|
||||
|
||||
|
||||
def is_suppressed(match: Match) -> bool:
|
||||
return has_reasoned_noqa(match.source_line, RULE_ID)
|
||||
return has_reasoned_guard_ignore(match.source_line, RULE_ID)
|
||||
|
||||
|
||||
def is_reportable_match(match: Match) -> bool:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ast_grep_guard import Match, has_reasoned_noqa, is_python_source_path, rule_path, run_guard
|
||||
from ast_grep_guard import Match, has_reasoned_guard_ignore, is_python_source_path, rule_path, run_guard
|
||||
|
||||
|
||||
RULE_ID = "no-new-getattr"
|
||||
@@ -12,7 +12,7 @@ VIOLATION_MESSAGE = "no-new-getattr net-new getattr() in added code"
|
||||
|
||||
|
||||
def is_reportable_match(match: Match) -> bool:
|
||||
return not has_reasoned_noqa(match.source_line, RULE_ID)
|
||||
return not has_reasoned_guard_ignore(match.source_line, RULE_ID)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -44,7 +44,7 @@ def parse_args() -> argparse.Namespace:
|
||||
help="Files or directories to scan. Defaults to api/controllers.",
|
||||
)
|
||||
parser.add_argument("--include-allowed", action="store_true", help="Print allowed flush()/commit() findings.")
|
||||
parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned noqa suppressions.")
|
||||
parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned guard suppressions.")
|
||||
parser.add_argument("--summary-only", action="store_true", help="Print counts without per-finding details.")
|
||||
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
||||
parser.add_argument(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppAccessPointDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppDeployDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Dependency } from '@/app/components/plugins/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useStore as usePluginDependencyStore } from '@/app/components/workflow/plugin-dependency/store'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guardAgentV2Route: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-bundle', () => ({
|
||||
default: ({ fromDSLPayload }: { fromDSLPayload: Dependency[] }) => (
|
||||
<div role="dialog" aria-label="Install missing plugins">
|
||||
{`bundle-size:${fromDSLPayload.length}`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../feature-guard', () => ({
|
||||
guardAgentV2Route: () => mocks.guardAgentV2Route(),
|
||||
}))
|
||||
@@ -18,6 +28,7 @@ vi.mock('../agents-access-guard', () => ({
|
||||
describe('RosterLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
usePluginDependencyStore.setState({ dependencies: [] })
|
||||
})
|
||||
|
||||
it('should render children when Agent v2 is enabled', async () => {
|
||||
@@ -33,6 +44,34 @@ describe('RosterLayout', () => {
|
||||
expect(screen.getByText('Roster content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the missing-plugin installer across Agent routes', async () => {
|
||||
usePluginDependencyStore.setState({
|
||||
dependencies: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
organization: 'langgenius',
|
||||
plugin: 'sample-plugin',
|
||||
version: '1.0.0',
|
||||
plugin_unique_identifier: 'langgenius/sample-plugin:1.0.0',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const { default: RosterLayout } = await import('../layout')
|
||||
|
||||
render(
|
||||
<RosterLayout>
|
||||
<div>Agent route content</div>
|
||||
</RosterLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Install missing plugins' })).toHaveTextContent(
|
||||
'bundle-size:1',
|
||||
)
|
||||
expect(screen.getByText('Agent route content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should block rendering when the roster guard throws notFound', async () => {
|
||||
mocks.guardAgentV2Route.mockImplementation(() => {
|
||||
throw new Error('NEXT_NOT_FOUND')
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import PluginDependency from '@/app/components/workflow/plugin-dependency'
|
||||
import { AgentsAccessGuard } from './agents-access-guard'
|
||||
import { guardAgentV2Route } from './feature-guard'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
guardAgentV2Route()
|
||||
|
||||
return <AgentsAccessGuard>{children}</AgentsAccessGuard>
|
||||
return (
|
||||
<AgentsAccessGuard>
|
||||
<PluginDependency />
|
||||
{children}
|
||||
</AgentsAccessGuard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function AppAccessPointPage() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function AppDeployPage() {
|
||||
return null
|
||||
}
|
||||
@@ -7,10 +7,12 @@ let mockAppMode = 'chat'
|
||||
let mockPathname = '/app/app-1/logs'
|
||||
let mockAppPermissionKeys: string[] = []
|
||||
let mockIsRbacEnabled = true
|
||||
let mockEnableAppDeploy = false
|
||||
const mockConsoleState = vi.hoisted(() => ({
|
||||
current: {
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: [] as string[],
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -18,6 +20,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
|
||||
renderWithConsoleQuery(ui, {
|
||||
systemFeatures: {
|
||||
rbac_enabled: mockIsRbacEnabled,
|
||||
enable_app_deploy: mockEnableAppDeploy,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,6 +47,10 @@ vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createPermissionStateModuleMock(() => mockConsoleState.current)
|
||||
})
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createWorkspaceStateModuleMock(() => mockConsoleState.current)
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
@@ -72,6 +79,8 @@ describe('AppDetailSection', () => {
|
||||
mockPathname = '/app/app-1/logs'
|
||||
mockAppPermissionKeys = [AppACLPermission.Monitor]
|
||||
mockIsRbacEnabled = true
|
||||
mockEnableAppDeploy = false
|
||||
mockConsoleState.current.isCurrentWorkspaceEditor = true
|
||||
})
|
||||
|
||||
// Rendering behavior for app detail navigation entries.
|
||||
@@ -183,6 +192,72 @@ describe('AppDetailSection', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render access point navigation using its app route', () => {
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.accessPoint' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/app-1/access-point',
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.apiAccess' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render deploy navigation for workflow apps when app deploy is available', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'workflow'
|
||||
mockEnableAppDeploy = true
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/app-1/deploy',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'the app is not a workflow app',
|
||||
mode: 'chat',
|
||||
enableAppDeploy: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
{
|
||||
label: 'app deploy is disabled',
|
||||
mode: 'workflow',
|
||||
enableAppDeploy: false,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
{
|
||||
label: 'the current workspace role cannot use app deploy',
|
||||
mode: 'workflow',
|
||||
enableAppDeploy: true,
|
||||
isCurrentWorkspaceEditor: false,
|
||||
},
|
||||
])(
|
||||
'should hide deploy navigation when $label',
|
||||
({ mode, enableAppDeploy, isCurrentWorkspaceEditor }) => {
|
||||
// Arrange
|
||||
mockAppMode = mode
|
||||
mockEnableAppDeploy = enableAppDeploy
|
||||
mockConsoleState.current.isCurrentWorkspaceEditor = isCurrentWorkspaceEditor
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.deploy' }),
|
||||
).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('should render resource access navigation when app access config permission is granted', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.AccessConfig]
|
||||
|
||||
@@ -3,18 +3,6 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { NavIcon } from './nav-link'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiDashboard2Fill,
|
||||
RiDashboard2Line,
|
||||
RiFileList3Fill,
|
||||
RiFileList3Line,
|
||||
RiLock2Fill,
|
||||
RiLock2Line,
|
||||
RiTerminalBoxFill,
|
||||
RiTerminalBoxLine,
|
||||
RiTerminalWindowFill,
|
||||
RiTerminalWindowLine,
|
||||
} from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
@@ -24,6 +12,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import Annotations from '@/app/components/base/icons/src/vender/Annotations'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { isCurrentWorkspaceEditorAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -45,6 +34,28 @@ const AnnotationNavIcon = ({ className, ...props }: ComponentProps<typeof Annota
|
||||
|
||||
AnnotationNavIcon.displayName = 'Annotations'
|
||||
|
||||
const createClassNameNavIcon = (iconClassName: string) => {
|
||||
const ClassNameNavIcon = ({ className }: ComponentProps<'svg'>) => (
|
||||
<span aria-hidden className={cn(iconClassName, className)} />
|
||||
)
|
||||
|
||||
ClassNameNavIcon.displayName = 'ClassNameNavIcon'
|
||||
|
||||
return ClassNameNavIcon
|
||||
}
|
||||
|
||||
const accessPointNavIcon = createClassNameNavIcon('i-custom-vender-agent-v2-access-point')
|
||||
const terminalWindowLineNavIcon = createClassNameNavIcon('i-ri-terminal-window-line')
|
||||
const terminalWindowFillNavIcon = createClassNameNavIcon('i-ri-terminal-window-fill')
|
||||
const instanceLineNavIcon = createClassNameNavIcon('i-ri-instance-line')
|
||||
const instanceFillNavIcon = createClassNameNavIcon('i-ri-instance-fill')
|
||||
const fileListLineNavIcon = createClassNameNavIcon('i-ri-file-list-3-line')
|
||||
const fileListFillNavIcon = createClassNameNavIcon('i-ri-file-list-3-fill')
|
||||
const dashboardLineNavIcon = createClassNameNavIcon('i-ri-dashboard-2-line')
|
||||
const dashboardFillNavIcon = createClassNameNavIcon('i-ri-dashboard-2-fill')
|
||||
const lockLineNavIcon = createClassNameNavIcon('i-ri-lock-2-line')
|
||||
const lockFillNavIcon = createClassNameNavIcon('i-ri-lock-2-fill')
|
||||
|
||||
const isLogsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/logs')
|
||||
const isAnnotationsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/annotations')
|
||||
|
||||
@@ -73,6 +84,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const currentUserId = useAtomValue(userProfileIdAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const appDetail = useStore((state) => state.appDetail)
|
||||
const appInfoActions = useAppInfoActions({
|
||||
@@ -85,6 +97,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
const appId = appDetail.id
|
||||
const isWorkflowApp =
|
||||
appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT
|
||||
const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW
|
||||
const supportsAnnotations =
|
||||
appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION
|
||||
const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, {
|
||||
@@ -100,24 +113,34 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
{
|
||||
name: t(($) => $['appMenus.promptEng'], { ns: 'common' }),
|
||||
href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
icon: terminalWindowLineNavIcon,
|
||||
selectedIcon: terminalWindowFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: t(($) => $['appMenus.apiAccess'], { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }),
|
||||
href: `/app/${appId}/access-point`,
|
||||
icon: accessPointNavIcon,
|
||||
selectedIcon: accessPointNavIcon,
|
||||
},
|
||||
...(supportsAppDeploy && isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
|
||||
? [
|
||||
{
|
||||
name: t(($) => $['appMenus.deploy'], { ns: 'common' }),
|
||||
href: `/app/${appId}/deploy`,
|
||||
icon: instanceLineNavIcon,
|
||||
selectedIcon: instanceFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(appACLCapabilities.canAccessLogAndAnnotation
|
||||
? [
|
||||
{
|
||||
name: t(($) => $['appMenus.logs'], { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
icon: fileListLineNavIcon,
|
||||
selectedIcon: fileListFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -136,8 +159,8 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
{
|
||||
name: t(($) => $['appMenus.overview'], { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
icon: dashboardLineNavIcon,
|
||||
selectedIcon: dashboardFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -146,13 +169,21 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
{
|
||||
name: t(($) => $['settings.resourceAccess'], { ns: 'common' }),
|
||||
href: `/app/${appId}/access-config`,
|
||||
icon: RiLock2Line,
|
||||
selectedIcon: RiLock2Fill,
|
||||
icon: lockLineNavIcon,
|
||||
selectedIcon: lockFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}, [appDetail, t, currentUserId, workspacePermissionKeys, isRbacEnabled])
|
||||
}, [
|
||||
appDetail,
|
||||
t,
|
||||
currentUserId,
|
||||
workspacePermissionKeys,
|
||||
isCurrentWorkspaceEditor,
|
||||
isRbacEnabled,
|
||||
systemFeatures.enable_app_deploy,
|
||||
])
|
||||
|
||||
if (!appDetail) return null
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
}))
|
||||
|
||||
describe('FeaturesWrappedAppPublisher', () => {
|
||||
const resetAppConfig = vi.fn()
|
||||
const publishedConfig = {
|
||||
modelConfig: {
|
||||
more_like_this: { enabled: true },
|
||||
@@ -81,7 +82,6 @@ describe('FeaturesWrappedAppPublisher', () => {
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
number_limits: 5,
|
||||
},
|
||||
resetAppConfig: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,13 +106,18 @@ describe('FeaturesWrappedAppPublisher', () => {
|
||||
})
|
||||
|
||||
it('should restore published features after confirmation', async () => {
|
||||
render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />)
|
||||
render(
|
||||
<FeaturesWrappedAppPublisher
|
||||
publishedConfig={publishedConfig as any}
|
||||
resetAppConfig={resetAppConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('restore-through-wrapper'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1)
|
||||
expect(resetAppConfig).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetFeatures).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
moreLikeThis: { enabled: true },
|
||||
@@ -128,12 +133,18 @@ describe('FeaturesWrappedAppPublisher', () => {
|
||||
citation: { enabled: true },
|
||||
annotationReply: { enabled: true },
|
||||
}),
|
||||
{ silent: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close restore confirmation without restoring when cancelled', async () => {
|
||||
render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />)
|
||||
render(
|
||||
<FeaturesWrappedAppPublisher
|
||||
publishedConfig={publishedConfig as any}
|
||||
resetAppConfig={resetAppConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('restore-through-wrapper'))
|
||||
const dialog = screen.getByRole('alertdialog')
|
||||
@@ -145,7 +156,7 @@ describe('FeaturesWrappedAppPublisher', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(publishedConfig.modelConfig.resetAppConfig).not.toHaveBeenCalled()
|
||||
expect(resetAppConfig).not.toHaveBeenCalled()
|
||||
expect(mockSetFeatures).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -18,12 +20,14 @@ const mockSetAppDetail = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUseGetUserCanAccessApp = vi.fn()
|
||||
const mockOpenAsyncWindow = vi.fn()
|
||||
const mockFetchInstalledAppList = vi.fn()
|
||||
const mockFetchAppDetail = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockWindowOpen = vi.fn()
|
||||
const mockInvalidateAppWorkflow = vi.fn()
|
||||
const mockUpdateWorkflow = vi.fn()
|
||||
const mockFetchPublishedWorkflow = vi.fn()
|
||||
let mockPublishedWorkflow: Record<string, any> | null = null
|
||||
|
||||
const sectionProps = vi.hoisted(() => ({
|
||||
summary: null as null | Record<string, any>,
|
||||
@@ -34,9 +38,20 @@ const hotkeyMocks = vi.hoisted(() => ({
|
||||
hotkeys: [] as string[],
|
||||
handlers: [] as Array<(event: { preventDefault: () => void }) => void>,
|
||||
}))
|
||||
const collaborationMocks = vi.hoisted(() => ({
|
||||
handler: undefined as
|
||||
| ((update: {
|
||||
type: 'app_publish_update'
|
||||
userId: string
|
||||
data: Record<string, unknown>
|
||||
timestamp: number
|
||||
}) => void)
|
||||
| undefined,
|
||||
}))
|
||||
|
||||
let mockAppDetail: Record<string, any> | null = null
|
||||
let mockWorkspacePermissionKeys: string[] = ['tool.manage']
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', () => ({
|
||||
useHotkey: (hotkey: string, handler: (event: { preventDefault: () => void }) => void) => {
|
||||
@@ -64,10 +79,6 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => mockOpenAsyncWindow,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
useGetUserCanAccessApp: (params: unknown) => {
|
||||
mockUseGetUserCanAccessApp(params)
|
||||
@@ -83,10 +94,6 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
|
||||
}))
|
||||
|
||||
const mockPublishToCreatorsPlatform = vi.fn()
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
@@ -95,7 +102,22 @@ vi.mock('@/service/apps', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
appWorkflowQueryOptions: (appId: string) => ({
|
||||
queryKey: ['workflow', 'publish', appId],
|
||||
queryFn: () => mockFetchPublishedWorkflow(appId),
|
||||
}),
|
||||
useAppWorkflow: () => ({ data: mockPublishedWorkflow, isSuccess: true }),
|
||||
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
|
||||
useUpdateWorkflow: () => ({ mutate: mockUpdateWorkflow }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
onAppPublishUpdate: (handler: NonNullable<(typeof collaborationMocks)['handler']>) => {
|
||||
collaborationMocks.handler = handler
|
||||
return vi.fn()
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
@@ -110,6 +132,7 @@ vi.mock('@/service/use-tools', () => ({
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createWorkspaceStateModuleMock(() => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys,
|
||||
}))
|
||||
@@ -125,6 +148,7 @@ vi.mock('@/context/permission-state', async () => {
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -132,16 +156,6 @@ vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/overview/embedded', () => ({
|
||||
default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) =>
|
||||
isShow ? (
|
||||
<div data-testid="embedded-modal">
|
||||
embedded modal
|
||||
<button onClick={onClose}>close-embedded-modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => {
|
||||
const MockAccessControl = ({
|
||||
onConfirm,
|
||||
@@ -180,6 +194,7 @@ vi.mock('../sections', () => ({
|
||||
<div>
|
||||
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
|
||||
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
|
||||
<button onClick={props.onEditVersion}>publisher-summary-edit-version</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -190,18 +205,18 @@ vi.mock('../sections', () => ({
|
||||
PublisherActionsSection: (props: Record<string, any>) => {
|
||||
sectionProps.actions = props
|
||||
return (
|
||||
<div>
|
||||
<button onClick={props.handleEmbed}>publisher-embed</button>
|
||||
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
|
||||
{props.handleOpenRunConfig && (
|
||||
<>
|
||||
<button onClick={() => props.handleOpenRunConfig(props.appURL)}>
|
||||
publisher-run-config
|
||||
</button>
|
||||
<button onClick={() => props.handleOpenRunConfig(`${props.appURL}?mode=batch`)}>
|
||||
publisher-batch-run-config
|
||||
</button>
|
||||
</>
|
||||
<div data-testid="publisher-actions">
|
||||
{props.showRunConfig && props.handleOpenRunConfig && (
|
||||
<button onClick={() => props.handleOpenRunConfig(props.appURL)}>
|
||||
publisher-run-config
|
||||
</button>
|
||||
)}
|
||||
{props.showMarketplaceAction && (
|
||||
<button disabled={props.marketplaceActionDisabled} onClick={props.onPublishToMarketplace}>
|
||||
{props.publishingToMarketplace
|
||||
? 'workflow.common.publishingToMarketplace'
|
||||
: 'workflow.common.publishToMarketplace'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button>
|
||||
</div>
|
||||
@@ -214,10 +229,14 @@ describe('AppPublisher', () => {
|
||||
vi.clearAllMocks()
|
||||
hotkeyMocks.hotkeys.length = 0
|
||||
hotkeyMocks.handlers.length = 0
|
||||
collaborationMocks.handler = undefined
|
||||
sectionProps.summary = null
|
||||
sectionProps.access = null
|
||||
sectionProps.actions = null
|
||||
mockPublishedWorkflow = null
|
||||
mockFetchPublishedWorkflow.mockResolvedValue(null)
|
||||
mockWorkspacePermissionKeys = ['tool.manage']
|
||||
mockIsCurrentWorkspaceEditor = true
|
||||
mockAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Demo App',
|
||||
@@ -228,17 +247,12 @@ describe('AppPublisher', () => {
|
||||
access_token: 'token-1',
|
||||
},
|
||||
}
|
||||
mockFetchInstalledAppList.mockResolvedValue({
|
||||
installed_apps: [{ id: 'installed-1' }],
|
||||
})
|
||||
mockFetchAppDetail.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
})
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
|
||||
return resolver()
|
||||
})
|
||||
Object.defineProperty(window, 'open', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
})
|
||||
@@ -282,16 +296,74 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the embedded modal from the actions section', () => {
|
||||
it('should edit the current workflow version from the publish summary', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
mockPublishedWorkflow = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: 'workflow-version-5',
|
||||
marked_name: 'Release 5',
|
||||
marked_comment: 'Initial notes',
|
||||
}
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-embed'))
|
||||
expect(sectionProps.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
isWorkflowApp: true,
|
||||
versionInfo: mockPublishedWorkflow,
|
||||
}),
|
||||
)
|
||||
fireEvent.click(screen.getByText('publisher-summary-edit-version'))
|
||||
|
||||
expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
const [titleInput, notesInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(titleInput!, { target: { value: 'Release 6' } })
|
||||
fireEvent.change(notesInput!, { target: { value: 'Updated notes' } })
|
||||
const publishButtons = screen.getAllByRole('button', {
|
||||
name: /(?:^|\.)common\.publish(?=$|:)/,
|
||||
})
|
||||
fireEvent.click(publishButtons.at(-1)!)
|
||||
|
||||
expect(mockUpdateWorkflow).toHaveBeenCalledWith(
|
||||
{
|
||||
url: '/apps/app-1/workflows/workflow-version-5',
|
||||
title: 'Release 6',
|
||||
releaseNotes: 'Updated notes',
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
onSettled: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
|
||||
const mutationCallbacks = mockUpdateWorkflow.mock.calls[0]![1]
|
||||
mutationCallbacks.onSuccess()
|
||||
expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1')
|
||||
expect(mockToastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should collect hidden inputs before opening published run links from config actions', async () => {
|
||||
it('should expose the Deploy quick link for editable workflow apps when enabled', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
renderWithConsoleQuery(<AppPublisher publishedAt={Date.now()} />, {
|
||||
systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
|
||||
expect(sectionProps.actions?.showDeployAction).toBe(true)
|
||||
expect(sectionProps.actions?.appURL).toContain('/workflow/token-1')
|
||||
})
|
||||
|
||||
it('should collect hidden inputs before opening the web app from its config action', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
@@ -309,6 +381,8 @@ describe('AppPublisher', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
|
||||
expect(sectionProps.actions?.showRunConfig).toBe(true)
|
||||
fireEvent.click(screen.getByText('publisher-run-config'))
|
||||
|
||||
expect(
|
||||
@@ -330,55 +404,21 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should open batch run config links with the configured hidden inputs', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
inputs={[
|
||||
{
|
||||
variable: 'batch_secret',
|
||||
label: 'Batch Secret',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
hide: true,
|
||||
default: '',
|
||||
} as any,
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-batch-run-config'))
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Batch Secret'), {
|
||||
target: { value: 'batch-value' },
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep workflow tool drawer mounted after closing the publish popover', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
render(<AppPublisher publishedAt={Date.now()} toolPublished />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.actions).toEqual(
|
||||
expect.objectContaining({
|
||||
toolPublished: true,
|
||||
workflowToolOutdated: false,
|
||||
}),
|
||||
)
|
||||
fireEvent.click(screen.getByText('publisher-workflow-tool'))
|
||||
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
@@ -401,14 +441,9 @@ describe('AppPublisher', () => {
|
||||
expect(sectionProps.actions?.workflowToolAvailable).toBe(false)
|
||||
})
|
||||
|
||||
it('should close embedded and access control panels through child callbacks', async () => {
|
||||
it('should close access control through its child callback', async () => {
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-embed'))
|
||||
fireEvent.click(screen.getByText('close-embedded-modal'))
|
||||
expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-access-control'))
|
||||
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
|
||||
@@ -443,25 +478,6 @@ describe('AppPublisher', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should open the installed explore page through the async window helper', async () => {
|
||||
let openedUrl = ''
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
|
||||
openedUrl = await resolver()
|
||||
})
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
|
||||
expect(openedUrl).toBe('/installed/installed-1')
|
||||
expect(sectionProps.actions?.appURL).toBe(`https://example.com${basePath}/chat/token-1`)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore the trigger when the publish button is disabled', () => {
|
||||
render(<AppPublisher disabled publishedAt={Date.now()} onToggle={mockOnToggle} />)
|
||||
|
||||
@@ -479,8 +495,13 @@ describe('AppPublisher', () => {
|
||||
const onRestore = vi.fn().mockResolvedValue(undefined)
|
||||
mockOnPublish.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} onRestore={onRestore} />,
|
||||
const { rerender } = render(
|
||||
<AppPublisher
|
||||
hasUnpublishedChanges
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(hotkeyMocks.hotkeys).toContain('Mod+Shift+P')
|
||||
@@ -491,6 +512,30 @@ describe('AppPublisher', () => {
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
rerender(
|
||||
<AppPublisher
|
||||
hasUnpublishedChanges={false}
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
hotkeyMocks.handlers.at(-1)!({ preventDefault })
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<AppPublisher
|
||||
hasUnpublishedChanges
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
hotkeyMocks.handlers.at(-1)!({ preventDefault })
|
||||
await waitFor(() => {
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-summary-restore'))
|
||||
|
||||
@@ -500,13 +545,44 @@ describe('AppPublisher', () => {
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the popover open when restore fails and reset published state after publish failures', async () => {
|
||||
it('should require an explicit model selection when publishing in multiple model mode', () => {
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
debugWithMultipleModel
|
||||
hasUnpublishedChanges
|
||||
multipleModelConfigs={[
|
||||
{
|
||||
id: 'model-1',
|
||||
model: 'gpt-4o',
|
||||
provider: 'openai',
|
||||
parameters: {},
|
||||
},
|
||||
]}
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
hotkeyMocks.handlers[0]!({ preventDefault })
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockOnPublish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep the popover open when restore and publish fail', async () => {
|
||||
const preventDefault = vi.fn()
|
||||
const onRestore = vi.fn().mockRejectedValue(new Error('restore failed'))
|
||||
mockOnPublish.mockRejectedValueOnce(new Error('publish failed'))
|
||||
|
||||
render(
|
||||
<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} onRestore={onRestore} />,
|
||||
<AppPublisher
|
||||
hasUnpublishedChanges
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
|
||||
hotkeyMocks.handlers[0]!({ preventDefault })
|
||||
@@ -526,57 +602,6 @@ describe('AppPublisher', () => {
|
||||
expect(screen.getByText('publisher-summary-publish'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should report missing explore installations', async () => {
|
||||
mockFetchInstalledAppList.mockResolvedValueOnce({
|
||||
installed_apps: [],
|
||||
})
|
||||
mockOpenAsyncWindow.mockImplementation(
|
||||
async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
|
||||
try {
|
||||
await resolver()
|
||||
} catch (error) {
|
||||
options.onError(error as Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should report explore errors when the app cannot be opened', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
id: undefined,
|
||||
}
|
||||
mockOpenAsyncWindow.mockImplementation(
|
||||
async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
|
||||
try {
|
||||
await resolver()
|
||||
} catch (error) {
|
||||
options.onError(error as Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('App not found')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show marketplace button and open redirect URL on success', async () => {
|
||||
mockPublishToCreatorsPlatform.mockResolvedValue({
|
||||
redirect_url: 'https://marketplace.example.com/publish?code=abc',
|
||||
@@ -588,6 +613,12 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.actions).toEqual(
|
||||
expect.objectContaining({
|
||||
marketplaceActionDisabled: false,
|
||||
showMarketplaceAction: true,
|
||||
}),
|
||||
)
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -624,6 +655,12 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.actions).toEqual(
|
||||
expect.objectContaining({
|
||||
marketplaceActionDisabled: true,
|
||||
showMarketplaceAction: true,
|
||||
}),
|
||||
)
|
||||
const marketplaceButton = screen
|
||||
.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)
|
||||
.closest('a, button, div[role="button"]') as HTMLElement
|
||||
@@ -637,6 +674,7 @@ describe('AppPublisher', () => {
|
||||
render(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.actions?.showMarketplaceAction).toBe(false)
|
||||
expect(
|
||||
screen.queryByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/),
|
||||
).not.toBeInTheDocument()
|
||||
@@ -656,4 +694,116 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not infer an app mode when app detail is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAppDetail = null
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
|
||||
expect(sectionProps.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
isChatApp: false,
|
||||
isWorkflowApp: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should derive workflow changes from draft and published hashes', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
mockPublishedWorkflow = {
|
||||
created_at: 1_710_000_100,
|
||||
hash: 'published-hash',
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<AppPublisher
|
||||
draftHash="published-hash"
|
||||
draftUpdatedAt={1_710_000_200_000}
|
||||
publishedAt={1_710_000_100_000}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.summary?.hasUnpublishedChanges).toBe(false)
|
||||
|
||||
rerender(
|
||||
<AppPublisher
|
||||
draftHash="changed-draft-hash"
|
||||
draftUpdatedAt={1_710_000_100_000}
|
||||
publishedAt={1_710_000_200_000}
|
||||
/>,
|
||||
)
|
||||
expect(sectionProps.summary?.hasUnpublishedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('should keep workflow publishing available when the published hash is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
mockPublishedWorkflow = {
|
||||
created_at: 1_710_000_100,
|
||||
hash: '',
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
draftHash="draft-hash"
|
||||
draftUpdatedAt={1_710_000_200_000}
|
||||
publishedAt={1_710_000_100_000}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
|
||||
expect(sectionProps.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
hasUnpublishedChanges: true,
|
||||
publishedAt: 1_710_000_100_000,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should refresh the shared workflow query and store after a collaborator publishes', async () => {
|
||||
const setPublishedAt = vi.fn()
|
||||
const workflowStore = {
|
||||
getState: () => ({ setPublishedAt }),
|
||||
}
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
mockFetchPublishedWorkflow.mockResolvedValue({
|
||||
created_at: 1_710_000_300,
|
||||
hash: 'published-hash',
|
||||
})
|
||||
|
||||
render(
|
||||
<WorkflowContext value={workflowStore as any}>
|
||||
<AppPublisher draftHash="draft-hash" publishedAt={1_710_000_100_000} />
|
||||
</WorkflowContext>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
collaborationMocks.handler?.({
|
||||
type: 'app_publish_update',
|
||||
userId: 'collaborator-1',
|
||||
data: { action: 'published' },
|
||||
timestamp: 1_710_000_300_000,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchPublishedWorkflow).toHaveBeenCalledWith('app-1')
|
||||
expect(setPublishedAt).toHaveBeenCalledWith(1_710_000_300)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,6 +143,27 @@ describe('PublishWithMultipleModel', () => {
|
||||
expect(screen.queryByText(/(?:^|\.)publishAs(?=$|:)/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable the trigger when publishing is unavailable', () => {
|
||||
render(
|
||||
<PublishWithMultipleModel
|
||||
disabled
|
||||
multipleModelConfigs={[
|
||||
{
|
||||
id: 'config-1',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
parameters: {},
|
||||
},
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /(?:^|\.)operation\.applyConfig(?=$|:)/ }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should open matching model options and call onSelect', () => {
|
||||
const handleSelect = vi.fn()
|
||||
const modelConfig = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -12,45 +13,19 @@ import {
|
||||
} from '../sections'
|
||||
|
||||
vi.mock('../publish-with-multiple-model', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: Record<string, unknown>) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}>
|
||||
default: ({
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
onSelect: (item: Record<string, unknown>) => void
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} onClick={() => onSelect({ model: 'gpt-4o' })}>
|
||||
publish-multiple-model
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../suggested-action', () => ({
|
||||
default: ({
|
||||
children,
|
||||
onClick,
|
||||
link,
|
||||
disabled,
|
||||
actionButton,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
link?: string
|
||||
disabled?: boolean
|
||||
actionButton?: { ariaLabel: string; onClick: () => void }
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
{actionButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={actionButton.ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={actionButton.onClick}
|
||||
>
|
||||
{actionButton.ariaLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div>
|
||||
@@ -60,6 +35,32 @@ vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
const createVersionInfo = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
|
||||
id: 'workflow-version-1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
created_at: 1_710_000_000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 1_710_000_000,
|
||||
updated_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
tool_published: false,
|
||||
version: '2024-03-09T16:00:00Z',
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('app-publisher sections', () => {
|
||||
it('should render restore controls for published chat apps', () => {
|
||||
const handleRestore = vi.fn()
|
||||
@@ -71,10 +72,10 @@ describe('app-publisher sections', () => {
|
||||
formatTimeFromNow={() => '3 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={handleRestore}
|
||||
hasUnpublishedChanges
|
||||
isChatApp
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
@@ -85,6 +86,35 @@ describe('app-publisher sections', () => {
|
||||
expect(handleRestore).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable restore for published chat apps without unpublished changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleRestore = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '3 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={handleRestore}
|
||||
hasUnpublishedChanges={false}
|
||||
isChatApp
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
publishedAt={Date.now()}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const restoreButton = screen.getByRole('button', {
|
||||
name: /(?:^|\.)common\.restore(?=$|:)/,
|
||||
})
|
||||
expect(restoreButton).toBeDisabled()
|
||||
await user.click(restoreButton)
|
||||
expect(handleRestore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose the access control warning when subjects are missing', () => {
|
||||
render(
|
||||
<PublisherAccessSection
|
||||
@@ -100,7 +130,7 @@ describe('app-publisher sections', () => {
|
||||
expect(screen.getByText(/(?:^|\.)publishApp\.notSetDesc(?=$|:)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the publish update action when the draft has not been published yet', () => {
|
||||
it('should render the initial publish action when the draft has not been published yet', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
@@ -111,17 +141,118 @@ describe('app-publisher sections', () => {
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/(?:^|\.)common\.publishUpdate(?=$|:)/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/(?:^|\.)common\.notPublishedYet(?=$|:)/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('P')).toBeInTheDocument()
|
||||
expect(screen.getByText(/(?:^|\.)common\.unpublishedChanges(?=$|:)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple-model publishing', () => {
|
||||
it('should expose naming for an unnamed published workflow with no draft changes', () => {
|
||||
const onEditVersion = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={1_710_000_000_000}
|
||||
formatTimeFromNow={() => '17 days ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges={false}
|
||||
isChatApp={false}
|
||||
isWorkflowApp
|
||||
multipleModelConfigs={[]}
|
||||
onEditVersion={onEditVersion}
|
||||
publishDisabled={false}
|
||||
publishedAt={1_710_000_100_000}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
versionInfo={createVersionInfo()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('2024-03-09T16:00:00Z')).toBeInTheDocument()
|
||||
const nameButton = screen.getByRole('button', {
|
||||
name: /versionHistory\.nameIt\b/,
|
||||
})
|
||||
fireEvent.click(nameButton)
|
||||
expect(onEditVersion).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: /common\.published\b/ })).toBeDisabled()
|
||||
expect(screen.queryByText('P')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/common\.noChanges\b/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show named workflow metadata and publish update when its draft changed', () => {
|
||||
const onEditVersion = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={1_710_000_200_000}
|
||||
formatTimeFromNow={() => '2 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges
|
||||
isChatApp={false}
|
||||
isWorkflowApp
|
||||
multipleModelConfigs={[]}
|
||||
onEditVersion={onEditVersion}
|
||||
publishDisabled={false}
|
||||
publishedAt={1_710_000_100_000}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
versionInfo={createVersionInfo({
|
||||
marked_name: 'Sprint-42',
|
||||
marked_comment: 'Fixed data synchronization and page loading.',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Sprint-42')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fixed data synchronization and page loading.')).toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /versionHistory\.editVersionInfo\b/,
|
||||
}),
|
||||
)
|
||||
expect(onEditVersion).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: /common\.publishUpdate\b/ })).toBeEnabled()
|
||||
expect(screen.getByText(/common\.unpublishedChanges\b/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/common\.savedAt\b/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep non-workflow apps free of workflow version details and saved time', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={1_710_000_200_000}
|
||||
formatTimeFromNow={() => '2 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges
|
||||
isChatApp
|
||||
isWorkflowApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
publishedAt={1_710_000_100_000}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/common\.latestPublished\b/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('#5')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/versionHistory\.nameIt\b/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/common\.unpublishedChanges\b/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/common\.savedAt\b/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep multiple-model publishing available without publish config changes', () => {
|
||||
const handlePublish = vi.fn()
|
||||
|
||||
render(
|
||||
@@ -131,11 +262,11 @@ describe('app-publisher sections', () => {
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={handlePublish}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges={false}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[{ id: '1' } as any]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishedAt={Date.now()}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
@@ -146,6 +277,27 @@ describe('app-publisher sections', () => {
|
||||
expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' })
|
||||
})
|
||||
|
||||
it('should disable multiple-model publishing when publishing is unavailable', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges={false}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[{ id: '1' } as any]}
|
||||
publishDisabled
|
||||
publishedAt={Date.now()}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'publish-multiple-model' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render the upgrade hint when the start node limit is exceeded', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
@@ -157,7 +309,6 @@ describe('app-publisher sections', () => {
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
startNodeLimitExceeded
|
||||
upgradeHighlightStyle={{}}
|
||||
@@ -213,12 +364,13 @@ describe('app-publisher sections', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
|
||||
const handleOpenInExplore = vi.fn()
|
||||
const handleEmbed = vi.fn()
|
||||
it('should render the published workflow actions with Workflow as Tool after Marketplace', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleOpenRunConfig = vi.fn()
|
||||
const onConfigureWorkflowTool = vi.fn()
|
||||
const onPublishToMarketplace = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'workflow-app',
|
||||
@@ -232,92 +384,280 @@ describe('app-publisher sections', () => {
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
missingStartNode={false}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
showBatchRunConfig
|
||||
showDeployAction
|
||||
showMarketplaceAction
|
||||
showRunConfig
|
||||
toolPublished
|
||||
workflowToolAvailable={false}
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated={false}
|
||||
workflowToolMessage="workflow-disabled"
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
onPublishToMarketplace={onPublishToMarketplace}
|
||||
onConfigureWorkflowTool={onConfigureWorkflowTool}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/(?:^|\.)common\.batchRunApp(?=$|:)/)).toHaveAttribute(
|
||||
'data-link',
|
||||
'https://example.com/app?mode=batch',
|
||||
expect(screen.getByRole('link', { name: /common\.openWebApp\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com/app',
|
||||
)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[0]!)
|
||||
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ }))
|
||||
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app')
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[1]!)
|
||||
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch')
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/))
|
||||
expect(handleOpenInExplore).toHaveBeenCalled()
|
||||
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow-disabled')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/workflow-app/access-point',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/workflow-app/deploy',
|
||||
)
|
||||
|
||||
rerender(
|
||||
const marketplaceAction = screen.getByRole('button', {
|
||||
name: /common\.publishToMarketplace\b/,
|
||||
})
|
||||
const workflowToolAction = screen.getByRole('button', {
|
||||
name: /common\.workflowAsTool\b/,
|
||||
})
|
||||
expect(
|
||||
marketplaceAction.compareDocumentPosition(workflowToolAction) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('status', { name: /common\.configureRequired\b/ })).toBeInTheDocument()
|
||||
|
||||
await user.click(marketplaceAction)
|
||||
expect(onPublishToMarketplace).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(workflowToolAction)
|
||||
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should expose Configure and Manage in Tools actions for a ready workflow tool', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfigureWorkflowTool = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'chat-app',
|
||||
mode: AppModeEnum.CHAT,
|
||||
name: 'Chat App',
|
||||
id: 'workflow-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
name: 'Workflow App',
|
||||
}}
|
||||
appURL="https://example.com/app?foo=bar"
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
handlePublish={vi.fn()}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
missingStartNode
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
toolPublished={false}
|
||||
showDeployAction
|
||||
toolPublished
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
onConfigureWorkflowTool={onConfigureWorkflowTool}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.embedIntoSite(?=$|:)/))
|
||||
expect(handleEmbed).toHaveBeenCalled()
|
||||
expect(screen.getByText(/(?:^|\.)common\.accessAPIReference(?=$|:)/)).toBeDisabled()
|
||||
expect(
|
||||
screen.getByRole('status', { name: /common\.workflowAsToolReady\b/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.manageInTools\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/integrations/tools/workflow',
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /common\.configure\b/ }))
|
||||
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show the disabled reason below setup and configured workflow tool actions', () => {
|
||||
const commonProps = {
|
||||
appDetail: {
|
||||
id: 'workflow-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
appURL: 'https://example.com/app',
|
||||
disabledFunctionButton: false,
|
||||
hasHumanInputNode: false,
|
||||
hasTriggerNode: false,
|
||||
onConfigureWorkflowTool: vi.fn(),
|
||||
publishedAt: Date.now(),
|
||||
workflowToolAvailable: false,
|
||||
workflowToolIsLoading: false,
|
||||
workflowToolMessage: 'Workflow tool unavailable',
|
||||
}
|
||||
const { rerender } = render(<PublisherActionsSection {...commonProps} toolPublished={false} />)
|
||||
|
||||
const setupAction = screen.getByRole('button', { name: /common\.workflowAsTool\b/ })
|
||||
const setupReason = screen.getByText('Workflow tool unavailable')
|
||||
expect(setupAction).toBeDisabled()
|
||||
expect(setupReason).toBeVisible()
|
||||
expect(
|
||||
setupAction.compareDocumentPosition(setupReason) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
|
||||
rerender(<PublisherActionsSection {...commonProps} toolPublished />)
|
||||
|
||||
const configureAction = screen.getByRole('button', { name: /common\.configure\b/ })
|
||||
const manageAction = screen.getByRole('button', { name: /common\.manageInTools\b/ })
|
||||
const configuredReason = screen.getByText('Workflow tool unavailable')
|
||||
expect(configureAction).toBeDisabled()
|
||||
expect(manageAction).toBeDisabled()
|
||||
expect(configuredReason).toBeVisible()
|
||||
expect(
|
||||
manageAction.compareDocumentPosition(configuredReason) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should surface update-needed and loading states for a configured workflow tool', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfigureWorkflowTool = vi.fn()
|
||||
const commonProps = {
|
||||
appDetail: {
|
||||
id: 'workflow-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
appURL: 'https://example.com/app',
|
||||
disabledFunctionButton: false,
|
||||
hasHumanInputNode: false,
|
||||
hasTriggerNode: false,
|
||||
onConfigureWorkflowTool,
|
||||
publishedAt: Date.now(),
|
||||
toolPublished: true,
|
||||
workflowToolAvailable: true,
|
||||
}
|
||||
const { rerender } = render(
|
||||
<PublisherActionsSection
|
||||
{...commonProps}
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('status', { name: /common\.workflowAsToolUpdateNeeded\b/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/common\.workflowAsToolTip\b/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /common\.workflowAsToolReconfigure\b/ }))
|
||||
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'trigger-app', mode: AppModeEnum.WORKFLOW }}
|
||||
{...commonProps}
|
||||
workflowToolIsLoading
|
||||
workflowToolOutdated={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.workflowAsTool\b/ })).toBeDisabled()
|
||||
expect(screen.getByRole('status', { name: /loading\b/ })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/common\.workflowAsToolTip\b/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep Access Point and Deploy available for trigger workflows', () => {
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'trigger-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode
|
||||
missingStartNode={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
toolPublished={false}
|
||||
publishedAt={Date.now()}
|
||||
showDeployAction
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/(?:^|\.)common\.runApp(?=$|:)/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/(?:^|\.)common\.openWebApp(?=$|:)/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/trigger-app/access-point',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/trigger-app/deploy',
|
||||
)
|
||||
})
|
||||
|
||||
it('should expose unavailable quick links as disabled buttons before the first publish', () => {
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
publishedAt={undefined}
|
||||
showDeployAction
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/(?:^|\.)common\.openWebApp(?=$|:)/).closest('button')).toBeDisabled()
|
||||
expect(
|
||||
screen.getByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/).closest('button'),
|
||||
).toBeDisabled()
|
||||
expect(screen.getByText(/(?:^|\.)appMenus\.deploy(?=$|:)/).closest('button')).toBeDisabled()
|
||||
expect(
|
||||
screen.getByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/).closest('button'),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show the disabled reason when hovering an unavailable action', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="Open web app unavailable"
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
publishedAt={undefined}
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.hover(screen.getByRole('button', { name: /common\.openWebApp\b/ }))
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable')
|
||||
})
|
||||
|
||||
it('should keep an unavailable action with a tooltip keyboard focusable', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="Open web app unavailable"
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
publishedAt={undefined}
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.tab()
|
||||
|
||||
const action = screen.getByRole('button', { name: /common\.openWebApp\b/ })
|
||||
expect(action).toHaveFocus()
|
||||
expect(action).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(action).toHaveAccessibleDescription('Open web app unavailable')
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,79 +1,133 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SuggestedAction from '../suggested-action'
|
||||
|
||||
describe('SuggestedAction', () => {
|
||||
it('should render an enabled external link', () => {
|
||||
render(<SuggestedAction link="https://example.com/docs">Open docs</SuggestedAction>)
|
||||
it('should render an enabled external link with supporting copy', () => {
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
external
|
||||
description="Read the documentation"
|
||||
>
|
||||
Open docs
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Open docs' })
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAccessibleDescription('Read the documentation')
|
||||
expect(screen.getByText('Read the documentation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should block clicks when disabled', () => {
|
||||
it('should render internal destinations without opening a new tab', () => {
|
||||
render(
|
||||
<SuggestedAction link="/app/app-1/deploy" description="Push versions to environments">
|
||||
Deploy
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Deploy' })
|
||||
expect(link).toHaveAttribute('href', '/app/app-1/deploy')
|
||||
expect(link).not.toHaveAttribute('target')
|
||||
expect(link).toHaveAccessibleDescription('Push versions to environments')
|
||||
})
|
||||
|
||||
it('should use native disabled button semantics for unavailable links', () => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs" disabled onClick={handleClick}>
|
||||
Disabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement
|
||||
fireEvent.click(link)
|
||||
|
||||
expect(link).not.toHaveAttribute('href')
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward click events when enabled', () => {
|
||||
const handleClick = vi.fn((event: ReactMouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs" onClick={handleClick}>
|
||||
Enabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Enabled action' }))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render and trigger the trailing action button when configured', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
disabled
|
||||
description="Unavailable until published"
|
||||
onClick={handleClick}
|
||||
>
|
||||
Disabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Disabled action' })
|
||||
fireEvent.click(action)
|
||||
|
||||
expect(action).toBeDisabled()
|
||||
expect(screen.queryByRole('link', { name: /Disabled action/ })).not.toBeInTheDocument()
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep an explained disabled action in the keyboard tab order', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction disabled focusableWhenDisabled onClick={handleClick}>
|
||||
Disabled action with explanation
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
await user.tab()
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Disabled action with explanation' })
|
||||
expect(action).toHaveFocus()
|
||||
expect(action).toHaveAttribute('aria-disabled', 'true')
|
||||
|
||||
await user.keyboard('{Enter}')
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render and trigger an enabled button action', () => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
description="Use as a tool in other apps"
|
||||
endIcon={<span data-testid="configure-icon" />}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Workflow as Tool
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workflow as Tool' }))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('configure-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the main link separate from a trailing action button', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/app"
|
||||
external
|
||||
actionButton={{
|
||||
ariaLabel: 'Configure action',
|
||||
icon: <span>config</span>,
|
||||
onClick: handleActionClick,
|
||||
}}
|
||||
>
|
||||
Configurable action
|
||||
Open web app
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute(
|
||||
expect(screen.getByRole('link', { name: 'Open web app' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com/docs',
|
||||
'https://example.com/app',
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
expect(handleActionClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should block action button clicks when disabled', () => {
|
||||
it('should disable both controls when an action with a trailing button is unavailable', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
link="https://example.com/app"
|
||||
external
|
||||
disabled
|
||||
actionButton={{
|
||||
ariaLabel: 'Configure action',
|
||||
@@ -81,11 +135,15 @@ describe('SuggestedAction', () => {
|
||||
onClick: handleActionClick,
|
||||
}}
|
||||
>
|
||||
Disabled with action
|
||||
Open web app
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
expect(screen.getByRole('button', { name: 'Open web app' })).toBeDisabled()
|
||||
|
||||
const actionButton = screen.getByRole('button', { name: 'Configure action' })
|
||||
fireEvent.click(actionButton)
|
||||
expect(actionButton).toBeDisabled()
|
||||
expect(handleActionClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
|
||||
type ActionTooltipProps = {
|
||||
children: ReactNode
|
||||
disabled: boolean
|
||||
tooltip?: ReactNode
|
||||
}
|
||||
|
||||
const ActionTooltip = ({ children, disabled, tooltip }: ActionTooltipProps) => {
|
||||
if (!tooltip) return <>{children}</>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
className={cn('flex w-full', disabled && 'cursor-not-allowed *:pointer-events-none')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent role="tooltip">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionTooltip
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_PUBLISH_DRAFT_CHANGED = 'APP_PUBLISH_DRAFT_CHANGED'
|
||||
@@ -2,8 +2,8 @@ import type {
|
||||
AppPublisherProps,
|
||||
AppPublisherPublishParams,
|
||||
} from '@/app/components/app/app-publisher'
|
||||
import type { ConfigurationPublishConfig } from '@/app/components/app/configuration/hooks/use-configuration-utils'
|
||||
import type { Features, FileUpload } from '@/app/components/base/features/types'
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppPublisher } from '@/app/components/app/app-publisher'
|
||||
@@ -23,18 +22,12 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
type PublishedModelConfig = ModelConfig & {
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
type Props = Omit<AppPublisherProps, 'onPublish'> & {
|
||||
onPublish?: (
|
||||
params?: AppPublisherPublishParams,
|
||||
features?: Features,
|
||||
) => Promise<unknown> | unknown
|
||||
publishedConfig: {
|
||||
modelConfig: PublishedModelConfig
|
||||
}
|
||||
publishedConfig: ConfigurationPublishConfig
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
@@ -43,6 +36,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
const features = useFeatures((s) => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
|
||||
const { resetAppConfig } = props
|
||||
const {
|
||||
more_like_this,
|
||||
opening_statement,
|
||||
@@ -54,10 +48,9 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
retriever_resource,
|
||||
annotation_reply,
|
||||
file_upload,
|
||||
resetAppConfig,
|
||||
} = props.publishedConfig.modelConfig
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const handleConfirm = () => {
|
||||
resetAppConfig?.()
|
||||
const { features, setFeatures } = featuresStore!.getState()
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
@@ -90,9 +83,9 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
number_limits: file_upload?.number_limits || file_upload?.image?.number_limits || 3,
|
||||
} as FileUpload
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
setFeatures(newFeatures, { silent: true })
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
}
|
||||
|
||||
const handlePublish = useCallback(
|
||||
(params?: AppPublisherPublishParams) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { use, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections'
|
||||
@@ -20,10 +21,8 @@ import {
|
||||
createWorkflowLaunchInitialValues,
|
||||
isWorkflowLaunchInputSupported,
|
||||
} from '@/app/components/app/overview/app-card-utils'
|
||||
import EmbeddedModal from '@/app/components/app/overview/embedded'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
|
||||
import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions'
|
||||
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
|
||||
import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button'
|
||||
@@ -31,18 +30,20 @@ import { collaborationManager } from '@/app/components/workflow/collaboration/co
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { isCurrentWorkspaceEditorAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
|
||||
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
import { fetchPublishedWorkflow } from '@/service/workflow'
|
||||
import {
|
||||
appWorkflowQueryOptions,
|
||||
useAppWorkflow,
|
||||
useInvalidateAppWorkflow,
|
||||
useUpdateWorkflow,
|
||||
} from '@/service/use-workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AccessControl from '../app-access-control'
|
||||
import { APP_PUBLISH_HOTKEY } from './hotkeys'
|
||||
import {
|
||||
@@ -50,12 +51,12 @@ import {
|
||||
PublisherActionsSection,
|
||||
PublisherSummarySection,
|
||||
} from './sections'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import {
|
||||
getDisabledFunctionTooltip,
|
||||
getPublisherAppUrl,
|
||||
isPublisherAccessConfigured,
|
||||
} from './utils'
|
||||
import VersionInfoModal from './version-info-modal'
|
||||
|
||||
export type AppPublisherProps = {
|
||||
disabled?: boolean
|
||||
@@ -63,6 +64,10 @@ export type AppPublisherProps = {
|
||||
publishedAt?: number
|
||||
/** only needed in workflow / chatflow mode */
|
||||
draftUpdatedAt?: number
|
||||
/** Current persisted workflow draft hash, used to compare with the published workflow. */
|
||||
draftHash?: string
|
||||
/** Non-workflow editors should pass their local dirty state. */
|
||||
hasUnpublishedChanges?: boolean
|
||||
debugWithMultipleModel?: boolean
|
||||
multipleModelConfigs?: ModelAndParameter[]
|
||||
/** modelAndParameter is passed when debugWithMultipleModel is true */
|
||||
@@ -94,6 +99,8 @@ export function AppPublisher({
|
||||
publishDisabled = false,
|
||||
publishedAt,
|
||||
draftUpdatedAt,
|
||||
draftHash,
|
||||
hasUnpublishedChanges,
|
||||
debugWithMultipleModel = false,
|
||||
multipleModelConfigs = [],
|
||||
onPublish,
|
||||
@@ -112,32 +119,44 @@ export function AppPublisher({
|
||||
}: AppPublisherProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [published, setPublished] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
|
||||
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
|
||||
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false)
|
||||
const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('')
|
||||
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<
|
||||
Record<string, WorkflowLaunchInputValue>
|
||||
>({})
|
||||
const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
|
||||
const [editVersionInfoOpen, setEditVersionInfoOpen] = useState(false)
|
||||
|
||||
const workflowStore = use(WorkflowContext)
|
||||
const appDetail = useAppStore((state) => state.appDetail)
|
||||
const setAppDetail = useAppStore((state) => state.setAppDetail)
|
||||
const canManageTools = useCanManageTools()
|
||||
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
|
||||
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
|
||||
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(
|
||||
appDetail?.mode || AppModeEnum.CHAT,
|
||||
const appMode = appDetail?.mode
|
||||
const isWorkflowApp = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
|
||||
const isChatApp =
|
||||
appMode === AppModeEnum.CHAT ||
|
||||
appMode === AppModeEnum.AGENT_CHAT ||
|
||||
appMode === AppModeEnum.COMPLETION
|
||||
const { data: publishedWorkflow, isSuccess: isPublishedWorkflowSuccess } = useAppWorkflow(
|
||||
isWorkflowApp ? (appDetail?.id ?? '') : '',
|
||||
)
|
||||
const currentPublishedAt =
|
||||
isWorkflowApp && isPublishedWorkflowSuccess
|
||||
? publishedWorkflow?.created_at
|
||||
? publishedWorkflow.created_at * 1000
|
||||
: undefined
|
||||
: publishedAt
|
||||
const { mutate: updateWorkflow } = useUpdateWorkflow()
|
||||
const hiddenLaunchVariables: WorkflowHiddenStartVariable[] = (inputs ?? []).filter(
|
||||
(input) => input.hide === true,
|
||||
)
|
||||
@@ -166,7 +185,6 @@ export function AppPublisher({
|
||||
appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
)
|
||||
const invalidateAppWorkflow = useInvalidateAppWorkflow()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const isAppAccessSet = isPublisherAccessConfigured(appDetail, appAccessSubjects)
|
||||
|
||||
@@ -176,10 +194,10 @@ export function AppPublisher({
|
||||
appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS &&
|
||||
!userCanAccessApp?.result,
|
||||
)
|
||||
const disabledFunctionButton = !publishedAt || missingStartNode || noAccessPermission
|
||||
const disabledFunctionButton = !currentPublishedAt || missingStartNode || noAccessPermission
|
||||
const disabledFunctionTooltip = getDisabledFunctionTooltip({
|
||||
t,
|
||||
publishedAt,
|
||||
publishedAt: currentPublishedAt,
|
||||
missingStartNode,
|
||||
noAccessPermission,
|
||||
})
|
||||
@@ -187,7 +205,6 @@ export function AppPublisher({
|
||||
async function handlePublish(params?: ModelAndParameter | PublishWorkflowParams) {
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
setPublished(true)
|
||||
|
||||
const appId = appDetail?.id
|
||||
const socket = appId ? webSocketClient.getSocket(appId) : null
|
||||
@@ -214,7 +231,6 @@ export function AppPublisher({
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[app-publisher] publish failed', error)
|
||||
setPublished(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,25 +249,6 @@ export function AppPublisher({
|
||||
|
||||
onToggle?.(nextOpen)
|
||||
setOpen(nextOpen)
|
||||
|
||||
if (nextOpen) setPublished(false)
|
||||
}
|
||||
|
||||
async function handleOpenInExplore() {
|
||||
await openAsyncWindow(
|
||||
async () => {
|
||||
if (!appDetail?.id) throw new Error('App not found')
|
||||
const { installed_apps } = await fetchInstalledAppList(appDetail.id)
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}`
|
||||
throw new Error(t(($) => $.notPublishedYet, { ns: 'app' }))
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error(`${err.message || err}`)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function handleAccessControlUpdate() {
|
||||
@@ -304,9 +301,50 @@ export function AppPublisher({
|
||||
}
|
||||
}
|
||||
|
||||
const hasPublishedVersion = Boolean(currentPublishedAt)
|
||||
const workflowHasUnpublishedChanges =
|
||||
!currentPublishedAt ||
|
||||
!draftHash ||
|
||||
!publishedWorkflow?.hash ||
|
||||
draftHash !== publishedWorkflow.hash
|
||||
const resolvedHasUnpublishedChanges =
|
||||
hasUnpublishedChanges ?? (isWorkflowApp ? workflowHasUnpublishedChanges : !currentPublishedAt)
|
||||
|
||||
function handleOpenVersionInfo() {
|
||||
if (!publishedWorkflow) return
|
||||
|
||||
handleOpenChange(false)
|
||||
setEditVersionInfoOpen(true)
|
||||
}
|
||||
|
||||
function handleUpdateVersionInfo(params: { id?: string; title: string; releaseNotes: string }) {
|
||||
if (!appDetail?.id || !params.id) return
|
||||
|
||||
updateWorkflow(
|
||||
{
|
||||
url: `/apps/${appDetail.id}/workflows/${params.id}`,
|
||||
title: params.title,
|
||||
releaseNotes: params.releaseNotes,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t(($) => $['versionHistory.action.updateSuccess'], { ns: 'workflow' }))
|
||||
invalidateAppWorkflow(appDetail.id)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['versionHistory.action.updateFailure'], { ns: 'workflow' }))
|
||||
},
|
||||
onSettled: () => {
|
||||
setEditVersionInfoOpen(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
useHotkey(APP_PUBLISH_HOTKEY, (e) => {
|
||||
if (debugWithMultipleModel) return
|
||||
e.preventDefault()
|
||||
if (publishDisabled || published) return
|
||||
if (publishDisabled || (hasPublishedVersion && !resolvedHasUnpublishedChanges)) return
|
||||
handlePublish()
|
||||
})
|
||||
|
||||
@@ -317,11 +355,10 @@ export function AppPublisher({
|
||||
const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
|
||||
const action = typeof update.data.action === 'string' ? update.data.action : undefined
|
||||
if (action === 'published') {
|
||||
invalidateAppWorkflow(appId)
|
||||
fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`)
|
||||
void queryClient
|
||||
.fetchQuery(appWorkflowQueryOptions(appId))
|
||||
.then((publishedWorkflow) => {
|
||||
if (publishedWorkflow?.created_at)
|
||||
workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
workflowStore?.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[app-publisher] refresh published workflow failed', error)
|
||||
@@ -330,9 +367,8 @@ export function AppPublisher({
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appDetail?.id, invalidateAppWorkflow, workflowStore])
|
||||
}, [appDetail?.id, queryClient, workflowStore])
|
||||
|
||||
const hasPublishedVersion = !!publishedAt
|
||||
const workflowToolVisible =
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
|
||||
const workflowToolAvailableForUser = workflowToolAvailable && canManageTools
|
||||
@@ -353,7 +389,7 @@ export function AppPublisher({
|
||||
const workflowTool = useConfigureButton({
|
||||
enabled: workflowToolVisible && canManageTools,
|
||||
published: workflowToolPublished,
|
||||
detailNeedUpdate: workflowToolPublished && published,
|
||||
detailNeedUpdate: workflowToolPublished && !resolvedHasUnpublishedChanges,
|
||||
workflowAppId: appDetail?.id ?? '',
|
||||
icon: workflowToolIcon,
|
||||
name: appDetail?.name ?? '',
|
||||
@@ -395,20 +431,23 @@ export function AppPublisher({
|
||||
alignOffset={crossAxisOffset}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<div className="w-98 rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={debugWithMultipleModel}
|
||||
draftUpdatedAt={draftUpdatedAt}
|
||||
formatTimeFromNow={formatTimeFromNow}
|
||||
handlePublish={handlePublish}
|
||||
handleRestore={handleRestore}
|
||||
hasUnpublishedChanges={resolvedHasUnpublishedChanges}
|
||||
isChatApp={isChatApp}
|
||||
isWorkflowApp={isWorkflowApp}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onEditVersion={handleOpenVersionInfo}
|
||||
publishDisabled={publishDisabled}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
publishedAt={currentPublishedAt}
|
||||
startNodeLimitExceeded={startNodeLimitExceeded}
|
||||
upgradeHighlightStyle={upgradeHighlightStyle}
|
||||
versionInfo={publishedWorkflow}
|
||||
/>
|
||||
<PublisherAccessSection
|
||||
enabled={systemFeatures.webapp_auth.enabled}
|
||||
@@ -428,57 +467,29 @@ export function AppPublisher({
|
||||
appURL={appURL}
|
||||
disabledFunctionButton={disabledFunctionButton}
|
||||
disabledFunctionTooltip={disabledFunctionTooltip}
|
||||
handleEmbed={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleOpenChange(false)
|
||||
}}
|
||||
handleOpenInExplore={() => {
|
||||
handleOpenChange(false)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
|
||||
handlePublish={handlePublish}
|
||||
hasHumanInputNode={hasHumanInputNode}
|
||||
hasTriggerNode={hasTriggerNode}
|
||||
missingStartNode={missingStartNode}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
showBatchRunConfig={
|
||||
hiddenLaunchVariables.length > 0 &&
|
||||
(appDetail?.mode === AppModeEnum.WORKFLOW ||
|
||||
appDetail?.mode === AppModeEnum.COMPLETION)
|
||||
marketplaceActionDisabled={!currentPublishedAt}
|
||||
publishedAt={currentPublishedAt}
|
||||
publishingToMarketplace={publishingToMarketplace}
|
||||
showDeployAction={
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW &&
|
||||
isCurrentWorkspaceEditor &&
|
||||
systemFeatures.enable_app_deploy
|
||||
}
|
||||
showMarketplaceAction={systemFeatures.enable_creators_platform}
|
||||
showRunConfig={hiddenLaunchVariables.length > 0}
|
||||
toolPublished={toolPublished}
|
||||
toolPublished={workflowToolPublished}
|
||||
workflowToolAvailable={workflowToolAvailableForUser}
|
||||
workflowToolIsLoading={workflowTool.isLoading}
|
||||
workflowToolOutdated={workflowTool.outdated}
|
||||
workflowToolMessage={workflowToolMessage}
|
||||
workflowToolOutdated={workflowTool.outdated}
|
||||
onConfigureWorkflowTool={openWorkflowToolDrawer}
|
||||
onPublishToMarketplace={handlePublishToMarketplace}
|
||||
/>
|
||||
{systemFeatures.enable_creators_platform && (
|
||||
<div className="border-t border-divider-subtle p-4">
|
||||
<SuggestedAction
|
||||
icon={<span className="i-ri-store-line size-4" />}
|
||||
disabled={!publishedAt || publishingToMarketplace}
|
||||
onClick={handlePublishToMarketplace}
|
||||
>
|
||||
{publishingToMarketplace
|
||||
? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' })
|
||||
: t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
<EmbeddedModal
|
||||
siteInfo={appDetail?.site}
|
||||
isShow={embeddingModalOpen}
|
||||
onClose={() => setEmbeddingModalOpen(false)}
|
||||
appBaseUrl={appBaseURL}
|
||||
accessToken={accessToken}
|
||||
hiddenInputs={hiddenLaunchVariables}
|
||||
/>
|
||||
{showAppAccessControl && (
|
||||
<AccessControl
|
||||
app={appDetail!}
|
||||
@@ -499,6 +510,14 @@ export function AppPublisher({
|
||||
onSubmit={handleWorkflowLaunchConfirm}
|
||||
/>
|
||||
</Popover>
|
||||
{editVersionInfoOpen && (
|
||||
<VersionInfoModal
|
||||
isOpen={editVersionInfoOpen}
|
||||
versionInfo={publishedWorkflow ?? undefined}
|
||||
onClose={() => setEditVersionInfoOpen(false)}
|
||||
onPublish={handleUpdateVersionInfo}
|
||||
/>
|
||||
)}
|
||||
{workflowToolDrawerOpen && canManageTools && (
|
||||
<WorkflowToolDrawer
|
||||
isAdd={!workflowToolPublished}
|
||||
|
||||
@@ -19,11 +19,13 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import ModelIcon from '../../header/account-setting/model-provider-page/model-icon'
|
||||
|
||||
type PublishWithMultipleModelProps = {
|
||||
disabled?: boolean
|
||||
multipleModelConfigs: ModelAndParameter[]
|
||||
// textGenerationModelList?: Model[]
|
||||
onSelect: (v: ModelAndParameter) => void
|
||||
}
|
||||
const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
|
||||
disabled = false,
|
||||
multipleModelConfigs,
|
||||
// textGenerationModelList = [],
|
||||
onSelect,
|
||||
@@ -55,13 +57,13 @@ const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
|
||||
}
|
||||
})
|
||||
|
||||
const triggerDisabled = disabled || !validModelConfigs.length
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={!validModelConfigs.length}
|
||||
render={
|
||||
<Button variant="primary" disabled={!validModelConfigs.length} className="mt-3 w-full" />
|
||||
}
|
||||
disabled={triggerDisabled}
|
||||
render={<Button variant="primary" disabled={triggerDisabled} className="w-full" />}
|
||||
>
|
||||
<>
|
||||
{t(($) => $['operation.applyConfig'], { ns: 'appDebug' })}
|
||||
@@ -69,12 +71,12 @@ const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[288px] p-1">
|
||||
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
|
||||
<div className="flex h-5.5 items-center px-3 text-xs font-medium text-text-tertiary">
|
||||
{t(($) => $.publishAs, { ns: 'appDebug' })}
|
||||
</div>
|
||||
{validModelConfigs.map((item, index) => (
|
||||
<DropdownMenuItem key={item.id} className="gap-0 px-3" onClick={() => onSelect(item)}>
|
||||
<span className="min-w-[18px] italic">#{index + 1}</span>
|
||||
<span className="min-w-4.5 italic">#{index + 1}</span>
|
||||
<ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" />
|
||||
<div
|
||||
className="ml-1 truncate text-text-secondary"
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { AppPublisherProps } from './index'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import type { PublishWorkflowParams, VersionHistory } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import { APP_PUBLISH_HOTKEY } from './hotkeys'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import { ACCESS_MODE_MAP } from './utils'
|
||||
import WorkflowToolAction from './workflow-tool-action'
|
||||
|
||||
type SummarySectionProps = Pick<
|
||||
AppPublisherProps,
|
||||
@@ -29,9 +30,12 @@ type SummarySectionProps = Pick<
|
||||
formatTimeFromNow: (value: number) => string
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
handleRestore: () => Promise<void>
|
||||
hasUnpublishedChanges?: boolean
|
||||
isChatApp: boolean
|
||||
published: boolean
|
||||
isWorkflowApp?: boolean
|
||||
onEditVersion?: () => void
|
||||
upgradeHighlightStyle: CSSProperties
|
||||
versionInfo?: VersionHistory | null
|
||||
}
|
||||
|
||||
type AccessSectionProps = {
|
||||
@@ -44,12 +48,7 @@ type AccessSectionProps = {
|
||||
|
||||
type ActionsSectionProps = Pick<
|
||||
AppPublisherProps,
|
||||
| 'hasHumanInputNode'
|
||||
| 'hasTriggerNode'
|
||||
| 'missingStartNode'
|
||||
| 'toolPublished'
|
||||
| 'publishedAt'
|
||||
| 'workflowToolAvailable'
|
||||
'hasHumanInputNode' | 'hasTriggerNode' | 'publishedAt' | 'toolPublished' | 'workflowToolAvailable'
|
||||
> & {
|
||||
appDetail:
|
||||
| {
|
||||
@@ -66,17 +65,17 @@ type ActionsSectionProps = Pick<
|
||||
appURL: string
|
||||
disabledFunctionButton: boolean
|
||||
disabledFunctionTooltip?: string
|
||||
handleEmbed: () => void
|
||||
handleOpenInExplore: () => void
|
||||
handleOpenRunConfig?: (url: string) => void
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
published: boolean
|
||||
showBatchRunConfig?: boolean
|
||||
marketplaceActionDisabled?: boolean
|
||||
publishingToMarketplace?: boolean
|
||||
showDeployAction?: boolean
|
||||
showMarketplaceAction?: boolean
|
||||
showRunConfig?: boolean
|
||||
workflowToolIsLoading: boolean
|
||||
workflowToolOutdated: boolean
|
||||
workflowToolMessage?: string
|
||||
workflowToolOutdated?: boolean
|
||||
onConfigureWorkflowTool: () => void
|
||||
onPublishToMarketplace?: () => void
|
||||
}
|
||||
|
||||
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
|
||||
@@ -98,97 +97,227 @@ export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MA
|
||||
)
|
||||
}
|
||||
|
||||
const PublisherTimelineMarker = ({ position }: { position: 'top' | 'bottom' }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'relative flex w-4 shrink-0 items-start p-1',
|
||||
position === 'top' ? 'self-stretch' : 'h-4',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="relative z-1 size-2 rounded-full border-2 border-text-quaternary bg-components-panel-bg"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute left-1/2 w-0.5 -translate-x-1/2 bg-divider-subtle',
|
||||
position === 'top' ? 'top-3.5 -bottom-3.5' : '-top-3.5 h-4',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
|
||||
export const PublisherSummarySection = ({
|
||||
debugWithMultipleModel = false,
|
||||
draftUpdatedAt,
|
||||
formatTimeFromNow,
|
||||
handlePublish,
|
||||
handleRestore,
|
||||
hasUnpublishedChanges,
|
||||
isChatApp,
|
||||
isWorkflowApp = false,
|
||||
multipleModelConfigs = [],
|
||||
onEditVersion,
|
||||
publishDisabled = false,
|
||||
published,
|
||||
publishedAt,
|
||||
startNodeLimitExceeded = false,
|
||||
upgradeHighlightStyle,
|
||||
versionInfo,
|
||||
}: SummarySectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const hasPublishedVersion = Boolean(publishedAt)
|
||||
const resolvedHasUnpublishedChanges = hasUnpublishedChanges ?? !hasPublishedVersion
|
||||
const publishedTimestamp =
|
||||
publishedAt || (versionInfo?.created_at ? versionInfo.created_at * 1000 : undefined)
|
||||
const publisherName = versionInfo?.created_by.name
|
||||
const markedName = versionInfo?.marked_name
|
||||
const markedComment = versionInfo?.marked_comment
|
||||
const publishButtonDisabled =
|
||||
publishDisabled || (hasPublishedVersion && !resolvedHasUnpublishedChanges)
|
||||
const publishButtonLabel = !hasPublishedVersion
|
||||
? t(($) => $['common.publish'], { ns: 'workflow' })
|
||||
: resolvedHasUnpublishedChanges
|
||||
? t(($) => $['common.publishUpdate'], { ns: 'workflow' })
|
||||
: t(($) => $['common.published'], { ns: 'workflow' })
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">
|
||||
{publishedAt
|
||||
? t(($) => $['common.latestPublished'], { ns: 'workflow' })
|
||||
: t(($) => $['common.currentDraftUnpublished'], { ns: 'workflow' })}
|
||||
</div>
|
||||
{publishedAt ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['common.publishedAt'], { ns: 'workflow' })} {formatTimeFromNow(publishedAt)}
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start gap-1 px-1 py-0.5">
|
||||
<PublisherTimelineMarker position="top" />
|
||||
{!hasPublishedVersion ? (
|
||||
<p className="min-w-0 flex-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['common.notPublishedYet'], { ns: 'workflow' })}
|
||||
</p>
|
||||
) : isWorkflowApp ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex min-h-4 min-w-0 items-center gap-1">
|
||||
<span className="truncate system-sm-semibold text-text-secondary">
|
||||
{markedName || versionInfo?.version}
|
||||
</span>
|
||||
<span aria-hidden className="system-xs-regular text-text-tertiary">
|
||||
·
|
||||
</span>
|
||||
{markedName ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded text-text-tertiary outline-hidden hover:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
aria-label={t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflow' })}
|
||||
disabled={!versionInfo || !onEditVersion}
|
||||
onClick={onEditVersion}
|
||||
>
|
||||
<span aria-hidden className="i-ri-edit-line size-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-1 rounded text-text-accent outline-hidden hover:text-text-accent-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-wait"
|
||||
disabled={!versionInfo || !onEditVersion}
|
||||
onClick={onEditVersion}
|
||||
>
|
||||
<span aria-hidden className="i-ri-edit-line size-3.5 shrink-0" />
|
||||
<span className="truncate system-xs-medium">
|
||||
{t(($) => $['versionHistory.nameIt'], { ns: 'workflow' })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{markedComment && (
|
||||
<>
|
||||
<p className="line-clamp-3 system-xs-regular wrap-break-word text-text-tertiary">
|
||||
{markedComment}
|
||||
</p>
|
||||
<span aria-hidden className="my-1 h-px w-4 bg-divider-regular" />
|
||||
</>
|
||||
)}
|
||||
{publishedTimestamp && (
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{publisherName
|
||||
? t(($) => $['common.publishedBy'], {
|
||||
ns: 'workflow',
|
||||
time: formatTimeFromNow(publishedTimestamp),
|
||||
author: publisherName,
|
||||
})
|
||||
: `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<p className="truncate system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['common.latestPublished'], { ns: 'workflow' })}
|
||||
</p>
|
||||
{publishedTimestamp && (
|
||||
<p className="truncate system-xs-regular text-text-tertiary">
|
||||
{publisherName
|
||||
? t(($) => $['common.publishedBy'], {
|
||||
ns: 'workflow',
|
||||
time: formatTimeFromNow(publishedTimestamp),
|
||||
author: publisherName,
|
||||
})
|
||||
: `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="h-6 shrink-0 gap-1"
|
||||
onClick={handleRestore}
|
||||
disabled={!resolvedHasUnpublishedChanges}
|
||||
>
|
||||
<span aria-hidden className="i-ri-reset-left-line size-3.5" />
|
||||
{t(($) => $['common.restore'], { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
{debugWithMultipleModel ? (
|
||||
<PublishWithMultipleModel
|
||||
disabled={publishDisabled}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={(item) => handlePublish(item)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishButtonDisabled}
|
||||
>
|
||||
{t(($) => $['common.restore'], { ns: 'workflow' })}
|
||||
{publishButtonDisabled ? (
|
||||
publishButtonLabel
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{publishButtonLabel}</span>
|
||||
<KbdGroup aria-hidden>
|
||||
{APP_PUBLISH_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['common.autoSaved'], { ns: 'workflow' })} ·
|
||||
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{debugWithMultipleModel ? (
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={(item) => handlePublish(item)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{published ? (
|
||||
t(($) => $['common.published'], { ns: 'workflow' })
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
|
||||
<KbdGroup>
|
||||
{APP_PUBLISH_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
{startNodeLimitExceeded && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p
|
||||
className="text-sm/5 font-semibold text-transparent"
|
||||
style={upgradeHighlightStyle}
|
||||
>
|
||||
<span className="block">
|
||||
{t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })}
|
||||
</span>
|
||||
<span className="block">
|
||||
{t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs/4 text-text-secondary">
|
||||
{t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn isShort className="mt-2.25 mb-3 h-8 w-23.25 self-start" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
{startNodeLimitExceeded && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p className="text-sm/5 font-semibold text-transparent" style={upgradeHighlightStyle}>
|
||||
<span className="block">
|
||||
{t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })}
|
||||
</span>
|
||||
<span className="block">
|
||||
{t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs/4 text-text-secondary">
|
||||
{t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn isShort className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 py-0.5 pr-0.5 pl-1">
|
||||
<PublisherTimelineMarker position="bottom" />
|
||||
<p className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
|
||||
{resolvedHasUnpublishedChanges ? (
|
||||
<>
|
||||
{t(($) => $['common.unpublishedChanges'], { ns: 'workflow' })}
|
||||
{isWorkflowApp && Boolean(draftUpdatedAt) && (
|
||||
<>
|
||||
{' · '}
|
||||
{t(($) => $['common.savedAt'], {
|
||||
ns: 'workflow',
|
||||
time: formatTimeFromNow(draftUpdatedAt!),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
t(($) => $['common.noChanges'], { ns: 'workflow' })
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -219,8 +348,9 @@ export const PublisherAccessSection = ({
|
||||
{t(($) => $['publishApp.title'], { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 hover:bg-primary-50 hover:text-text-accent"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-x-0.5 rounded-lg border-0 bg-components-input-bg-normal py-1 pr-2 pl-2.5 text-left outline-hidden hover:bg-primary-50 hover:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
|
||||
@@ -234,7 +364,7 @@ export const PublisherAccessSection = ({
|
||||
<div className="flex size-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-arrow-right-s-line size-4 text-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{!isAppAccessSet && (
|
||||
<p className="mt-1 system-xs-regular text-text-warning">
|
||||
{t(($) => $['publishApp.notSetDesc'], { ns: 'app' })}
|
||||
@@ -246,143 +376,114 @@ export const PublisherAccessSection = ({
|
||||
)
|
||||
}
|
||||
|
||||
const ActionTooltip = ({
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean
|
||||
tooltip?: ReactNode
|
||||
children: ReactNode
|
||||
}) => {
|
||||
if (!disabled || !tooltip) return <>{children}</>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className="flex">{children}</div>} />
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export const PublisherActionsSection = ({
|
||||
appDetail,
|
||||
appURL,
|
||||
disabledFunctionButton,
|
||||
disabledFunctionTooltip,
|
||||
handleEmbed,
|
||||
handleOpenInExplore,
|
||||
handleOpenRunConfig,
|
||||
hasHumanInputNode = false,
|
||||
hasTriggerNode = false,
|
||||
missingStartNode = false,
|
||||
marketplaceActionDisabled = false,
|
||||
publishedAt,
|
||||
showBatchRunConfig = false,
|
||||
publishingToMarketplace = false,
|
||||
showDeployAction = false,
|
||||
showMarketplaceAction = false,
|
||||
showRunConfig = false,
|
||||
toolPublished,
|
||||
toolPublished = false,
|
||||
workflowToolAvailable = true,
|
||||
workflowToolIsLoading,
|
||||
workflowToolOutdated,
|
||||
workflowToolMessage,
|
||||
workflowToolOutdated = false,
|
||||
onConfigureWorkflowTool,
|
||||
onPublishToMarketplace,
|
||||
}: ActionsSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (hasTriggerNode) return null
|
||||
|
||||
const workflowToolDisabled = !publishedAt || !workflowToolAvailable
|
||||
const appId = appDetail?.id
|
||||
const hasPublishedVersion = Boolean(publishedAt)
|
||||
const showOpenWebApp = !hasTriggerNode
|
||||
const showDeploy = Boolean(showDeployAction && appId)
|
||||
const showWorkflowTool =
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
|
||||
const navigationDisabled = !hasPublishedVersion || !appId
|
||||
const workflowToolDisabled =
|
||||
!hasPublishedVersion || !workflowToolAvailable || (toolPublished && workflowToolIsLoading)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-play-circle-line size-4" />}
|
||||
actionButton={
|
||||
showRunConfig
|
||||
? {
|
||||
ariaLabel: t(($) => $['operation.config'], { ns: 'common' }),
|
||||
icon: <span className="i-ri-settings-2-line size-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(appURL),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t(($) => $['common.runApp'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION ? (
|
||||
<div className="flex flex-col border-t-[0.5px] border-t-divider-regular p-3">
|
||||
{showOpenWebApp && (
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-ri-play-list-2-line size-4" />}
|
||||
description={
|
||||
disabledFunctionButton && disabledFunctionTooltip
|
||||
? disabledFunctionTooltip
|
||||
: t(($) => $['common.openWebAppDescription'], { ns: 'workflow' })
|
||||
}
|
||||
external
|
||||
focusableWhenDisabled={Boolean(disabledFunctionTooltip)}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-planet-line size-4" />}
|
||||
actionButton={
|
||||
showBatchRunConfig
|
||||
showRunConfig && handleOpenRunConfig
|
||||
? {
|
||||
ariaLabel: t(($) => $['operation.config'], { ns: 'common' }),
|
||||
icon: <span className="i-ri-settings-2-line size-4" />,
|
||||
onClick: () =>
|
||||
handleOpenRunConfig?.(
|
||||
`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`,
|
||||
),
|
||||
onClick: () => handleOpenRunConfig(appURL),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t(($) => $['common.batchRunApp'], { ns: 'workflow' })}
|
||||
{t(($) => $['common.openWebApp'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
) : (
|
||||
)}
|
||||
<SuggestedAction
|
||||
disabled={navigationDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={appId ? `/app/${appId}/access-point` : undefined}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
{showDeploy && (
|
||||
<SuggestedAction
|
||||
onClick={handleEmbed}
|
||||
disabled={!publishedAt}
|
||||
icon={<span className="i-custom-vender-line-development-code-browser size-4" />}
|
||||
disabled={navigationDisabled}
|
||||
description={t(($) => $['common.deployDescription'], { ns: 'workflow' })}
|
||||
link={`/app/${appId}/deploy`}
|
||||
icon={<span className="i-ri-instance-line size-4" />}
|
||||
>
|
||||
{t(($) => $['common.embedIntoSite'], { ns: 'workflow' })}
|
||||
{t(($) => $['appMenus.deploy'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
{showMarketplaceAction && (
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (publishedAt) handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<span className="i-ri-planet-line size-4" />}
|
||||
disabled={marketplaceActionDisabled || publishingToMarketplace || !onPublishToMarketplace}
|
||||
description={t(($) => $['common.publishToMarketplaceDescription'], {
|
||||
ns: 'workflow',
|
||||
})}
|
||||
icon={<span className="i-ri-store-2-line size-4" />}
|
||||
onClick={onPublishToMarketplace}
|
||||
>
|
||||
{t(($) => $['common.openInExplore'], { ns: 'workflow' })}
|
||||
{publishingToMarketplace
|
||||
? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' })
|
||||
: t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
<ActionTooltip
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
tooltip={
|
||||
!publishedAt
|
||||
? t(($) => $.notPublishedYet, { ns: 'app' })
|
||||
: t(($) => $.noUserInputNode, { ns: 'app' })
|
||||
}
|
||||
>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link="./develop"
|
||||
icon={<span className="i-ri-terminal-box-line size-4" />}
|
||||
>
|
||||
{t(($) => $['common.accessAPIReference'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
isLoading={workflowToolIsLoading}
|
||||
outdated={workflowToolOutdated}
|
||||
onConfigure={onConfigureWorkflowTool}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
{showWorkflowTool && (
|
||||
<>
|
||||
<div aria-hidden className="m-1 h-px bg-divider-subtle" />
|
||||
<WorkflowToolAction
|
||||
disabled={workflowToolDisabled}
|
||||
isLoading={workflowToolIsLoading}
|
||||
message={workflowToolMessage}
|
||||
outdated={workflowToolOutdated}
|
||||
published={toolPublished}
|
||||
onConfigure={onConfigureWorkflowTool}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,80 +1,166 @@
|
||||
import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import type { PropsWithChildren, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import { useId } from 'react'
|
||||
import Link from '@/next/link'
|
||||
|
||||
type SuggestedActionButton = {
|
||||
ariaLabel: string
|
||||
icon: React.ReactNode
|
||||
icon: ReactNode
|
||||
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
type SuggestedActionProps = PropsWithChildren<
|
||||
HTMLProps<HTMLAnchorElement> & {
|
||||
icon?: React.ReactNode
|
||||
link?: string
|
||||
disabled?: boolean
|
||||
actionButton?: SuggestedActionButton
|
||||
}
|
||||
>
|
||||
type SuggestedActionProps = PropsWithChildren<{
|
||||
icon?: ReactNode
|
||||
link?: string
|
||||
external?: boolean
|
||||
disabled?: boolean
|
||||
focusableWhenDisabled?: boolean
|
||||
description?: ReactNode
|
||||
endIcon?: ReactNode
|
||||
actionButton?: SuggestedActionButton
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}>
|
||||
|
||||
const SuggestedAction = ({
|
||||
icon,
|
||||
link,
|
||||
disabled,
|
||||
external = false,
|
||||
disabled = false,
|
||||
focusableWhenDisabled = false,
|
||||
description,
|
||||
endIcon,
|
||||
actionButton,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
actionButton,
|
||||
...props
|
||||
}: SuggestedActionProps) => {
|
||||
const handleClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
onClick?.(event)
|
||||
}
|
||||
|
||||
const handleActionClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
actionButton?.onClick(event)
|
||||
}
|
||||
|
||||
const mainAction = (
|
||||
<a
|
||||
href={disabled ? undefined : link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'flex min-w-0 items-center justify-start gap-2 px-2.5 py-2 text-text-secondary transition-colors',
|
||||
actionButton
|
||||
? 'flex-1 rounded-l-lg'
|
||||
: 'rounded-lg bg-background-section-burn not-first:mt-1',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-30 shadow-xs'
|
||||
: 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative size-4 shrink-0">{icon}</div>
|
||||
<div className="shrink grow basis-0 system-sm-medium">{children}</div>
|
||||
<RiArrowRightUpLine className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
const id = useId()
|
||||
const labelId = `${id}-label`
|
||||
const descriptionId = `${id}-description`
|
||||
const interactiveClassName = cn(
|
||||
'group flex h-10 min-w-0 items-center gap-2 border-0 bg-transparent p-1 text-left outline-hidden transition-colors',
|
||||
actionButton ? 'flex-1 rounded-l-lg' : 'w-full rounded-lg',
|
||||
disabled
|
||||
? cn(
|
||||
'cursor-not-allowed',
|
||||
!actionButton && 'opacity-30',
|
||||
focusableWhenDisabled && 'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
)
|
||||
: 'cursor-pointer hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
!actionButton && className,
|
||||
)
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-components-panel-on-panel-item-bg text-text-secondary shadow-xs transition-colors',
|
||||
!disabled && 'group-hover:text-text-accent group-focus-visible:text-text-accent',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
id={labelId}
|
||||
className={cn(
|
||||
'block truncate system-sm-medium text-text-secondary transition-colors',
|
||||
!disabled && 'group-hover:text-text-primary group-focus-visible:text-text-primary',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{description && (
|
||||
<span
|
||||
id={descriptionId}
|
||||
className={cn(
|
||||
'hidden truncate system-xs-regular text-text-tertiary',
|
||||
!disabled && 'group-hover:block group-focus-visible:block',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-h-4 min-w-4 shrink-0 items-center justify-center text-text-quaternary">
|
||||
{endIcon ?? <span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const accessibilityProps = {
|
||||
'aria-labelledby': labelId,
|
||||
'aria-describedby': description ? descriptionId : undefined,
|
||||
}
|
||||
|
||||
let mainAction: ReactNode
|
||||
|
||||
if (disabled) {
|
||||
mainAction = (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!focusableWhenDisabled}
|
||||
aria-disabled={focusableWhenDisabled || undefined}
|
||||
className={interactiveClassName}
|
||||
onClick={
|
||||
focusableWhenDisabled
|
||||
? (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
focusableWhenDisabled
|
||||
? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') event.preventDefault()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
} else if (!link) {
|
||||
mainAction = (
|
||||
<button
|
||||
type="button"
|
||||
className={interactiveClassName}
|
||||
onClick={onClick}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
} else if (external) {
|
||||
mainAction = (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={interactiveClassName}
|
||||
onClick={onClick}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
} else {
|
||||
mainAction = (
|
||||
<Link href={link} className={interactiveClassName} onClick={onClick} {...accessibilityProps}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (!actionButton) return mainAction
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-stretch rounded-lg bg-background-section-burn not-first:mt-1',
|
||||
disabled ? 'opacity-30 shadow-xs' : '',
|
||||
'flex w-full min-w-0 items-stretch rounded-lg',
|
||||
disabled && 'opacity-30',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -84,14 +170,16 @@ const SuggestedAction = ({
|
||||
aria-label={actionButton.ariaLabel}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary transition-colors',
|
||||
'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary outline-hidden transition-colors',
|
||||
disabled
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
|
||||
: 'cursor-pointer hover:bg-state-base-hover hover:text-text-accent focus-visible:bg-state-base-hover focus-visible:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
)}
|
||||
onClick={handleActionClick}
|
||||
onClick={actionButton.onClick}
|
||||
>
|
||||
{actionButton.icon}
|
||||
<span aria-hidden className="flex size-4 items-center justify-center">
|
||||
{actionButton.icon}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
type WorkflowToolDisabledReasonProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
const WorkflowToolDisabledReason = ({ message }: WorkflowToolDisabledReasonProps) => {
|
||||
return <p className="mt-1 px-2.5 pb-2 system-xs-regular text-text-tertiary opacity-30">{message}</p>
|
||||
}
|
||||
|
||||
export default WorkflowToolDisabledReason
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buildIntegrationPath } from '@/app/components/integrations/routes'
|
||||
import Link from '@/next/link'
|
||||
import SuggestedAction from '../suggested-action'
|
||||
import WorkflowToolDisabledReason from './disabled-reason'
|
||||
import WorkflowToolLoadingStatus from './loading-status'
|
||||
import WorkflowToolSetupStatus from './setup-status'
|
||||
import WorkflowToolStateLabel from './state-label'
|
||||
|
||||
type WorkflowToolActionProps = {
|
||||
disabled: boolean
|
||||
isLoading: boolean
|
||||
message?: string
|
||||
outdated: boolean
|
||||
published: boolean
|
||||
onConfigure: () => void
|
||||
}
|
||||
|
||||
const WorkflowToolAction = ({
|
||||
disabled,
|
||||
isLoading,
|
||||
message,
|
||||
outdated,
|
||||
published,
|
||||
onConfigure,
|
||||
}: WorkflowToolActionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const disabledReason = disabled ? message : undefined
|
||||
const workflowToolLabel = t(($) => $['common.workflowAsTool'], { ns: 'workflow' })
|
||||
|
||||
if (!published || isLoading)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-lg'
|
||||
)}
|
||||
>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
description={t(($) => $['common.workflowAsToolDescription'], { ns: 'workflow' })}
|
||||
endIcon={
|
||||
isLoading ? (
|
||||
<WorkflowToolLoadingStatus label={t(($) => $.loading, { ns: 'appApi' })} />
|
||||
) : (
|
||||
<WorkflowToolSetupStatus
|
||||
label={t(($) => $['common.configureRequired'], { ns: 'workflow' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
icon={<span className="i-ri-hammer-line size-4" />}
|
||||
onClick={onConfigure}
|
||||
>
|
||||
{workflowToolLabel}
|
||||
</SuggestedAction>
|
||||
{disabledReason && <WorkflowToolDisabledReason message={disabledReason} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
const configureLabel = outdated
|
||||
? t(($) => $['common.workflowAsToolReconfigure'], { ns: 'workflow' })
|
||||
: t(($) => $['common.configure'], { ns: 'workflow' })
|
||||
const manageInToolsLabel = t(($) => $['common.manageInTools'], { ns: 'workflow' })
|
||||
const stateLabel = outdated
|
||||
? t(($) => $['common.workflowAsToolUpdateNeeded'], { ns: 'workflow' })
|
||||
: t(($) => $['common.workflowAsToolReady'], { ns: 'workflow' })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-col rounded-lg',
|
||||
disabled && 'opacity-30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2 p-1">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-components-panel-on-panel-item-bg text-text-secondary shadow-xs"
|
||||
>
|
||||
<span className="i-ri-hammer-line size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-h-7 items-center gap-2 pt-1 pr-1 pb-1">
|
||||
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
|
||||
{workflowToolLabel}
|
||||
</span>
|
||||
<WorkflowToolStateLabel label={stateLabel} outdated={outdated} />
|
||||
</div>
|
||||
{outdated && (
|
||||
<p className="pr-4 pb-1 system-xs-regular text-text-warning">
|
||||
{t(($) => $['common.workflowAsToolTip'], { ns: 'workflow' })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 py-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
className="gap-1 px-1.5"
|
||||
onClick={onConfigure}
|
||||
>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line size-3.5" />
|
||||
{configureLabel}
|
||||
</Button>
|
||||
{disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="flex h-6 cursor-not-allowed items-center gap-1 rounded-md px-2 system-xs-medium text-components-button-tertiary-text-disabled"
|
||||
>
|
||||
{manageInToolsLabel}
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={buildIntegrationPath('workflow-tool')}
|
||||
className="flex h-6 items-center gap-1 rounded-md px-2 system-xs-medium text-text-tertiary outline-hidden hover:bg-components-button-tertiary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
{manageInToolsLabel}
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{disabledReason && <WorkflowToolDisabledReason message={disabledReason} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowToolAction
|
||||
@@ -0,0 +1,15 @@
|
||||
type WorkflowToolLoadingStatusProps = {
|
||||
label: string
|
||||
}
|
||||
|
||||
const WorkflowToolLoadingStatus = ({ label }: WorkflowToolLoadingStatusProps) => {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className="i-ri-loader-2-line size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowToolLoadingStatus
|
||||
@@ -0,0 +1,17 @@
|
||||
type WorkflowToolSetupStatusProps = {
|
||||
label: string
|
||||
}
|
||||
|
||||
const WorkflowToolSetupStatus = ({ label }: WorkflowToolSetupStatusProps) => {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowToolSetupStatus
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
|
||||
type WorkflowToolStateLabelProps = {
|
||||
label: string
|
||||
outdated: boolean
|
||||
}
|
||||
|
||||
const WorkflowToolStateLabel = ({ label, outdated }: WorkflowToolStateLabelProps) => {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1 system-xs-semibold-uppercase',
|
||||
outdated ? 'text-util-colors-warning-warning-600' : 'text-util-colors-green-green-600',
|
||||
)}
|
||||
>
|
||||
<StatusDot size="small" status={outdated ? 'warning' : 'success'} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowToolStateLabel
|
||||
@@ -214,6 +214,11 @@ const createViewModel = (
|
||||
publishedConfig: {
|
||||
modelConfig: createContextValue().modelConfig,
|
||||
completionParams: {},
|
||||
promptMode: createContextValue().promptMode,
|
||||
chatPromptConfig: createContextValue().chatPromptConfig,
|
||||
completionPromptConfig: createContextValue().completionPromptConfig,
|
||||
datasetConfigs: createContextValue().datasetConfigs,
|
||||
externalDataToolsConfig: createContextValue().externalDataToolsConfig,
|
||||
},
|
||||
resetAppConfig: vi.fn(),
|
||||
} as ComponentProps<typeof AppPublisher>,
|
||||
@@ -248,6 +253,7 @@ const createViewModel = (
|
||||
onCompletionParamsChange: vi.fn(),
|
||||
onConfirmUseGPT4: vi.fn(),
|
||||
onEnableMultipleModelDebug: vi.fn(),
|
||||
onFeatureStoreChange: vi.fn(),
|
||||
onFeaturesChange: vi.fn(),
|
||||
onHideDebugPanel: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
|
||||
@@ -99,6 +99,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
onCompletionParamsChange,
|
||||
onConfirmUseGPT4,
|
||||
onEnableMultipleModelDebug,
|
||||
onFeatureStoreChange,
|
||||
onFeaturesChange,
|
||||
onHideDebugPanel,
|
||||
onModelChange,
|
||||
@@ -128,7 +129,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={contextValue}>
|
||||
<FeaturesProvider features={featuresData}>
|
||||
<FeaturesProvider features={featuresData} onFeaturesChange={onFeatureStoreChange}>
|
||||
<MittProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative flex h-[200px] grow pt-14">
|
||||
|
||||
@@ -1143,6 +1143,7 @@ describe('Debug', () => {
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
{ silent: true },
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -369,7 +369,7 @@ const Debug: FC<IDebug> = ({
|
||||
enabled: supportedVision,
|
||||
}
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
setFeatures(newFeatures, { silent: true })
|
||||
}
|
||||
}, [debugWithMultipleModel, featuresStore, mode, multipleModelConfigs, textGenerationModelList])
|
||||
|
||||
|
||||
+3
@@ -24,6 +24,7 @@ describe('useAdvancedPromptConfig', () => {
|
||||
|
||||
it('should update the advanced chat prompt and mark user changes', () => {
|
||||
const handleUserChangedPrompt = vi.fn()
|
||||
const handlePublishConfigChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useAdvancedPromptConfig({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
@@ -36,6 +37,7 @@ describe('useAdvancedPromptConfig', () => {
|
||||
completionParams: {},
|
||||
setCompletionParams: vi.fn(),
|
||||
setStop: vi.fn(),
|
||||
onPublishConfigChange: handlePublishConfigChange,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -51,6 +53,7 @@ describe('useAdvancedPromptConfig', () => {
|
||||
])
|
||||
expect(result.current.hasSetBlockStatus.query).toBe(true)
|
||||
expect(handleUserChangedPrompt).toHaveBeenCalledTimes(1)
|
||||
expect(handlePublishConfigChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should derive simple prompt block status from the pre-prompt', () => {
|
||||
|
||||
+123
-30
@@ -1,5 +1,6 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
import {
|
||||
AgentStrategy,
|
||||
@@ -153,12 +154,20 @@ describe('useConfiguration utils', () => {
|
||||
is_team_authorization: false,
|
||||
},
|
||||
] as any,
|
||||
datasetConfigs: {
|
||||
datasets: { datasets: [] },
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
top_k: 4,
|
||||
} as any,
|
||||
deletedTools: [{ provider_id: 'tool-1', tool_name: 'search' }],
|
||||
mode: AppModeEnum.AGENT_CHAT,
|
||||
nextDataSets: [{ id: 'dataset-1' }] as any,
|
||||
})
|
||||
|
||||
expect(publishedConfig.completionParams).toEqual({ temperature: 0.7 })
|
||||
expect(publishedConfig.datasetConfigs.top_k).toBe(4)
|
||||
expect(publishedConfig.promptMode).toBe('simple')
|
||||
expect(publishedConfig.externalDataToolsConfig).toHaveLength(1)
|
||||
expect(publishedConfig.modelConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
dataSets: [{ id: 'dataset-1' }],
|
||||
@@ -180,6 +189,76 @@ describe('useConfiguration utils', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should normalize an empty chat prompt config for completion apps', () => {
|
||||
const publishedConfig = buildPublishedConfig({
|
||||
backendModelConfig: {
|
||||
chat_prompt_config: {},
|
||||
completion_prompt_config: {
|
||||
prompt: { text: 'completion' },
|
||||
conversation_histories_role: {
|
||||
assistant_prefix: '',
|
||||
user_prefix: '',
|
||||
},
|
||||
},
|
||||
dataset_configs: {
|
||||
datasets: { datasets: [] },
|
||||
},
|
||||
external_data_tools: [],
|
||||
model: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
name: 'gpt-4o',
|
||||
mode: ModelModeType.completion,
|
||||
completion_params: {},
|
||||
},
|
||||
user_input_form: [],
|
||||
} as any,
|
||||
collectionList: [],
|
||||
datasetConfigs: {
|
||||
datasets: { datasets: [] },
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
} as any,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
nextDataSets: [],
|
||||
})
|
||||
|
||||
expect(publishedConfig.chatPromptConfig).toEqual(DEFAULT_CHAT_PROMPT_CONFIG)
|
||||
expect(publishedConfig.modelConfig.chat_prompt_config).toEqual(DEFAULT_CHAT_PROMPT_CONFIG)
|
||||
})
|
||||
|
||||
it('should normalize an empty completion prompt config for chat apps', () => {
|
||||
const publishedConfig = buildPublishedConfig({
|
||||
backendModelConfig: {
|
||||
chat_prompt_config: {
|
||||
prompt: [{ role: 'system', text: 'chat' }],
|
||||
},
|
||||
completion_prompt_config: {},
|
||||
dataset_configs: {
|
||||
datasets: { datasets: [] },
|
||||
},
|
||||
external_data_tools: [],
|
||||
model: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
name: 'gpt-4o',
|
||||
mode: ModelModeType.chat,
|
||||
completion_params: {},
|
||||
},
|
||||
user_input_form: [],
|
||||
} as any,
|
||||
collectionList: [],
|
||||
datasetConfigs: {
|
||||
datasets: { datasets: [] },
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
} as any,
|
||||
mode: AppModeEnum.CHAT,
|
||||
nextDataSets: [],
|
||||
})
|
||||
|
||||
expect(publishedConfig.completionPromptConfig).toEqual(DEFAULT_COMPLETION_PROMPT_CONFIG)
|
||||
expect(publishedConfig.modelConfig.completion_prompt_config).toEqual(
|
||||
DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
)
|
||||
})
|
||||
|
||||
it('should build dataset configs with reranking defaults', () => {
|
||||
const datasetConfigs = buildConfigurationDatasetConfigs({
|
||||
backendModelConfig: {
|
||||
@@ -665,7 +744,6 @@ describe('useConfiguration utils', () => {
|
||||
const onPublish = createPublishHandler({
|
||||
appId: 'app-1',
|
||||
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any,
|
||||
citationConfig: { enabled: true } as any,
|
||||
completionParamsState: { temperature: 0.7 },
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'completion' },
|
||||
@@ -680,13 +758,13 @@ describe('useConfiguration utils', () => {
|
||||
datasetConfigs: {
|
||||
datasets: { datasets: [] },
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
top_k: 7,
|
||||
} as any,
|
||||
externalDataToolsConfig: [],
|
||||
externalDataToolsConfig: [{ enabled: true, variable: 'external' }] as any,
|
||||
hasSetBlockStatus: {
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
introduction: 'hello',
|
||||
isAdvancedMode: true,
|
||||
isFunctionCall: true,
|
||||
mode: AppModeEnum.CHAT,
|
||||
@@ -711,36 +789,40 @@ describe('useConfiguration utils', () => {
|
||||
workflow_file_upload_limit: 1,
|
||||
},
|
||||
} as any,
|
||||
moreLikeThisConfig: { enabled: true },
|
||||
promptEmpty: false,
|
||||
promptMode: 'advanced' as any,
|
||||
resolvedModelModeType: ModelModeType.chat,
|
||||
setCanReturnToSimpleMode,
|
||||
setPublishedConfig,
|
||||
speechToTextConfig: { enabled: false } as any,
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false } as any,
|
||||
t,
|
||||
textToSpeechConfig: { enabled: false, voice: '', language: '' } as any,
|
||||
})
|
||||
|
||||
const result = await onPublish(mockUpdateAppModelConfig, undefined, {
|
||||
moreLikeThis: { enabled: true },
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
moderation: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false, voice: '', language: '' },
|
||||
file: {
|
||||
enabled: false,
|
||||
image: {
|
||||
const result = await onPublish(
|
||||
mockUpdateAppModelConfig,
|
||||
{
|
||||
model: 'published-model',
|
||||
provider: 'published-provider',
|
||||
parameters: { temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
moreLikeThis: { enabled: true },
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
moderation: { enabled: true },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false, voice: '', language: '' },
|
||||
file: {
|
||||
enabled: false,
|
||||
detail: 'low',
|
||||
number_limits: 1,
|
||||
transfer_methods: ['local_file'],
|
||||
},
|
||||
image: {
|
||||
enabled: false,
|
||||
detail: 'low',
|
||||
number_limits: 1,
|
||||
transfer_methods: ['local_file'],
|
||||
},
|
||||
} as any,
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: true },
|
||||
} as any,
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: true },
|
||||
} as any)
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockUpdateAppModelConfig).toHaveBeenCalledWith(
|
||||
@@ -753,7 +835,24 @@ describe('useConfiguration utils', () => {
|
||||
url: '/apps/app-1/model-config',
|
||||
}),
|
||||
)
|
||||
expect(setPublishedConfig).toHaveBeenCalledTimes(1)
|
||||
expect(setPublishedConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] },
|
||||
completionParams: { temperature: 0.2 },
|
||||
datasetConfigs: expect.objectContaining({ top_k: 7 }),
|
||||
externalDataToolsConfig: [{ enabled: true, variable: 'external' }],
|
||||
modelConfig: expect.objectContaining({
|
||||
file_upload: expect.objectContaining({
|
||||
image: expect.objectContaining({ detail: 'low' }),
|
||||
}),
|
||||
model_id: 'published-model',
|
||||
opening_statement: '',
|
||||
provider: 'published-provider',
|
||||
sensitive_word_avoidance: { enabled: true },
|
||||
}),
|
||||
promptMode: 'advanced',
|
||||
}),
|
||||
)
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('api.success')
|
||||
expect(setCanReturnToSimpleMode).toHaveBeenCalledWith(false)
|
||||
})
|
||||
@@ -764,7 +863,6 @@ describe('useConfiguration utils', () => {
|
||||
createPublishHandler({
|
||||
appId: 'app-1',
|
||||
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any,
|
||||
citationConfig: { enabled: false } as any,
|
||||
completionParamsState: { temperature: 0.7 },
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'completion' },
|
||||
@@ -782,7 +880,6 @@ describe('useConfiguration utils', () => {
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
introduction: 'hello',
|
||||
isAdvancedMode: true,
|
||||
isFunctionCall: false,
|
||||
mode: AppModeEnum.CHAT,
|
||||
@@ -801,16 +898,12 @@ describe('useConfiguration utils', () => {
|
||||
workflow_file_upload_limit: 1,
|
||||
},
|
||||
} as any,
|
||||
moreLikeThisConfig: { enabled: false },
|
||||
promptEmpty: false,
|
||||
promptMode: 'advanced' as any,
|
||||
resolvedModelModeType: ModelModeType.completion,
|
||||
setCanReturnToSimpleMode: vi.fn(),
|
||||
setPublishedConfig: vi.fn(),
|
||||
speechToTextConfig: { enabled: false } as any,
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false } as any,
|
||||
t,
|
||||
textToSpeechConfig: { enabled: false, voice: '', language: '' } as any,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user