Compare commits

...
Author SHA1 Message Date
twwu 6e75e7c5b0 Merge branch 'main' into feat/app-multi-env-deployments 2026-07-28 18:10:30 +08:00
twwu 8c91bce282 feat(workflow): update workflow tool publish action 2026-07-28 17:58:30 +08:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
798e5ed7a7 refactor(web): derive workspace billing from current workspace (#39674)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 09:32:03 +00:00
Escape0707andGitHub eef709e475 ci: enforce strict checks for unit tests (#39682) 2026-07-28 09:10:02 +00:00
Escape0707andGitHub 04158ac8ea ci: replace custom noqa markers with guard ignores (#39679) 2026-07-28 08:52:12 +00:00
JoelandGitHub ae0b66311d feat: add missing plugin installation to agent DSL imports (#39680) 2026-07-28 08:23:42 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>yunlu.wen
b97abe5328 chore: bump nltk from 3.9.4 to 3.10.0 in /api (#39503)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-28 08:19:15 +00:00
twwu 24a66293fc feat: refine app publishing and dirty tracking 2026-07-28 16:17:57 +08:00
JyongandGitHub d94314627f ci: wait for KnowledgeFS before deployment (#39677) 2026-07-28 07:12:29 +00:00
ShaktiandGitHub 7ec6a57ddf fix: populate completion_params from model schema defaults in Agent node (#39590) 2026-07-28 07:06:49 +00:00
JoelandGitHub 35b539e35b fix: improve unconfigured agent guidance (#39676) 2026-07-28 07:02:49 +00:00
Asuka MinatoGitHubByron.wangautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
3b040e6bed test: use sqlite3 session in test_web_login (#38725)
Co-authored-by: Byron.wang <byron@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 06:37:45 +00:00
Asuka MinatoandGitHub 9300be03f0 test: use sqlite3 session in test_agent_app_feature_service (#38724) 2026-07-28 06:32:07 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
137d4f3f60 chore(i18n): sync translations with en-US (#39671)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-28 06:11:36 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
e723b348cf test: use SQLite sessions in core app apps (#39103)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:54:17 +00:00
59fb603ec6 fix: reject trailing newlines in alphanumeric() validator (#39666) (#39667)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-07-28 05:49:19 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
63f072ebfb test: use SQLite sessions in core rag (#39105)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:47:22 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
0913d04d33 test: use SQLite sessions in services retention (#39074)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:47:01 +00:00
Asuka MinatoandGitHub 80ff108fc0 test: use SQLite in Aliyun trace utilities (#38983) 2026-07-28 05:40:27 +00:00
Asuka MinatoandGitHub ea58129ebe test: move workflow app service coverage to unit tests (#38946) 2026-07-28 05:34:59 +00:00
-LAN-andGitHub 698869460c chore(docker): remove redundant feature preview env (#39035) 2026-07-28 05:34:35 +00:00
Asuka MinatoandGitHub f3f2f63110 test: use SQLite in LangSmith traces (#38986) 2026-07-28 05:26:52 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b100cdc382 test: use SQLite sessions in core app (#39099)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:22:47 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
9e90b32991 test: use SQLite sessions in core datasource (#39104)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:22:18 +00:00
非法操作andGitHub b2d54cb2e9 chore: improve archived logs hint in logs list page (#39659) 2026-07-28 05:15:35 +00:00
yyhandGitHub 1e5e47b889 fix: validate plugin installation scope (#39669) 2026-07-28 04:13:20 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
dd28b0d165 test: move OAuth server service coverage to unit tests (#38931)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-28 04:09:32 +00:00
JoelandGitHub 49e74e2f58 fix: prevent the model selector footer from covering options (#39668) 2026-07-28 04:02:28 +00:00
e6e5d761c2 fix: prevent Safari from clipping the settings close button (#39664)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-28 04:01:12 +00:00
Asuka MinatoandGitHub e1ce808567 test: move data source controller coverage to unit tests (#38924) 2026-07-28 03:51:22 +00:00
Asuka MinatoandGitHub 3c7ad816d9 test: use SQLite sessions in commands (#39080) 2026-07-28 03:29:02 +00:00
Asuka MinatoandGitHub f44dd343da test: use sqlite3 session in test_plugin_service (#38727) 2026-07-28 03:27:58 +00:00
Asuka MinatoandGitHub 2d9b2d50f3 test: use sqlite3 session in test_wraps (#38770) 2026-07-28 03:27:22 +00:00
Asuka MinatoandGitHub 003e0f9614 test: move message cleanup coverage to unit tests (#38932) 2026-07-28 03:26:43 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
da0979b373 test: use SQLite sessions in services core (#39090)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-28 03:20:27 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
81fb57639e test: use SQLite sessions in controllers service api (#39098)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-28 03:19:17 +00:00
Asuka MinatoandGitHub 1e61078e93 test: use SQLite sessions in services core (#39088) 2026-07-28 03:18:32 +00:00
Escape0707andGitHub b597bb1b17 test: separate human input unit and database paths (#39655) 2026-07-28 03:18:01 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
65ead05dfc test: use SQLite sessions in services plugin (#39084)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-28 03:16:19 +00:00
6f8ed69ee1 fix: fix mcp output_schema is optional (#39453)
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-28 02:31:18 +00:00
Yunlu WenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
59b879d2df chore: bump version to 1.16.1 (#39653)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 01:09:25 +00:00
twwu be88e6adda feat(workflow): redesign publisher action menu 2026-07-27 16:47:33 +08:00
twwu 3b832da46a feat(workflow): show deployment details in version history
Preview workflow IDs in the copy action, add mock environment badges, and fix the action menu trigger's native button semantics.
2026-07-27 13:54:37 +08:00
twwu 8812fd649c feat: Add app detail routes for Access Point and Deploy 2026-07-27 11:04:00 +08:00
250 changed files with 9031 additions and 3727 deletions
+43
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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):
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+27 -19
View File
@@ -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)
+2
View File
@@ -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,
+1 -1
View File
@@ -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]):
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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")
@@ -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"
@@ -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
View File
@@ -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/*
+2 -2
View File
@@ -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:
+48 -9
View File
@@ -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."
@@ -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
@@ -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
+430
View File
@@ -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,
)
@@ -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":"![image](http://files.local/files/u1/file-preview) '
'![image](http://files.local/files/u1/file-preview)"'
f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview) '
f'![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
)
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":"![image](http://files.local/files/u1/file-preview)"'
f'"Question":"";"Answer":"";"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
)
assert len(saves) == 1
assert len(session_factory.persisted) == 1
assert [session.commit_count for session in session_factory.sessions] == [1]
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(self, monkeypatch: pytest.MonkeyPatch):
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":"![image](http://files.local/files/u1/file-preview)"'
'"Question":"Q1";"Answer":"A1";'
f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
)
assert first_docs[0].page_content == expected_page_content
assert second_docs[0].page_content == expected_page_content
assert len(saves) == 1
assert len(session_factory.persisted) == 1
assert [session.commit_count for session in session_factory.sessions] == [1, 0]
def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
class FakeExcelFile:
@@ -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) == {}
+45 -1
View File
@@ -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")
+961
View File
@@ -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()
@@ -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
+111 -124
View File
@@ -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(
@@ -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
View File
@@ -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
View File
@@ -71,7 +71,7 @@
"channel": "alpha",
"compat": {
"minDify": "1.16.0",
"maxDify": "1.16.0"
"maxDify": "1.16.1"
},
"release": {
"tagPrefix": "difyctl-v",
+14
View File
@@ -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 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -581,7 +581,7 @@ wheels = [
[[package]]
name = "dify-agent"
version = "1.16.0"
version = "1.16.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
-3
View File
@@ -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
+7 -7
View File
@@ -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
+7 -7
View File
@@ -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
-8
View File
@@ -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
+3 -3
View File
@@ -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:
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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')
+7 -1
View File
@@ -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) => {
+106 -87
View File
@@ -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"
+286 -185
View File
@@ -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])
@@ -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', () => {
@@ -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