Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e75e7c5b0 | ||
|
|
8c91bce282 | ||
|
|
798e5ed7a7 | ||
|
|
eef709e475 | ||
|
|
04158ac8ea | ||
|
|
ae0b66311d | ||
|
|
b97abe5328 | ||
|
|
24a66293fc | ||
|
|
d94314627f | ||
|
|
7ec6a57ddf | ||
|
|
35b539e35b | ||
|
|
3b040e6bed | ||
|
|
9300be03f0 | ||
|
|
137d4f3f60 | ||
|
|
e723b348cf | ||
|
|
59fb603ec6 | ||
|
|
63f072ebfb | ||
|
|
0913d04d33 | ||
|
|
80ff108fc0 | ||
|
|
ea58129ebe | ||
|
|
698869460c | ||
|
|
f3f2f63110 | ||
|
|
b100cdc382 | ||
|
|
9e90b32991 | ||
|
|
b2d54cb2e9 | ||
|
|
1e5e47b889 | ||
|
|
dd28b0d165 | ||
|
|
49e74e2f58 | ||
|
|
e6e5d761c2 | ||
|
|
e1ce808567 | ||
|
|
3c7ad816d9 | ||
|
|
f44dd343da | ||
|
|
2d9b2d50f3 | ||
|
|
003e0f9614 | ||
|
|
da0979b373 | ||
|
|
81fb57639e | ||
|
|
1e61078e93 | ||
|
|
b597bb1b17 | ||
|
|
65ead05dfc | ||
|
|
6f8ed69ee1 | ||
|
|
59b879d2df | ||
|
|
a57b0b9b58 | ||
|
|
d8506efed6 | ||
|
|
b25b28cc76 | ||
|
|
f68cabe226 | ||
|
|
f73f83c5d6 | ||
|
|
29801d6a65 | ||
|
|
75016e8bfe | ||
|
|
b6dea7b2ba | ||
|
|
b2b1cd7e97 | ||
|
|
ca63e01d45 | ||
|
|
271b6e1f5c | ||
|
|
609ddf9e01 | ||
|
|
be88e6adda | ||
|
|
3b832da46a | ||
|
|
8812fd649c |
@@ -1,6 +1,7 @@
|
||||
name: Deploy Knowledge
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
on:
|
||||
@@ -18,6 +19,48 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/konwledge'
|
||||
steps:
|
||||
- name: Wait for KnowledgeFS CI
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
timeout-minutes: 35
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const workflowId = "knowledge-fs-ci.yml";
|
||||
const headBranch = context.payload.workflow_run.head_branch;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
const pollIntervalMs = 15 * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
branch: headBranch,
|
||||
event: "push",
|
||||
head_sha: headSha,
|
||||
per_page: 10,
|
||||
});
|
||||
const run = data.workflow_runs[0];
|
||||
|
||||
if (!run) {
|
||||
core.info(`Waiting for ${workflowId} to start for ${headSha}.`);
|
||||
} else if (run.status !== "completed") {
|
||||
core.info(`Waiting for ${run.html_url}; current status is ${run.status}.`);
|
||||
} else if (run.conclusion !== "success") {
|
||||
throw new Error(
|
||||
`${workflowId} did not succeed for ${headSha}: ${run.conclusion} (${run.html_url})`,
|
||||
);
|
||||
} else {
|
||||
core.info(`KnowledgeFS CI succeeded for ${headSha}: ${run.html_url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${workflowId} to succeed for ${headSha}.`);
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
|
||||
@@ -666,6 +666,7 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
|
||||
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
||||
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
# Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users.
|
||||
# Example: langgenius/openai,langgenius/gemini
|
||||
|
||||
+2
-2
@@ -99,9 +99,9 @@ ENV VIRTUAL_ENV=/app/api/.venv
|
||||
COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
|
||||
# Download nltk data
|
||||
RUN mkdir -p /usr/local/share/nltk_data \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger'); nltk.download('stopwords')" \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -m nltk.downloader punkt_tab averaged_perceptron_tagger_eng stopwords \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.data.find('tokenizers/punkt_tab'); nltk.data.find('taggers/averaged_perceptron_tagger_eng'); nltk.data.find('corpora/stopwords')" \
|
||||
&& chmod -R 755 /usr/local/share/nltk_data
|
||||
|
||||
ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache
|
||||
|
||||
@@ -266,6 +266,12 @@ class PluginConfig(BaseSettings):
|
||||
default=60 * 60,
|
||||
)
|
||||
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED: bool = Field(
|
||||
description="Whether tenant plugin model providers are cached in Redis. Disable when plugins are installed "
|
||||
"by a system other than this one, which cannot invalidate the cache when a tenant's plugins change.",
|
||||
default=True,
|
||||
)
|
||||
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field(
|
||||
description="TTL in seconds for caching tenant plugin model providers in Redis",
|
||||
default=60 * 60 * 24,
|
||||
|
||||
@@ -52,7 +52,7 @@ def with_session[T, **P, R](
|
||||
session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
session.rollback() # noqa: no-new-controller-sqlalchemy decorator owns transaction rollback
|
||||
session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback
|
||||
raise
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
|
||||
@@ -317,7 +317,7 @@ class _WorkflowResponseSource:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def created_by_account(self) -> Account | None:
|
||||
|
||||
@@ -218,7 +218,7 @@ class _DatasetQueryResponseSource:
|
||||
return self.query.get_queries(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.query, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.query, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class DatasetQueryListResponse(ResponseModel):
|
||||
@@ -257,7 +257,7 @@ class _RelatedAppResponseSource:
|
||||
return self.app.mode_compatible_with_agent_with_session(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.app, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class RelatedAppListResponse(ResponseModel):
|
||||
|
||||
@@ -106,7 +106,7 @@ class ExternalKnowledgeApiResponseSource:
|
||||
return self.external_knowledge_api.get_dataset_bindings(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.external_knowledge_api, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def external_knowledge_api_response(
|
||||
|
||||
@@ -404,7 +404,7 @@ class TrialWorkflowResponseSource:
|
||||
return self.workflow.get_tool_published(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
register_schema_models(
|
||||
|
||||
@@ -23,7 +23,6 @@ from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import plugin_model_providers as _plugin_model_providers
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
@@ -36,7 +35,6 @@ __all__ = [
|
||||
"_knowledge_retrieval",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_plugin_model_providers",
|
||||
"_runtime_credentials",
|
||||
"_workspace",
|
||||
"api",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
class InvalidatePluginModelProvidersCachePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated")
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate")
|
||||
class EnterprisePluginModelProvidersCacheInvalidate(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_invalidate_plugin_model_providers_cache",
|
||||
responses={
|
||||
200: "Cache invalidated",
|
||||
400: "Invalid request",
|
||||
401: "Unauthorized - invalid API key",
|
||||
},
|
||||
)
|
||||
@inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__])
|
||||
def post(self):
|
||||
args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {})
|
||||
|
||||
for tenant_id in args.tenant_ids:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}, 200
|
||||
@@ -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])
|
||||
@@ -434,14 +434,18 @@ class PluginService:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _fetch_plugin_model_providers_uncached(
|
||||
cls, tenant_id: str, client: PluginModelClient | None
|
||||
) -> tuple[ProviderEntity, ...]:
|
||||
model_client = client or PluginModelClient()
|
||||
return tuple(cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id))
|
||||
|
||||
@classmethod
|
||||
def _fetch_and_cache_plugin_model_providers(
|
||||
cls, tenant_id: str, client: PluginModelClient | None, *, refresh_generation: int | None
|
||||
) -> tuple[ProviderEntity, ...]:
|
||||
model_client = client or PluginModelClient()
|
||||
providers = tuple(
|
||||
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
|
||||
)
|
||||
providers = cls._fetch_plugin_model_providers_uncached(tenant_id, client)
|
||||
generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||
if generation is not None and generation == refresh_generation:
|
||||
cls._store_cached_plugin_model_providers(tenant_id, generation, providers)
|
||||
@@ -471,6 +475,9 @@ class PluginService:
|
||||
are intentionally owned by this service so tenant isolation and cache
|
||||
expiry are handled in one place.
|
||||
"""
|
||||
if not dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED:
|
||||
return cls._fetch_plugin_model_providers_uncached(tenant_id, client)
|
||||
|
||||
deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT
|
||||
|
||||
while True:
|
||||
@@ -597,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
|
||||
@@ -627,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:
|
||||
@@ -900,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)
|
||||
@@ -912,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
|
||||
@@ -967,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)
|
||||
|
||||
@@ -989,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)
|
||||
|
||||
@@ -1069,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:
|
||||
@@ -1079,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)
|
||||
@@ -1101,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:
|
||||
@@ -1119,7 +1134,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -107,6 +107,8 @@ class MCPTool(Tool):
|
||||
if self.entity.output_schema and result.structuredContent:
|
||||
for k, v in result.structuredContent.items():
|
||||
yield self.create_variable_message(k, v)
|
||||
elif result.structuredContent:
|
||||
yield self.create_json_message(result.structuredContent)
|
||||
|
||||
def _process_text_content(self, content: TextContent) -> Generator[ToolInvokeMessage, None, None]:
|
||||
"""Process text content and yield appropriate messages."""
|
||||
|
||||
@@ -198,8 +198,23 @@ class AgentRuntimeSupport:
|
||||
if model_schema:
|
||||
model_schema = self._remove_unsupported_model_features_for_old_version(model_schema)
|
||||
value["entity"] = model_schema.model_dump(mode="json")
|
||||
# The model selector value from the workflow frontend only
|
||||
# carries provider/model/mode — it does NOT include
|
||||
# completion_params. AgentStrategy plugins (cot_agent,
|
||||
# function_calling) read completion_params to build the
|
||||
# LLMModelConfig that is backwards-invoked, and some model
|
||||
# providers raise KeyError('required') when
|
||||
# completion_params is empty because their parameter_rules
|
||||
# declare required fields with no default. Populate
|
||||
# completion_params with the defaults declared in the model
|
||||
# schema so the plugin daemon always receives a valid set
|
||||
# of model parameters.
|
||||
if "completion_params" not in value:
|
||||
value["completion_params"] = self._extract_default_completion_params(model_schema)
|
||||
else:
|
||||
value["entity"] = None
|
||||
if "completion_params" not in value:
|
||||
value["completion_params"] = {}
|
||||
result[parameter_name] = value
|
||||
|
||||
return result
|
||||
@@ -275,6 +290,24 @@ class AgentRuntimeSupport:
|
||||
model_schema.features.remove(feature)
|
||||
return model_schema
|
||||
|
||||
@staticmethod
|
||||
def _extract_default_completion_params(model_schema: AIModelEntity) -> dict[str, Any]:
|
||||
"""Build a completion_params dict from the model schema's parameter_rules.
|
||||
|
||||
The workflow Agent node's model-selector parameter only stores
|
||||
provider/model/mode — it never carries completion_params. When the
|
||||
value is forwarded to the plugin daemon, AgentModelConfig defaults
|
||||
completion_params to ``{}``, which causes some model providers to fail
|
||||
because their parameter_rules declare required fields. This helper
|
||||
collects the ``default`` value of every parameter_rule that has one so
|
||||
the plugin daemon receives a valid, non-empty set of model parameters.
|
||||
"""
|
||||
completion_params: dict[str, Any] = {}
|
||||
for rule in model_schema.parameter_rules:
|
||||
if rule.default is not None:
|
||||
completion_params[rule.name] = rule.default
|
||||
return completion_params
|
||||
|
||||
@staticmethod
|
||||
def _filter_mcp_type_tool(
|
||||
strategy: ResolvedAgentStrategy,
|
||||
|
||||
@@ -34,7 +34,7 @@ class _SessionResponseSource[SourceT]:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._source, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._source, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]):
|
||||
|
||||
@@ -227,7 +227,7 @@ class DatasetDetailResponseSource:
|
||||
return self.dataset.get_total_available_documents(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.dataset, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.dataset, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def dataset_detail_response_source(dataset: Any, *, session: Session) -> DatasetDetailResponseSource:
|
||||
|
||||
@@ -90,7 +90,7 @@ class DocumentWithSession:
|
||||
return self.document.get_doc_metadata_details(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.document, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.document, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def document_response(document: Document, *, session: Session) -> DocumentResponse:
|
||||
|
||||
+5
-1
@@ -289,7 +289,11 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)]
|
||||
|
||||
def alphanumeric(value: str):
|
||||
# check if the value is alphanumeric and underlined
|
||||
if re.match(r"^[a-zA-Z0-9_]+$", value):
|
||||
# Use re.fullmatch instead of re.match to reject trailing newlines.
|
||||
# In Python, '$' matches at end-of-string OR just before a trailing newline,
|
||||
# so re.match accepts "tool_name\n". re.fullmatch requires the entire
|
||||
# string to match. Regression for #39666 (sibling of #39234 / #39548).
|
||||
if re.fullmatch(r"^[a-zA-Z0-9_]+$", value):
|
||||
return value
|
||||
|
||||
raise ValueError(f"{value} is not a valid alphanumeric value")
|
||||
|
||||
+21
-12
@@ -1,3 +1,5 @@
|
||||
"""Unit tests for Aliyun trace utility transformations and database lookups."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
@@ -25,11 +27,13 @@ from dify_trace_aliyun.utils import (
|
||||
serialize_json_data,
|
||||
)
|
||||
from opentelemetry.trace import Link, StatusCode
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.rag.models.document import Document
|
||||
from graphon.entities import WorkflowNodeExecution
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from models import EndUser
|
||||
from models.enums import EndUserType
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -40,35 +44,40 @@ def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPat
|
||||
assert get_user_id_from_message_data(message_data) == "account_id"
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
message_data = MagicMock()
|
||||
message_data.from_account_id = "account_id"
|
||||
message_data.from_end_user_id = "end_user_id"
|
||||
|
||||
end_user_data = MagicMock(spec=EndUser)
|
||||
end_user_data.session_id = "session_id"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = end_user_data
|
||||
end_user_data = EndUser(
|
||||
id="end_user_id",
|
||||
tenant_id="tenant_id",
|
||||
app_id="app_id",
|
||||
type=EndUserType.BROWSER,
|
||||
session_id="session_id",
|
||||
)
|
||||
sqlite3_session.add(end_user_data)
|
||||
sqlite3_session.commit()
|
||||
|
||||
from dify_trace_aliyun.utils import db
|
||||
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
monkeypatch.setattr(db, "session", sqlite3_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "session_id"
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_get_user_id_from_message_data_end_user_not_found(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
message_data = MagicMock()
|
||||
message_data.from_account_id = "account_id"
|
||||
message_data.from_end_user_id = "end_user_id"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = None
|
||||
|
||||
from dify_trace_aliyun.utils import db
|
||||
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
monkeypatch.setattr(db, "session", sqlite3_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "account_id"
|
||||
|
||||
|
||||
+67
-29
@@ -1,5 +1,8 @@
|
||||
"""Unit tests for LangSmith trace translation with SQLite-backed lookups."""
|
||||
|
||||
import collections
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import override
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -11,6 +14,7 @@ from dify_trace_langsmith.entities.langsmith_trace_entity import (
|
||||
LangSmithRunUpdateModel,
|
||||
)
|
||||
from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.ops.entities.trace_entity import (
|
||||
DatasetRetrievalTraceInfo,
|
||||
@@ -24,6 +28,7 @@ from core.ops.entities.trace_entity import (
|
||||
)
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey
|
||||
from models import EndUser
|
||||
from models.enums import EndUserType
|
||||
|
||||
|
||||
def _dt() -> datetime:
|
||||
@@ -108,7 +113,8 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
mocks["generate_name_trace"].assert_called_once_with(info)
|
||||
|
||||
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
# Setup trace info
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
@@ -137,10 +143,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
# Mock dependencies
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
|
||||
# Mock node executions
|
||||
node_llm = MagicMock()
|
||||
@@ -228,7 +234,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
assert call_args[4].run_type == LangSmithRunType.retriever
|
||||
|
||||
|
||||
def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_no_start_time(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -256,9 +265,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
mock_factory = MagicMock()
|
||||
@@ -271,7 +281,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey
|
||||
assert trace_instance.add_run.called
|
||||
|
||||
|
||||
def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_missing_app_id(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.trace_id = "trace-1"
|
||||
trace_info.message_id = None
|
||||
@@ -287,15 +300,17 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.Monke
|
||||
trace_info.workflow_run_outputs = {}
|
||||
trace_info.error = ""
|
||||
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="No app_id found in trace_info metadata"):
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
message_data = MagicMock()
|
||||
message_data.id = "msg-1"
|
||||
message_data.from_account_id = "acc-1"
|
||||
@@ -321,10 +336,19 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
message_file_data=MagicMock(url="file-url"),
|
||||
)
|
||||
|
||||
# Mock EndUser lookup
|
||||
mock_end_user = MagicMock(spec=EndUser)
|
||||
mock_end_user.session_id = "session-id-123"
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db.session.get", lambda model, pk: mock_end_user)
|
||||
end_user = EndUser(
|
||||
id="end-user-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
type=EndUserType.BROWSER,
|
||||
session_id="session-id-123",
|
||||
)
|
||||
sqlite3_session.add(end_user)
|
||||
sqlite3_session.commit()
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
@@ -521,9 +545,13 @@ def test_update_run_error(trace_instance):
|
||||
trace_instance.update_run(update_data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_usage_extraction_error(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
trace_instance,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
sqlite3_session: Session,
|
||||
) -> None:
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -576,8 +604,10 @@ def test_workflow_trace_usage_extraction_error(
|
||||
mock_factory = MagicMock()
|
||||
mock_factory.create_workflow_node_execution_repository.return_value = repo
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock())
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
@@ -644,9 +674,11 @@ def _make_workflow_trace_info(
|
||||
)
|
||||
|
||||
|
||||
def _patch_workflow_trace_deps(monkeypatch, trace_instance):
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Session) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
factory = MagicMock()
|
||||
@@ -656,14 +688,17 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance):
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
|
||||
def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_id_uses_message_id_not_external(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
"""Chatflow with external trace_id: LangSmith trace_id must be message_id, not external."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id="msg-abc",
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
@@ -677,14 +712,17 @@ def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypa
|
||||
assert trace_info.metadata.get("external_trace_id") == "external-999"
|
||||
|
||||
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
"""Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id=None,
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
@@ -316,10 +316,10 @@ class OracleVector(BaseVector):
|
||||
entities.append(current_entity)
|
||||
else:
|
||||
try:
|
||||
nltk.data.find("tokenizers/punkt")
|
||||
nltk.data.find("tokenizers/punkt_tab")
|
||||
nltk.data.find("corpora/stopwords")
|
||||
except LookupError:
|
||||
raise LookupError("Unable to find the required NLTK data package: punkt and stopwords")
|
||||
raise LookupError("Unable to find the required NLTK data package: punkt_tab and stopwords")
|
||||
e_str = re.sub(r"[^\w ]", "", query)
|
||||
all_tokens = nltk.word_tokenize(e_str)
|
||||
stop_words = stopwords.words("english")
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
@@ -206,7 +206,7 @@ storage = [
|
||||
############################################################
|
||||
# [ Tools ] dependency group
|
||||
############################################################
|
||||
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.9.1,<4.0.0"]
|
||||
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.0,<4.0.0"]
|
||||
|
||||
############################################################
|
||||
# [ VDB ] workspace plugins — hollow packages under providers/vdb/*
|
||||
|
||||
@@ -114,7 +114,7 @@ class AppModelConfigResponseView:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._app_model_config, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._app_model_config, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def annotation_reply_dict(self) -> Any:
|
||||
@@ -129,7 +129,7 @@ class AppResponseView:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._app, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def desc_or_prompt(self) -> str:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from configs import dify_config
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
@@ -10,6 +12,8 @@ from enums.hosted_provider import HostedTrialProvider
|
||||
from services.billing_service import BillingInfo, BillingService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeatureResponseModel(BaseModel):
|
||||
model_config = ConfigDict(json_schema_serialization_defaults_required=True, protected_namespaces=())
|
||||
@@ -131,6 +135,13 @@ class PluginInstallationPermissionModel(FeatureResponseModel):
|
||||
restrict_to_marketplace_only: bool = False
|
||||
|
||||
|
||||
class _EnterprisePluginInstallationPermission(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
plugin_installation_scope: PluginInstallationScope = Field(alias="pluginInstallationScope")
|
||||
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
|
||||
|
||||
|
||||
class FeatureModel(FeatureResponseModel):
|
||||
billing: BillingModel = BillingModel()
|
||||
education: EducationModel = EducationModel()
|
||||
@@ -285,6 +296,14 @@ class FeatureService:
|
||||
"""Return whether Enterprise plugin credential policies must be enforced."""
|
||||
return dify_config.ENTERPRISE_ENABLED
|
||||
|
||||
@classmethod
|
||||
def get_plugin_installation_permission(cls) -> PluginInstallationPermissionModel:
|
||||
"""Resolve the validated deployment-wide plugin installation policy."""
|
||||
if not dify_config.ENTERPRISE_ENABLED:
|
||||
return PluginInstallationPermissionModel()
|
||||
|
||||
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
|
||||
|
||||
@classmethod
|
||||
def get_license(cls) -> LicenseModel:
|
||||
"""Return full license detail. Enterprise-only; requires an authenticated caller.
|
||||
@@ -452,6 +471,33 @@ class FeatureService:
|
||||
)
|
||||
return license_model
|
||||
|
||||
@classmethod
|
||||
def _resolve_plugin_installation_permission(
|
||||
cls, enterprise_info: Mapping[str, object]
|
||||
) -> PluginInstallationPermissionModel:
|
||||
if "PluginInstallationPermission" not in enterprise_info:
|
||||
return PluginInstallationPermissionModel()
|
||||
|
||||
try:
|
||||
permission = _EnterprisePluginInstallationPermission.model_validate(
|
||||
enterprise_info["PluginInstallationPermission"]
|
||||
)
|
||||
except ValidationError as exc:
|
||||
# Do not attach the exception because it may contain raw Enterprise configuration values.
|
||||
logger.error( # noqa: TRY400
|
||||
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
|
||||
exc.errors(include_input=False),
|
||||
)
|
||||
return PluginInstallationPermissionModel(
|
||||
plugin_installation_scope=PluginInstallationScope.NONE,
|
||||
restrict_to_marketplace_only=True,
|
||||
)
|
||||
|
||||
return PluginInstallationPermissionModel(
|
||||
plugin_installation_scope=permission.plugin_installation_scope,
|
||||
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
|
||||
enterprise_info = EnterpriseService.get_info()
|
||||
@@ -499,11 +545,4 @@ class FeatureService:
|
||||
status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
|
||||
)
|
||||
|
||||
if "PluginInstallationPermission" in enterprise_info:
|
||||
plugin_installation_info = enterprise_info["PluginInstallationPermission"]
|
||||
features.plugin_installation_permission.plugin_installation_scope = plugin_installation_info[
|
||||
"pluginInstallationScope"
|
||||
]
|
||||
features.plugin_installation_permission.restrict_to_marketplace_only = plugin_installation_info[
|
||||
"restrictToMarketplaceOnly"
|
||||
]
|
||||
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)
|
||||
|
||||
@@ -2,114 +2,94 @@ extend = "../../.ruff.toml"
|
||||
src = ["../.."]
|
||||
|
||||
[lint]
|
||||
extend-select = ["ANN401", "ARG", "TID251"]
|
||||
extend-select = ["ANN401", "ARG"]
|
||||
|
||||
# Existing strict-mode debt. Remove a file entry when bringing it under strict checking.
|
||||
[lint.per-file-ignores]
|
||||
"core/rag/pipeline/test_queue_integration.py" = ["ANN401", "TID251", "ARG"]
|
||||
"controllers/console/test_apikey.py" = ["ARG002"]
|
||||
"controllers/openapi/test_app_dsl.py" = ["ARG002"]
|
||||
"controllers/service_api/dataset/test_dataset.py" = ["ARG002"]
|
||||
"controllers/web/test_conversation.py" = ["ARG002"]
|
||||
"controllers/web/test_human_input_form.py" = ["ARG001"]
|
||||
"controllers/web/test_wraps.py" = ["ARG002"]
|
||||
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG002"]
|
||||
"core/rag/pipeline/test_queue_integration.py" = ["ARG002", "TID251"]
|
||||
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG002"]
|
||||
"models/test_conversation_message_inputs.py" = ["ARG001"]
|
||||
"models/test_types_enum_text.py" = ["ANN401", "TID251"]
|
||||
"services/test_app_dsl_service.py" = ["ANN401", "TID251", "ARG"]
|
||||
"services/test_file_service_zip_and_lookup.py" = ["ANN401", "TID251", "ARG"]
|
||||
"trigger/conftest.py" = ["ANN401", "TID251"]
|
||||
"trigger/test_trigger_e2e.py" = ["ANN401", "TID251", "ARG"]
|
||||
"controllers/console/app/test_app_apis.py" = ["ARG"]
|
||||
"controllers/console/app/test_app_import_api.py" = ["ARG"]
|
||||
"controllers/console/auth/test_oauth.py" = ["ARG"]
|
||||
"controllers/console/auth/test_password_reset.py" = ["ARG"]
|
||||
"controllers/console/datasets/test_data_source.py" = ["ARG"]
|
||||
"controllers/console/test_apikey.py" = ["ARG"]
|
||||
"controllers/console/workspace/test_tool_provider.py" = ["ARG"]
|
||||
"controllers/mcp/test_mcp.py" = ["ARG"]
|
||||
"controllers/openapi/test_app_dsl.py" = ["ARG"]
|
||||
"controllers/openapi/test_workspaces.py" = ["ARG"]
|
||||
"controllers/service_api/dataset/test_dataset.py" = ["ARG"]
|
||||
"controllers/web/test_conversation.py" = ["ARG"]
|
||||
"controllers/web/test_human_input_form.py" = ["ARG"]
|
||||
"controllers/web/test_wraps.py" = ["ARG"]
|
||||
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG"]
|
||||
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG"]
|
||||
"models/test_conversation_message_inputs.py" = ["ARG"]
|
||||
"models/test_conversation_status_count.py" = ["ARG"]
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG"]
|
||||
"repositories/test_workflow_run_repository.py" = ["ARG"]
|
||||
"services/auth/test_api_key_auth_service.py" = ["ARG"]
|
||||
"services/auth/test_auth_integration.py" = ["ARG"]
|
||||
"services/dataset_collection_binding.py" = ["ARG"]
|
||||
"services/dataset_service_update_delete.py" = ["ARG"]
|
||||
"services/document_service_status.py" = ["ARG"]
|
||||
"services/enterprise/test_account_deletion_sync.py" = ["ARG"]
|
||||
"services/plugin/test_plugin_parameter_service.py" = ["ARG"]
|
||||
"services/plugin/test_plugin_service.py" = ["ARG"]
|
||||
"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG"]
|
||||
"services/recommend_app/test_database_retrieval.py" = ["ARG"]
|
||||
"services/test_account_service.py" = ["ARG"]
|
||||
"services/test_advanced_prompt_template_service.py" = ["ARG"]
|
||||
"services/test_annotation_service.py" = ["ARG"]
|
||||
"services/test_api_based_extension_service.py" = ["ARG"]
|
||||
"services/test_api_token_service.py" = ["ARG"]
|
||||
"services/test_app_generate_service.py" = ["ARG"]
|
||||
"services/test_app_service.py" = ["ARG"]
|
||||
"services/test_attachment_service.py" = ["ARG"]
|
||||
"services/test_conversation_variable_updater.py" = ["ARG"]
|
||||
"services/test_dataset_permission_service.py" = ["ARG"]
|
||||
"services/test_dataset_service_batch_update_document_status.py" = ["ARG"]
|
||||
"services/test_dataset_service_retrieval.py" = ["ARG"]
|
||||
"services/test_delete_archived_workflow_run.py" = ["ARG"]
|
||||
"services/test_document_service_rename_document.py" = ["ARG"]
|
||||
"services/test_end_user_service.py" = ["ARG"]
|
||||
"services/test_feature_service.py" = ["ARG"]
|
||||
"services/test_feedback_service.py" = ["ARG"]
|
||||
"services/test_file_service.py" = ["ARG"]
|
||||
"services/test_human_input_delivery_test_service.py" = ["ARG"]
|
||||
"services/test_message_service.py" = ["ARG"]
|
||||
"services/test_messages_clean_service.py" = ["ARG", "S110"]
|
||||
"services/test_metadata_partial_update.py" = ["ARG"]
|
||||
"services/test_metadata_service.py" = ["ARG"]
|
||||
"services/test_model_load_balancing_service.py" = ["ARG"]
|
||||
"services/test_model_provider_service.py" = ["ARG"]
|
||||
"services/test_oauth_server_service.py" = ["ARG"]
|
||||
"services/test_ops_service.py" = ["ARG"]
|
||||
"services/test_saved_message_service.py" = ["ARG"]
|
||||
"services/test_web_conversation_service.py" = ["ARG"]
|
||||
"services/test_webapp_auth_service.py" = ["ARG"]
|
||||
"services/test_webhook_service.py" = ["ARG"]
|
||||
"services/test_workflow_app_service.py" = ["ARG"]
|
||||
"services/test_workflow_draft_variable_service.py" = ["ARG"]
|
||||
"services/test_workflow_run_service.py" = ["ARG"]
|
||||
"services/test_workflow_service.py" = ["ARG"]
|
||||
"services/test_workspace_service.py" = ["ARG"]
|
||||
"services/tools/test_api_tools_manage_service.py" = ["ARG"]
|
||||
"services/tools/test_mcp_tools_manage_service.py" = ["ARG"]
|
||||
"services/tools/test_tools_transform_service.py" = ["ARG"]
|
||||
"services/workflow/test_workflow_converter.py" = ["ARG"]
|
||||
"tasks/test_add_document_to_index_task.py" = ["ARG"]
|
||||
"tasks/test_batch_clean_document_task.py" = ["ARG"]
|
||||
"tasks/test_batch_create_segment_to_index_task.py" = ["ARG"]
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG002", "ARG005"]
|
||||
"repositories/test_workflow_run_repository.py" = ["ARG002"]
|
||||
"services/auth/test_auth_integration.py" = ["ARG002"]
|
||||
"services/dataset_collection_binding.py" = ["ARG002"]
|
||||
"services/document_service_status.py" = ["ARG002"]
|
||||
"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG002"]
|
||||
"services/recommend_app/test_database_retrieval.py" = ["ARG002"]
|
||||
"services/test_account_service.py" = ["ARG002"]
|
||||
"services/test_advanced_prompt_template_service.py" = ["ARG002"]
|
||||
"services/test_app_dsl_service.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"services/test_app_service.py" = ["ARG002"]
|
||||
"services/test_attachment_service.py" = ["ARG002"]
|
||||
"services/test_conversation_variable_updater.py" = ["ARG002"]
|
||||
"services/test_dataset_service_batch_update_document_status.py" = ["ARG002"]
|
||||
"services/test_delete_archived_workflow_run.py" = ["ARG002"]
|
||||
"services/test_document_service_rename_document.py" = ["ARG001"]
|
||||
"services/test_end_user_service.py" = ["ARG002"]
|
||||
"services/test_feature_service.py" = ["ARG002"]
|
||||
"services/test_file_service.py" = ["ARG002"]
|
||||
"services/test_file_service_zip_and_lookup.py" = ["TID251"]
|
||||
"services/test_messages_clean_service.py" = ["ARG002", "S110"]
|
||||
"services/test_metadata_partial_update.py" = ["ARG002"]
|
||||
"services/test_metadata_service.py" = ["ARG002"]
|
||||
"services/test_model_load_balancing_service.py" = ["ARG002"]
|
||||
"services/test_model_provider_service.py" = ["ARG002"]
|
||||
"services/test_ops_service.py" = ["ARG002"]
|
||||
"services/test_webapp_auth_service.py" = ["ARG002"]
|
||||
"services/test_webhook_service.py" = ["ARG002"]
|
||||
"services/test_workflow_draft_variable_service.py" = ["ARG002"]
|
||||
"services/test_workflow_run_service.py" = ["ARG002"]
|
||||
"services/test_workflow_service.py" = ["ARG002"]
|
||||
"services/test_workspace_service.py" = ["ARG002"]
|
||||
"services/tools/test_api_tools_manage_service.py" = ["ARG002"]
|
||||
"services/tools/test_mcp_tools_manage_service.py" = ["ARG002", "ARG005"]
|
||||
"services/tools/test_tools_transform_service.py" = ["ARG002"]
|
||||
"services/workflow/test_workflow_converter.py" = ["ARG002"]
|
||||
"tasks/test_add_document_to_index_task.py" = ["ARG002"]
|
||||
"tasks/test_batch_clean_document_task.py" = ["ARG002"]
|
||||
"tasks/test_batch_create_segment_to_index_task.py" = ["ARG001", "ARG002"]
|
||||
"tasks/test_clean_dataset_task.py" = ["T201"]
|
||||
"tasks/test_clean_notion_document_task.py" = ["ARG"]
|
||||
"tasks/test_create_segment_to_index_task.py" = ["ARG"]
|
||||
"tasks/test_dataset_indexing_task.py" = ["ARG"]
|
||||
"tasks/test_deal_dataset_vector_index_task.py" = ["ARG"]
|
||||
"tasks/test_delete_segment_from_index_task.py" = ["ARG"]
|
||||
"tasks/test_disable_segment_from_index_task.py" = ["ARG"]
|
||||
"tasks/test_disable_segments_from_index_task.py" = ["ARG"]
|
||||
"tasks/test_document_indexing_sync_task.py" = ["ARG"]
|
||||
"tasks/test_document_indexing_task.py" = ["ARG"]
|
||||
"tasks/test_document_indexing_update_task.py" = ["ARG"]
|
||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG"]
|
||||
"tasks/test_enable_segments_to_index_task.py" = ["ARG"]
|
||||
"tasks/test_mail_change_mail_task.py" = ["ARG"]
|
||||
"tasks/test_mail_email_code_login_task.py" = ["ARG"]
|
||||
"tasks/test_mail_human_input_delivery_task.py" = ["ARG"]
|
||||
"tasks/test_mail_inner_task.py" = ["ARG"]
|
||||
"tasks/test_mail_invite_member_task.py" = ["ARG"]
|
||||
"tasks/test_mail_owner_transfer_task.py" = ["ARG"]
|
||||
"tasks/test_mail_register_task.py" = ["ARG"]
|
||||
"tasks/test_rag_pipeline_run_tasks.py" = ["ARG"]
|
||||
"tasks/test_clean_notion_document_task.py" = ["ARG002"]
|
||||
"tasks/test_create_segment_to_index_task.py" = ["ARG002"]
|
||||
"tasks/test_dataset_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_deal_dataset_vector_index_task.py" = ["ARG002"]
|
||||
"tasks/test_delete_segment_from_index_task.py" = ["ARG002"]
|
||||
"tasks/test_disable_segment_from_index_task.py" = ["ARG002"]
|
||||
"tasks/test_disable_segments_from_index_task.py" = ["ARG002"]
|
||||
"tasks/test_document_indexing_sync_task.py" = ["ARG002"]
|
||||
"tasks/test_document_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_document_indexing_update_task.py" = ["ARG002"]
|
||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_enable_segments_to_index_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_change_mail_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_email_code_login_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_human_input_delivery_task.py" = ["ARG001"]
|
||||
"tasks/test_mail_inner_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_invite_member_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_owner_transfer_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_register_task.py" = ["ARG002"]
|
||||
"tasks/test_rag_pipeline_run_tasks.py" = ["ARG002"]
|
||||
"test_workflow_pause_integration.py" = ["T201"]
|
||||
"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG"]
|
||||
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG"]
|
||||
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG"]
|
||||
"trigger/conftest.py" = ["ANN401", "TID251"]
|
||||
"trigger/test_trigger_e2e.py" = ["ANN401", "ARG001", "TID251"]
|
||||
"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG002"]
|
||||
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG002"]
|
||||
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG002"]
|
||||
"workflow/nodes/code_executor/test_utils.py" = ["T201"]
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."typing.Any"]
|
||||
msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead."
|
||||
|
||||
+71
-480
@@ -1,494 +1,85 @@
|
||||
"""Testcontainers integration tests for controllers.console.datasets.data_source endpoints."""
|
||||
"""Integration coverage for Notion page bindings backed by persisted documents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from inspect import unwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets import data_source
|
||||
from controllers.console.datasets.data_source import (
|
||||
DataSourceApi,
|
||||
DataSourceNotionDatasetSyncApi,
|
||||
DataSourceNotionDocumentSyncApi,
|
||||
DataSourceNotionIndexingEstimateApi,
|
||||
DataSourceNotionListApi,
|
||||
DataSourceNotionPreviewApi,
|
||||
)
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from models import Account, DataSourceOauthBinding
|
||||
from controllers.console.datasets.data_source import DataSourceNotionListApi
|
||||
from models import Account
|
||||
from models.dataset import Document
|
||||
from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
account = Account(name="Test User", email="[email protected]")
|
||||
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="[email protected]")
|
||||
account.id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info='{"notion_page_id": "page-1"}',
|
||||
batch=f"batch-{uuid4()}",
|
||||
name="Notion Page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
indexing_status=IndexingStatus.COMPLETED,
|
||||
enabled=True,
|
||||
)
|
||||
db_session_with_containers.add(document)
|
||||
db_session_with_containers.commit()
|
||||
runtime = MagicMock(
|
||||
get_online_document_pages=lambda **_kwargs: iter(
|
||||
[
|
||||
MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="workspace-1",
|
||||
workspace_name="Workspace",
|
||||
workspace_icon=None,
|
||||
pages=[
|
||||
MagicMock(
|
||||
page_id="page-1",
|
||||
page_name="Page",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
datasource_provider_type=lambda: None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_engine() -> Iterator[None]:
|
||||
with patch.object(
|
||||
type(data_source.db),
|
||||
"engine",
|
||||
new_callable=PropertyMock,
|
||||
return_value=MagicMock(),
|
||||
with (
|
||||
flask_app_with_containers.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "token"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(data_source_type="notion_import"),
|
||||
),
|
||||
patch(
|
||||
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
|
||||
return_value=runtime,
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
class TestDataSourceApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_success(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
binding = DataSourceOauthBinding(
|
||||
tenant_id="tenant-1",
|
||||
access_token="token",
|
||||
provider="notion",
|
||||
source_info={
|
||||
"workspace_name": "Workspace",
|
||||
"workspace_id": "workspace-1",
|
||||
"workspace_icon": None,
|
||||
"total": 1,
|
||||
"pages": [
|
||||
{
|
||||
"page_id": "page-1",
|
||||
"page_name": "Page",
|
||||
"page_icon": {"type": "emoji", "emoji": "P", "url": None},
|
||||
"parent_id": "parent-1",
|
||||
"type": "page",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
binding.id = "b1"
|
||||
binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC)
|
||||
binding.disabled = False
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.db.session.scalars",
|
||||
return_value=MagicMock(all=lambda: [binding]),
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
assert response["data"][0] == {
|
||||
"id": "b1",
|
||||
"provider": "notion",
|
||||
"created_at": 1779670923,
|
||||
"is_bound": True,
|
||||
"disabled": False,
|
||||
"source_info": {
|
||||
"workspace_name": "Workspace",
|
||||
"workspace_id": "workspace-1",
|
||||
"workspace_icon": None,
|
||||
"pages": [
|
||||
{
|
||||
"page_name": "Page",
|
||||
"page_id": "page-1",
|
||||
"page_icon": {"type": "emoji", "url": None, "emoji": "P"},
|
||||
"parent_id": "parent-1",
|
||||
"type": "page",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
"link": "http://localhost/console/api/oauth/data-source/notion",
|
||||
}
|
||||
|
||||
def test_get_no_bindings(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.db.session.scalars",
|
||||
return_value=MagicMock(all=lambda: []),
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
assert response["data"] == []
|
||||
|
||||
def test_patch_enable_binding(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
response, status = method(api, session, "tenant-1", "b1", "enable")
|
||||
|
||||
assert status == 200
|
||||
assert binding.disabled is False
|
||||
|
||||
def test_patch_disable_binding(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=False)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
response, status = method(api, session, "tenant-1", "b1", "disable")
|
||||
|
||||
assert status == 200
|
||||
assert binding.disabled is True
|
||||
|
||||
def test_patch_binding_not_found(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, session, "tenant-1", "b1", "enable")
|
||||
|
||||
def test_patch_enable_already_enabled(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=False)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, session, "tenant-1", "b1", "enable")
|
||||
|
||||
def test_patch_disable_already_disabled(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, session, "tenant-1", "b1", "disable")
|
||||
|
||||
|
||||
class TestDataSourceNotionListApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_credential_not_found(self, app: Flask, current_user: Account) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
|
||||
def test_get_success_no_dataset_id(self, app: Flask, current_user: Account, mock_engine: None) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
page = MagicMock(
|
||||
page_id="p1",
|
||||
page_name="Page 1",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
response, status = unwrap(DataSourceNotionListApi().get)(
|
||||
DataSourceNotionListApi(), db_session_with_containers, tenant_id, account
|
||||
)
|
||||
|
||||
online_document_message = MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="w1",
|
||||
workspace_name="My Workspace",
|
||||
workspace_icon="icon",
|
||||
pages=[page],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "t"},
|
||||
),
|
||||
patch(
|
||||
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
|
||||
return_value=MagicMock(
|
||||
get_online_document_pages=lambda **kw: iter([online_document_message]),
|
||||
datasource_provider_type=lambda: None,
|
||||
),
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_success_with_dataset_id(
|
||||
self, app: Flask, current_user: Account, mock_engine: None, db_session_with_containers: Session
|
||||
) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
tenant_id = str(uuid4())
|
||||
dataset_id = str(uuid4())
|
||||
|
||||
page = MagicMock(
|
||||
page_id="p1",
|
||||
page_name="Page 1",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
)
|
||||
|
||||
online_document_message = MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="w1",
|
||||
workspace_name="My Workspace",
|
||||
workspace_icon="icon",
|
||||
pages=[page],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
dataset = MagicMock(data_source_type="notion_import")
|
||||
document = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info='{"notion_page_id": "p1"}',
|
||||
batch=f"batch-{uuid4()}",
|
||||
name="Notion Page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
indexing_status=IndexingStatus.COMPLETED,
|
||||
enabled=True,
|
||||
)
|
||||
db_session_with_containers.add(document)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
with (
|
||||
app.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=dataset,
|
||||
),
|
||||
patch(
|
||||
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
|
||||
return_value=MagicMock(
|
||||
get_online_document_pages=lambda **kw: iter([online_document_message]),
|
||||
datasource_provider_type=lambda: None,
|
||||
),
|
||||
),
|
||||
):
|
||||
response, status = method(api, db_session_with_containers, tenant_id, current_user)
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_invalid_dataset_type(self, app: Flask, current_user: Account, mock_engine: None) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
dataset = MagicMock(data_source_type="other_type")
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1&dataset_id=ds1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=dataset,
|
||||
),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
|
||||
|
||||
class TestDataSourceNotionPreviewApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_preview_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionPreviewApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")])
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"integration_secret": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.NotionExtractor",
|
||||
return_value=extractor,
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1", "p1", "page")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionIndexingEstimateApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_post_indexing_estimate_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionIndexingEstimateApi()
|
||||
method = inspect.unwrap(api.post)
|
||||
|
||||
empty_rules: dict[str, object] = {}
|
||||
payload: dict[str, object] = {
|
||||
"notion_info_list": [
|
||||
{
|
||||
"workspace_id": "w1",
|
||||
"credential_id": "c1",
|
||||
"pages": [{"page_id": "p1", "type": "page"}],
|
||||
}
|
||||
],
|
||||
"process_rule": {"rules": empty_rules},
|
||||
"doc_form": IndexStructureType.PARAGRAPH_INDEX,
|
||||
"doc_language": "English",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.estimate_args_validate",
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.IndexingRunner.indexing_estimate",
|
||||
return_value=MagicMock(model_dump=lambda: {"total_pages": 1}),
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionDatasetSyncApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id",
|
||||
return_value=[MagicMock(id="d1")],
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "ds-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_dataset_not_found(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), "ds-1")
|
||||
|
||||
|
||||
class TestDataSourceNotionDocumentSyncApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "ds-1", "doc-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_document_not_found(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), "ds-1", "doc-1")
|
||||
assert status == 200
|
||||
assert response["notion_info"][0]["pages"][0]["is_bound"] is True
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
preset = "strict"
|
||||
strict-callable-subtyping = true
|
||||
project-includes = ["."]
|
||||
search-path = ["../.."]
|
||||
python-platform = "linux"
|
||||
python-version = "3.12.0"
|
||||
infer-with-first-use = true
|
||||
min-severity = "warn"
|
||||
|
||||
# Verify project-excludes from the repo root:
|
||||
# tmp_config=$(mktemp --tmpdir=api/tests/test_containers_integration_tests pyrefly-no-excludes.XXXXXX.toml)
|
||||
# awk 'BEGIN {skip=0} /^project-excludes = \[/ {skip=1; next} skip && /^\]/ {skip=0; next} !skip {print}' api/tests/test_containers_integration_tests/pyrefly.toml > "$tmp_config"
|
||||
# tmp_name=$(basename "$tmp_config")
|
||||
# comm -3 <(sed -n 's/^ "\(.*\)",$/\1/p' api/tests/test_containers_integration_tests/pyrefly.toml | sort) <(uv --directory api run pyrefly check --config "tests/test_containers_integration_tests/$tmp_name" --summary=none --output-format=min-text 2>/dev/null | rg '^ERROR ' | sed -E 's#^ERROR (tests/test_containers_integration_tests/[^:]+):.*#\1#' | sed 's#^tests/test_containers_integration_tests/##' | sort -u)
|
||||
# rm --force "$tmp_config"
|
||||
# Existing strict-mode debt. Remove a file when bringing it under strict checking.
|
||||
project-excludes = [
|
||||
"commands/test_legacy_model_type_migration.py",
|
||||
"controllers/console/app/test_app_apis.py",
|
||||
"controllers/console/app/test_app_import_api.py",
|
||||
"controllers/console/app/test_chat_conversation_status_count_api.py",
|
||||
"controllers/console/app/test_conversation_read_timestamp.py",
|
||||
"controllers/console/app/test_workflow_draft_variable.py",
|
||||
"controllers/console/auth/test_email_register.py",
|
||||
"controllers/console/auth/test_forgot_password.py",
|
||||
"controllers/console/auth/test_oauth.py",
|
||||
"controllers/console/auth/test_password_reset.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py",
|
||||
"controllers/console/datasets/test_data_source.py",
|
||||
"controllers/console/explore/test_conversation.py",
|
||||
"controllers/console/test_api_based_extension.py",
|
||||
"controllers/console/test_apikey.py",
|
||||
"controllers/console/workspace/test_tool_provider.py",
|
||||
"controllers/console/workspace/test_trigger_providers.py",
|
||||
"controllers/console/workspace/test_workspace_wraps.py",
|
||||
"controllers/mcp/test_mcp.py",
|
||||
"controllers/service_api/dataset/test_dataset.py",
|
||||
"controllers/service_api/test_site.py",
|
||||
"controllers/web/test_conversation.py",
|
||||
"controllers/web/test_site.py",
|
||||
"controllers/web/test_web_forgot_password.py",
|
||||
"controllers/web/test_wraps.py",
|
||||
"core/app/layers/test_pause_state_persist_layer.py",
|
||||
"core/rag/pipeline/test_queue_integration.py",
|
||||
@@ -57,22 +39,16 @@ project-excludes = [
|
||||
"repositories/test_sqlalchemy_execution_extra_content_repository.py",
|
||||
"repositories/test_sqlalchemy_workflow_node_execution_repository.py",
|
||||
"repositories/test_workflow_run_repository.py",
|
||||
"services/auth/test_api_key_auth_service.py",
|
||||
"services/auth/test_auth_integration.py",
|
||||
"services/dataset_collection_binding.py",
|
||||
"services/dataset_service_update_delete.py",
|
||||
"services/document_service_status.py",
|
||||
"services/enterprise/test_account_deletion_sync.py",
|
||||
"services/plugin/test_plugin_parameter_service.py",
|
||||
"services/plugin/test_plugin_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_service_db.py",
|
||||
"services/recommend_app/test_database_retrieval.py",
|
||||
"services/test_account_service.py",
|
||||
"services/test_advanced_prompt_template_service.py",
|
||||
"services/test_agent_service.py",
|
||||
"services/test_annotation_service.py",
|
||||
"services/test_api_based_extension_service.py",
|
||||
"services/test_api_token_service.py",
|
||||
"services/test_app_dsl_service.py",
|
||||
"services/test_app_generate_service.py",
|
||||
"services/test_app_service.py",
|
||||
@@ -97,20 +73,15 @@ project-excludes = [
|
||||
"services/test_document_service_rename_document.py",
|
||||
"services/test_end_user_service.py",
|
||||
"services/test_feature_service.py",
|
||||
"services/test_feedback_service.py",
|
||||
"services/test_file_service.py",
|
||||
"services/test_human_input_delivery_test.py",
|
||||
"services/test_human_input_delivery_test_service.py",
|
||||
"services/test_message_export_service.py",
|
||||
"services/test_message_service.py",
|
||||
"services/test_message_service_execution_extra_content.py",
|
||||
"services/test_message_service_extra_contents.py",
|
||||
"services/test_messages_clean_service.py",
|
||||
"services/test_metadata_partial_update.py",
|
||||
"services/test_metadata_service.py",
|
||||
"services/test_model_load_balancing_service.py",
|
||||
"services/test_model_provider_service.py",
|
||||
"services/test_oauth_server_service.py",
|
||||
"services/test_ops_service.py",
|
||||
"services/test_restore_archived_workflow_run.py",
|
||||
"services/test_saved_message_service.py",
|
||||
@@ -124,7 +95,6 @@ project-excludes = [
|
||||
"services/test_workflow_run_service.py",
|
||||
"services/test_workflow_service.py",
|
||||
"services/test_workspace_service.py",
|
||||
"services/tools/test_api_tools_manage_service.py",
|
||||
"services/tools/test_mcp_tools_manage_service.py",
|
||||
"services/tools/test_tools_transform_service.py",
|
||||
"services/tools/test_workflow_tools_manage_service.py",
|
||||
@@ -161,7 +131,6 @@ project-excludes = [
|
||||
"test_workflow_pause_integration.py",
|
||||
"trigger/conftest.py",
|
||||
"trigger/test_trigger_e2e.py",
|
||||
"workflow/nodes/code_executor/test_code_executor.py",
|
||||
"workflow/nodes/code_executor/test_code_javascript.py",
|
||||
"workflow/nodes/code_executor/test_code_jinja2.py",
|
||||
"workflow/nodes/code_executor/test_code_python3.py",
|
||||
@@ -169,6 +138,7 @@ project-excludes = [
|
||||
]
|
||||
|
||||
[errors]
|
||||
missing-override-decorator = "error"
|
||||
redundant-cast = true
|
||||
unannotated-return = true
|
||||
unnecessary-type-conversion = true
|
||||
|
||||
+1
-58
@@ -4,7 +4,7 @@ import datetime
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@@ -1172,65 +1172,8 @@ class TestMessagesCleanServiceIntegration:
|
||||
# Verify all messages were deleted
|
||||
assert db_session_with_containers.query(Message).where(Message.id.in_(msg_ids)).count() == 0
|
||||
|
||||
def test_from_time_range_validation(self):
|
||||
"""Test that from_time_range raises ValueError for invalid inputs."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
now = datetime.datetime.now()
|
||||
|
||||
with pytest.raises(ValueError, match="start_from .* must be less than end_before"):
|
||||
MessagesCleanService.from_time_range(policy, now, now)
|
||||
|
||||
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
|
||||
MessagesCleanService.from_time_range(policy, now - datetime.timedelta(days=1), now, batch_size=0)
|
||||
|
||||
def test_from_time_range_success(self):
|
||||
"""Test that from_time_range creates a service with correct parameters."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
start = datetime.datetime(2024, 1, 1)
|
||||
end = datetime.datetime(2024, 2, 1)
|
||||
|
||||
service = MessagesCleanService.from_time_range(policy, start, end)
|
||||
assert service._start_from == start
|
||||
assert service._end_before == end
|
||||
|
||||
def test_from_days_validation(self):
|
||||
"""Test that from_days raises ValueError for invalid inputs."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
|
||||
with pytest.raises(ValueError, match="days .* must be greater than or equal to 0"):
|
||||
MessagesCleanService.from_days(policy, days=-1)
|
||||
|
||||
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
|
||||
MessagesCleanService.from_days(policy, days=30, batch_size=0)
|
||||
|
||||
def test_from_days_success(self):
|
||||
"""Test that from_days creates a service with correct parameters."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
|
||||
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
|
||||
fixed_now = datetime.datetime(2024, 6, 1)
|
||||
mock_now.return_value = fixed_now
|
||||
|
||||
service = MessagesCleanService.from_days(policy, days=10)
|
||||
assert service._start_from is None
|
||||
assert service._end_before == fixed_now - datetime.timedelta(days=10)
|
||||
|
||||
def test_batch_delete_message_relations_empty(self, db_session_with_containers: Session):
|
||||
"""Test that batch_delete_message_relations with empty list does nothing."""
|
||||
# Get execute call count before
|
||||
MessagesCleanService._batch_delete_message_relations(db_session_with_containers, [])
|
||||
# No exception means success — empty list is a no-op
|
||||
|
||||
def test_run_calls_clean_messages(self):
|
||||
"""Test that run() delegates to _clean_messages_by_time_range."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
service = MessagesCleanService(
|
||||
policy=policy,
|
||||
end_before=datetime.datetime.now(),
|
||||
batch_size=10,
|
||||
)
|
||||
with patch.object(service, "_clean_messages_by_time_range") as mock_clean:
|
||||
mock_clean.return_value = {"total_deleted": 5}
|
||||
result = service.run()
|
||||
assert result == {"total_deleted": 5}
|
||||
mock_clean.assert_called_once()
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -12,13 +11,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
|
||||
from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.workflow import WorkflowAppLogCreatedFrom
|
||||
from services.account_service import AccountService, TenantService
|
||||
|
||||
# Delay import of AppService to avoid circular dependency
|
||||
# from services.app_service import AppService, CreateAppParams
|
||||
from services.workflow_app_service import LogView, WorkflowAppService
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
from tests.test_containers_integration_tests.helpers import generate_valid_password
|
||||
|
||||
|
||||
@@ -1627,73 +1626,3 @@ class TestWorkflowAppService:
|
||||
end_user_item = next(d for d in result["data"] if d["created_by_end_user"] is not None)
|
||||
assert account_item["created_by_account"].id == account.id
|
||||
assert end_user_item["created_by_end_user"].id == end_user.id
|
||||
|
||||
|
||||
class TestLogView:
|
||||
def test_details_and_proxy_attributes(self):
|
||||
log = SimpleNamespace(id="log-1", status="succeeded")
|
||||
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
|
||||
|
||||
assert view.details == {"trigger_metadata": {"type": "plugin"}}
|
||||
assert view.status == "succeeded"
|
||||
|
||||
|
||||
class TestHandleTriggerMetadata:
|
||||
def test_returns_empty_dict_when_metadata_missing(self):
|
||||
service = WorkflowAppService()
|
||||
assert service.handle_trigger_metadata("tenant-1", None) == {}
|
||||
|
||||
def test_enriches_plugin_icons(self):
|
||||
service = WorkflowAppService()
|
||||
meta = {
|
||||
"type": AppTriggerType.TRIGGER_PLUGIN.value,
|
||||
"icon_filename": "light.png",
|
||||
"icon_dark_filename": "dark.png",
|
||||
}
|
||||
with patch(
|
||||
"services.workflow_app_service.PluginService.get_plugin_icon_url",
|
||||
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
|
||||
) as mock_icon:
|
||||
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
|
||||
|
||||
assert result["icon"] == "https://cdn/light.png"
|
||||
assert result["icon_dark"] == "https://cdn/dark.png"
|
||||
assert mock_icon.call_count == 2
|
||||
|
||||
def test_non_plugin_metadata_without_icon_lookup(self):
|
||||
service = WorkflowAppService()
|
||||
meta = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
|
||||
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
|
||||
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
|
||||
|
||||
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
|
||||
mock_icon.assert_not_called()
|
||||
|
||||
|
||||
class TestSafeJsonLoads:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
('{"k":"v"}', {"k": "v"}),
|
||||
("not-json", None),
|
||||
({"raw": True}, {"raw": True}),
|
||||
],
|
||||
)
|
||||
def test_handles_various_inputs(self, value, expected):
|
||||
assert WorkflowAppService._safe_json_loads(value) == expected
|
||||
|
||||
|
||||
class TestSafeParseUuid:
|
||||
def test_returns_none_for_short_or_invalid_values(self):
|
||||
service = WorkflowAppService()
|
||||
assert service._safe_parse_uuid("short") is None
|
||||
assert service._safe_parse_uuid("x" * 40) is None
|
||||
|
||||
def test_returns_uuid_for_valid_string(self):
|
||||
service = WorkflowAppService()
|
||||
raw = str(uuid.uuid4())
|
||||
result = service._safe_parse_uuid(raw)
|
||||
assert result is not None
|
||||
assert str(result) == raw
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
extend = "../../.ruff.toml"
|
||||
src = ["../.."]
|
||||
|
||||
[lint]
|
||||
extend-select = ["ANN401", "ARG"]
|
||||
|
||||
# Existing strict-mode debt. Remove a file entry when bringing it under strict checking.
|
||||
[lint.per-file-ignores]
|
||||
"clients/agent_backend/test_request_builder.py" = ["TID251"]
|
||||
"commands/test_archive_workflow_runs.py" = ["ARG005"]
|
||||
"commands/test_data_migration_wizard.py" = ["ARG005"]
|
||||
"commands/test_legacy_model_type_migration.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"controllers/common/test_agent_app_parameters.py" = ["ARG005", "TID251"]
|
||||
"controllers/common/test_app_access.py" = ["ARG005"]
|
||||
"controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"]
|
||||
"controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/app/test_agent_config_inspector.py" = ["ARG005"]
|
||||
"controllers/console/app/test_agent_drive_inspector.py" = ["ARG005"]
|
||||
"controllers/console/app/test_agent_manage_guard.py" = ["ARG001"]
|
||||
"controllers/console/app/test_agent_skills.py" = ["ARG005"]
|
||||
"controllers/console/app/test_annotation_security.py" = ["ARG002"]
|
||||
"controllers/console/app/test_app_apis.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/app/test_app_import_api.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"controllers/console/app/test_app_response_models.py" = ["ARG002", "ARG004", "ARG005"]
|
||||
"controllers/console/app/test_conversation_api.py" = ["ARG001"]
|
||||
"controllers/console/app/test_generator_api.py" = ["ARG001"]
|
||||
"controllers/console/app/test_mcp_server_response.py" = ["ARG002"]
|
||||
"controllers/console/app/test_message_api.py" = ["ARG001"]
|
||||
"controllers/console/app/test_statistic_api.py" = ["ANN401", "ARG001", "TID251"]
|
||||
"controllers/console/app/test_workflow.py" = ["ARG001", "ARG005"]
|
||||
"controllers/console/app/test_workflow_convert_api.py" = ["ARG005"]
|
||||
"controllers/console/app/test_workflow_node_output_inspector.py" = ["ANN401", "ARG001", "TID251"]
|
||||
"controllers/console/app/test_workflow_run_api.py" = ["ANN401", "TID251"]
|
||||
"controllers/console/app/workflow_draft_variables_test.py" = ["TID251"]
|
||||
"controllers/console/auth/test_account_activation.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_authentication_security.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_email_verification.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_login_logout.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_oauth.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_oauth_timezone.py" = ["ARG001"]
|
||||
"controllers/console/auth/test_password_reset.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_token_refresh.py" = ["ARG002"]
|
||||
"controllers/console/billing/test_billing.py" = ["ARG002"]
|
||||
"controllers/console/datasets/test_datasets.py" = ["ARG005"]
|
||||
"controllers/console/datasets/test_datasets_document.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/datasets/test_datasets_document_download.py" = ["ARG005"]
|
||||
"controllers/console/datasets/test_datasets_segments.py" = ["TID251"]
|
||||
"controllers/console/datasets/test_external.py" = ["TID251"]
|
||||
"controllers/console/datasets/test_wraps.py" = ["ARG001"]
|
||||
"controllers/console/explore/test_trial.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"controllers/console/explore/test_wraps.py" = ["ARG001"]
|
||||
"controllers/console/snippets/test_snippet_workflow.py" = ["ARG001"]
|
||||
"controllers/console/tag/test_tags.py" = ["ARG002"]
|
||||
"controllers/console/test_files.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/test_human_input_form.py" = ["ARG001", "ARG005"]
|
||||
"controllers/console/test_init_validate.py" = ["ARG005"]
|
||||
"controllers/console/test_workspace_account.py" = ["ARG002"]
|
||||
"controllers/console/test_workspace_members.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/test_wraps.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/workspace/test_load_balancing_config.py" = ["ARG001"]
|
||||
"controllers/console/workspace/test_plugin.py" = ["ARG002", "TID251"]
|
||||
"controllers/console/workspace/test_snippets.py" = ["ARG002"]
|
||||
"controllers/console/workspace/test_tool_providers.py" = ["ARG001", "ARG005"]
|
||||
"controllers/console/workspace/test_trigger_providers.py" = ["ARG001"]
|
||||
"controllers/console/workspace/test_workspace.py" = ["ARG005"]
|
||||
"controllers/files/test_image_preview.py" = ["ARG005"]
|
||||
"controllers/files/test_tool_files.py" = ["ARG002", "ARG005"]
|
||||
"controllers/files/test_upload.py" = ["ARG002", "ARG005"]
|
||||
"controllers/inner_api/plugin/test_plugin.py" = ["ARG002"]
|
||||
"controllers/inner_api/plugin/test_plugin_wraps.py" = ["ARG001", "ARG002", "ARG003", "TID251"]
|
||||
"controllers/inner_api/test_runtime_credentials.py" = ["ARG001"]
|
||||
"controllers/mcp/test_mcp.py" = ["ARG002"]
|
||||
"controllers/openapi/auth/test_conditions.py" = ["ARG005"]
|
||||
"controllers/openapi/auth/test_flow.py" = ["ARG005"]
|
||||
"controllers/openapi/auth/test_pipeline.py" = ["ARG001"]
|
||||
"controllers/openapi/conftest.py" = ["ARG001"]
|
||||
"controllers/openapi/test_account.py" = ["ARG005"]
|
||||
"controllers/openapi/test_app_describe_builder.py" = ["ARG001"]
|
||||
"controllers/openapi/test_app_run_streaming.py" = ["ARG001"]
|
||||
"controllers/openapi/test_contract.py" = ["ARG001", "TID251"]
|
||||
"controllers/openapi/test_error_contract.py" = ["ARG002"]
|
||||
"controllers/openapi/test_human_input_form.py" = ["ARG002"]
|
||||
"controllers/openapi/test_oauth_sso_claims.py" = ["ARG002"]
|
||||
"controllers/openapi/test_workflow_events_openapi.py" = ["ARG002", "ARG005"]
|
||||
"controllers/openapi/test_workspaces_members.py" = ["ARG001"]
|
||||
"controllers/service_api/app/test_app.py" = ["ARG002"]
|
||||
"controllers/service_api/app/test_completion.py" = ["ARG002"]
|
||||
"controllers/service_api/app/test_file.py" = ["ARG002"]
|
||||
"controllers/service_api/app/test_hitl_service_api.py" = ["ARG002", "ARG005"]
|
||||
"controllers/service_api/app/test_workflow_events.py" = ["ARG005"]
|
||||
"controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py" = ["ARG002"]
|
||||
"controllers/service_api/dataset/test_dataset_segment.py" = ["ARG002"]
|
||||
"controllers/service_api/dataset/test_document.py" = ["ARG001", "ARG002"]
|
||||
"controllers/service_api/dataset/test_metadata.py" = ["ARG002"]
|
||||
"controllers/service_api/test_trace_session_id_parsing.py" = ["ARG001"]
|
||||
"controllers/service_api/test_wraps.py" = ["ARG001", "ARG002"]
|
||||
"controllers/trigger/test_trigger.py" = ["ARG002"]
|
||||
"controllers/trigger/test_webhook.py" = ["ARG002"]
|
||||
"controllers/web/conftest.py" = ["ANN401", "TID251"]
|
||||
"controllers/web/test_app.py" = ["ARG002", "ARG005"]
|
||||
"controllers/web/test_audio.py" = ["ARG002"]
|
||||
"controllers/web/test_completion.py" = ["ARG002"]
|
||||
"controllers/web/test_feature.py" = ["ARG002"]
|
||||
"controllers/web/test_human_input_form.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"controllers/web/test_message_endpoints.py" = ["ARG002"]
|
||||
"controllers/web/test_remote_files.py" = ["ARG002"]
|
||||
"controllers/web/test_saved_message.py" = ["ARG002"]
|
||||
"controllers/web/test_web_login.py" = ["ARG002"]
|
||||
"controllers/web/test_web_passport.py" = ["ARG002"]
|
||||
"controllers/web/test_workflow.py" = ["ARG002"]
|
||||
"core/agent/test_base_agent_runner.py" = ["ARG002"]
|
||||
"core/agent/test_cot_agent_runner.py" = ["ARG001"]
|
||||
"core/agent/test_cot_chat_agent_runner.py" = ["ARG002"]
|
||||
"core/agent/test_fc_agent_runner.py" = ["TID251"]
|
||||
"core/app/app_config/common/test_parameters_mapping.py" = ["ARG002"]
|
||||
"core/app/app_config/easy_ui_based_app/test_dataset_manager.py" = ["ARG001", "ARG002"]
|
||||
"core/app/app_config/easy_ui_based_app/test_model_config_converter.py" = ["ARG002"]
|
||||
"core/app/app_config/easy_ui_based_app/test_variables_manager.py" = ["ARG002"]
|
||||
"core/app/apps/advanced_chat/test_app_generator.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/advanced_chat/test_app_runner_input_moderation.py" = ["ARG001", "ARG005"]
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline.py" = ["ARG005"]
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/agent_app/test_app_generator.py" = ["ARG001", "ARG005"]
|
||||
"core/app/apps/agent_app/test_app_runner.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"core/app/apps/agent_app/test_input_guards.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/app/apps/agent_app/test_resolve_agent.py" = ["ANN401", "TID251"]
|
||||
"core/app/apps/agent_app/test_runtime_request_builder.py" = ["ARG002", "TID251"]
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_config_manager.py" = ["ARG005"]
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_generator.py" = ["ARG001"]
|
||||
"core/app/apps/chat/test_app_config_manager.py" = ["ARG001"]
|
||||
"core/app/apps/chat/test_app_generator_and_runner.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/common/test_workflow_response_converter_truncation.py" = ["TID251"]
|
||||
"core/app/apps/completion/test_app_runner.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/pipeline/test_pipeline_generator.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/pipeline/test_pipeline_runner.py" = ["ARG001", "ARG002"]
|
||||
"core/app/apps/test_advanced_chat_app_generator.py" = ["ARG001"]
|
||||
"core/app/apps/test_base_app_generator.py" = ["ARG005"]
|
||||
"core/app/apps/test_base_app_runner.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/test_pause_resume.py" = ["ANN401", "TID251"]
|
||||
"core/app/apps/test_streaming_utils.py" = ["ARG001"]
|
||||
"core/app/apps/test_workflow_app_generator.py" = ["ARG005"]
|
||||
"core/app/apps/test_workflow_app_runner_core.py" = ["ARG001", "ARG002", "ARG004", "ARG005"]
|
||||
"core/app/apps/test_workflow_app_runner_single_node.py" = ["ANN401", "TID251"]
|
||||
"core/app/apps/test_workflow_pause_events.py" = ["ARG005"]
|
||||
"core/app/apps/workflow/test_app_generator_extra.py" = ["ARG005"]
|
||||
"core/app/apps/workflow/test_generate_task_pipeline_core.py" = ["ARG002", "ARG005"]
|
||||
"core/app/features/rate_limiting/test_rate_limit.py" = ["ARG001"]
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py" = ["ARG002"]
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/task_pipeline/test_message_cycle_manager_optimization.py" = ["ARG002"]
|
||||
"core/app/test_easy_ui_model_config_manager.py" = ["ARG005"]
|
||||
"core/app/workflow/layers/test_persistence_inspector_publish.py" = ["ANN401", "ARG005", "TID251"]
|
||||
"core/app/workflow/test_file_runtime.py" = ["ARG001", "ARG005"]
|
||||
"core/app/workflow/test_observability_layer_extra.py" = ["ARG005"]
|
||||
"core/app/workflow/test_persistence_layer.py" = ["ARG001"]
|
||||
"core/base/test_app_generator_tts_publisher.py" = ["ARG002"]
|
||||
"core/callback_handler/test_agent_tool_callback_handler.py" = ["ARG002"]
|
||||
"core/callback_handler/test_workflow_tool_callback_handler.py" = ["ARG002"]
|
||||
"core/datasource/__base/test_datasource_provider.py" = ["ARG002"]
|
||||
"core/datasource/test_datasource_file_manager.py" = ["ARG001", "ARG002"]
|
||||
"core/datasource/test_notion_provider.py" = ["ARG002", "TID251"]
|
||||
"core/datasource/test_website_crawl.py" = ["ARG002"]
|
||||
"core/datasource/utils/test_message_transformer.py" = ["ARG002"]
|
||||
"core/entities/test_entities_mcp_provider.py" = ["ARG001"]
|
||||
"core/entities/test_entities_provider_configuration.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/extension/test_extensible.py" = ["ARG002", "ARG005"]
|
||||
"core/external_data_tool/api/test_api.py" = ["ARG001"]
|
||||
"core/external_data_tool/test_base.py" = ["TID251"]
|
||||
"core/external_data_tool/test_external_data_fetch.py" = ["ARG001"]
|
||||
"core/helper/code_executor/test_code_executor.py" = ["TID251"]
|
||||
"core/helper/code_executor/test_template_transformer.py" = ["ANN401", "TID251"]
|
||||
"core/llm_generator/test_llm_generator.py" = ["ARG002"]
|
||||
"core/mcp/auth/test_auth_flow.py" = ["ARG002"]
|
||||
"core/mcp/client/test_session.py" = ["ARG001", "TID251"]
|
||||
"core/mcp/client/test_sse.py" = ["ARG001", "TID251"]
|
||||
"core/mcp/client/test_streamable_http.py" = ["ARG001", "ARG005", "S110", "TID251"]
|
||||
"core/mcp/session/test_base_session.py" = ["S110"]
|
||||
"core/mcp/session/test_client_session.py" = ["ARG005"]
|
||||
"core/mcp/test_mcp_client.py" = ["ARG002"]
|
||||
"core/memory/test_token_buffer_memory.py" = ["ARG002"]
|
||||
"core/moderation/test_content_moderation.py" = ["TID251"]
|
||||
"core/moderation/test_output_moderation.py" = ["ARG001", "ARG002"]
|
||||
"core/ops/test_base_trace_instance.py" = ["ARG001"]
|
||||
"core/ops/test_lookup_helpers.py" = ["ARG002"]
|
||||
"core/ops/test_ops_trace_manager.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/ops/test_trace_queue_manager.py" = ["ARG004"]
|
||||
"core/ops/test_trace_session_metadata.py" = ["ARG001", "ARG005"]
|
||||
"core/plugin/impl/test_agent_client.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_datasource_manager.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_oauth_handler.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_tool_manager.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_trigger_client.py" = ["ARG001"]
|
||||
"core/plugin/test_endpoint_client.py" = ["ARG002"]
|
||||
"core/plugin/test_model_runtime_adapter.py" = ["ARG002"]
|
||||
"core/plugin/test_plugin_runtime.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/prompt/test_advanced_prompt_transform.py" = ["ARG005"]
|
||||
"core/prompt/test_prompt_transform.py" = ["ARG005"]
|
||||
"core/rag/datasource/keyword/jieba/test_jieba.py" = ["ARG001", "TID251"]
|
||||
"core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py" = ["ARG004"]
|
||||
"core/rag/datasource/keyword/test_keyword_factory.py" = ["ARG005"]
|
||||
"core/rag/datasource/test_datasource_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"core/rag/datasource/test_retrieval_attachment_access.py" = ["ARG005"]
|
||||
"core/rag/datasource/vdb/test_vector_factory.py" = ["ARG002"]
|
||||
"core/rag/embedding/test_embedding_base.py" = ["TID251"]
|
||||
"core/rag/embedding/test_embedding_service.py" = ["ARG001"]
|
||||
"core/rag/extractor/firecrawl/test_firecrawl.py" = ["TID251"]
|
||||
"core/rag/extractor/test_csv_extractor.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/test_excel_extractor.py" = ["ARG002", "ARG005"]
|
||||
"core/rag/extractor/test_extract_processor.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/test_helpers.py" = ["ARG002"]
|
||||
"core/rag/extractor/test_markdown_extractor.py" = ["ARG001"]
|
||||
"core/rag/extractor/test_notion_extractor.py" = ["ARG002", "ARG005"]
|
||||
"core/rag/extractor/test_pdf_extractor.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/test_text_extractor.py" = ["ARG001"]
|
||||
"core/rag/extractor/test_word_extractor.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/rag/extractor/unstructured/test_unstructured_extractors.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/watercrawl/test_watercrawl.py" = ["ARG001", "ARG005", "TID251"]
|
||||
"core/rag/indexing/processor/conftest.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/rag/indexing/processor/test_paragraph_index_processor.py" = ["ARG002", "TID251"]
|
||||
"core/rag/indexing/processor/test_qa_index_processor.py" = ["ARG001", "TID251"]
|
||||
"core/rag/indexing/test_index_processor.py" = ["ARG005"]
|
||||
"core/rag/indexing/test_indexing_runner.py" = ["ARG005", "TID251"]
|
||||
"core/rag/pipeline/test_queue.py" = ["ARG002"]
|
||||
"core/rag/retrieval/test_dataset_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"core/rag/splitter/test_text_splitter.py" = ["ARG002", "ARG005"]
|
||||
"core/repositories/test_celery_workflow_execution_repository.py" = ["ARG002"]
|
||||
"core/repositories/test_celery_workflow_node_execution_repository.py" = ["ARG002"]
|
||||
"core/repositories/test_human_input_form_repository_impl.py" = ["ARG001", "ARG005"]
|
||||
"core/repositories/test_human_input_repository.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/repositories/test_sqlalchemy_workflow_node_execution_repository.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"core/repositories/test_workflow_node_execution_truncation.py" = ["TID251"]
|
||||
"core/schemas/test_resolver.py" = ["ARG005", "T201"]
|
||||
"core/telemetry/test_facade.py" = ["ARG002", "ARG004"]
|
||||
"core/telemetry/test_gateway_integration.py" = ["ARG002"]
|
||||
"core/test_model_manager.py" = ["ARG001"]
|
||||
"core/test_trigger_debug_event_selectors.py" = ["ARG002"]
|
||||
"core/tools/test_base_tool.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/tools/test_builtin_tool_base.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/tools/test_builtin_tool_provider.py" = ["ARG001", "ARG005", "TID251"]
|
||||
"core/tools/test_builtin_tools_extra.py" = ["ARG005"]
|
||||
"core/tools/test_custom_tool.py" = ["ARG001", "ARG005", "TID251"]
|
||||
"core/tools/test_dataset_retriever_tool.py" = ["ARG005"]
|
||||
"core/tools/test_mcp_tool.py" = ["S110"]
|
||||
"core/tools/test_tool_engine.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"core/tools/test_tool_file_manager.py" = ["ARG001"]
|
||||
"core/tools/test_tool_label_manager.py" = ["TID251"]
|
||||
"core/tools/test_tool_manager.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/tools/test_tool_provider_controller.py" = ["TID251"]
|
||||
"core/tools/utils/test_configuration.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/tools/utils/test_encryption.py" = ["ANN401", "TID251"]
|
||||
"core/tools/utils/test_message_transformer.py" = ["TID251"]
|
||||
"core/tools/utils/test_misc_utils_extra.py" = ["ARG002"]
|
||||
"core/tools/utils/test_model_invocation_utils.py" = ["ARG005", "TID251"]
|
||||
"core/tools/utils/test_parser.py" = ["TID251"]
|
||||
"core/tools/utils/test_web_reader_tool.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/tools/workflow_as_tool/test_provider.py" = ["TID251"]
|
||||
"core/tools/workflow_as_tool/test_tool.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/trigger/conftest.py" = ["ANN401", "TID251"]
|
||||
"core/trigger/debug/test_debug_event_selectors.py" = ["ARG002", "TID251"]
|
||||
"core/variables/test_segment_type_validation.py" = ["TID251"]
|
||||
"core/workflow/context/test_execution_context.py" = ["ANN401", "ARG002", "S110", "TID251"]
|
||||
"core/workflow/context/test_flask_app_context.py" = ["ARG002"]
|
||||
"core/workflow/generator/test_runner.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/workflow/generator/test_runner_missing.py" = ["ARG003"]
|
||||
"core/workflow/generator/test_tool_catalogue.py" = ["ARG002"]
|
||||
"core/workflow/graph_engine/layers/test_observability.py" = ["ARG002"]
|
||||
"core/workflow/graph_engine/test_mock_config.py" = ["TID251"]
|
||||
"core/workflow/graph_engine/test_mock_factory.py" = ["TID251"]
|
||||
"core/workflow/graph_engine/test_mock_nodes.py" = ["ANN401", "S110", "TID251"]
|
||||
"core/workflow/graph_engine/test_parallel_human_input_join_resume.py" = ["ARG002", "TID251"]
|
||||
"core/workflow/graph_engine/test_table_runner.py" = ["ARG001", "TID251"]
|
||||
"core/workflow/nodes/agent_v2/test_agent_node.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/workflow/nodes/agent_v2/test_ask_human_hitl.py" = ["ANN401", "TID251"]
|
||||
"core/workflow/nodes/agent_v2/test_dify_tools_builder.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"core/workflow/nodes/agent_v2/test_output_adapter.py" = ["ARG005"]
|
||||
"core/workflow/nodes/agent_v2/test_runtime_request_builder.py" = ["ARG002"]
|
||||
"core/workflow/nodes/agent_v2/test_validators.py" = ["ARG001"]
|
||||
"core/workflow/nodes/http_request/test_http_request_node.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/workflow/nodes/human_input/test_entities.py" = ["TID251"]
|
||||
"core/workflow/nodes/human_input/test_human_input_form_filled_event.py" = ["TID251"]
|
||||
"core/workflow/nodes/iteration/test_iteration_child_engine_errors.py" = ["ARG002", "TID251"]
|
||||
"core/workflow/nodes/knowledge_index/test_knowledge_index_node.py" = ["ARG002"]
|
||||
"core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py" = ["ARG002"]
|
||||
"core/workflow/nodes/llm/test_node.py" = ["ARG002"]
|
||||
"core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py" = ["TID251"]
|
||||
"core/workflow/nodes/test_document_extractor_node.py" = ["ARG001"]
|
||||
"core/workflow/nodes/tool/test_tool_node.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/workflow/nodes/webhook/test_webhook_file_conversion.py" = ["TID251"]
|
||||
"core/workflow/nodes/webhook/test_webhook_node.py" = ["TID251"]
|
||||
"core/workflow/test_form_input_serialization_compat.py" = ["ANN401", "TID251"]
|
||||
"core/workflow/test_human_input_adapter.py" = ["ARG005"]
|
||||
"core/workflow/test_node_factory.py" = ["ARG002"]
|
||||
"core/workflow/test_workflow_entry.py" = ["ARG001"]
|
||||
"enterprise/telemetry/test_enterprise_trace.py" = ["ARG002", "TID251"]
|
||||
"enterprise/telemetry/test_exporter.py" = ["ARG001"]
|
||||
"enterprise/telemetry/test_gateway.py" = ["ARG002"]
|
||||
"events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py" = ["ARG005"]
|
||||
"extensions/logstore/test_sql_escape.py" = ["ARG001", "ARG002"]
|
||||
"extensions/otel/decorators/handlers/test_generate_handler.py" = ["ARG001", "ARG002"]
|
||||
"extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py" = ["ARG001"]
|
||||
"extensions/otel/decorators/test_base.py" = ["ARG002"]
|
||||
"extensions/otel/decorators/test_handler.py" = ["ARG002"]
|
||||
"extensions/otel/test_retrieval_tracing.py" = ["ARG001"]
|
||||
"extensions/test_ext_request_logging.py" = ["ARG002"]
|
||||
"extensions/test_redis.py" = ["ARG001"]
|
||||
"factories/test_build_from_mapping.py" = ["ARG001"]
|
||||
"factories/test_file_factory.py" = ["ARG001"]
|
||||
"factories/test_variable_factory.py" = ["TID251"]
|
||||
"fields/test_file_fields.py" = ["ARG005"]
|
||||
"libs/_human_input/support.py" = ["TID251"]
|
||||
"libs/broadcast_channel/redis/test_channel_unit_tests.py" = ["ARG002", "ARG005"]
|
||||
"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"libs/test_cron_compatibility.py" = ["S110"]
|
||||
"libs/test_email_i18n.py" = ["ANN401", "TID251"]
|
||||
"libs/test_oauth_bearer_rate_limit_ordering.py" = ["ARG001"]
|
||||
"libs/test_pyrefly_type_coverage.py" = ["TID251"]
|
||||
"libs/test_schedule_utils_enhanced.py" = ["S110"]
|
||||
"libs/test_sendgrid_client.py" = ["ARG001", "TID251"]
|
||||
"libs/test_smtp_client.py" = ["TID251"]
|
||||
"models/test_dataset_models.py" = ["ARG005"]
|
||||
"models/test_plugin_entities.py" = ["TID251"]
|
||||
"models/test_snippet.py" = ["ARG001"]
|
||||
"oss/__mock/aliyun_oss.py" = ["ARG002"]
|
||||
"oss/__mock/baidu_obs.py" = ["ARG002"]
|
||||
"oss/__mock/base.py" = ["ARG002"]
|
||||
"oss/__mock/tencent_cos.py" = ["ARG002"]
|
||||
"oss/__mock/volcengine_tos.py" = ["ARG002"]
|
||||
"oss/aliyun_oss/aliyun_oss/test_aliyun_oss.py" = ["ARG002"]
|
||||
"oss/baidu_obs/test_baidu_obs.py" = ["ARG002"]
|
||||
"oss/opendal/test_opendal.py" = ["ARG002"]
|
||||
"oss/tencent_cos/test_tencent_cos.py" = ["ARG002"]
|
||||
"oss/volcengine_tos/test_volcengine_tos.py" = ["ARG002"]
|
||||
"services/agent/test_agent_observability_service.py" = ["ARG002", "ARG005"]
|
||||
"services/agent/test_agent_services.py" = ["ARG001", "ARG002", "ARG003", "ARG005"]
|
||||
"services/agent/test_composer_candidates.py" = ["ARG005"]
|
||||
"services/agent/test_prompt_mentions.py" = ["ARG005"]
|
||||
"services/agent/test_skill_tool_inference_service.py" = ["ARG001", "ARG005"]
|
||||
"services/auth/test_jina_auth_standalone_module.py" = ["TID251"]
|
||||
"services/controller_api.py" = ["ARG002"]
|
||||
"services/data_migration/test_import_service.py" = ["ARG002", "ARG005"]
|
||||
"services/dataset_service_test_helpers.py" = ["TID251"]
|
||||
"services/enterprise/test_account_deletion_sync.py" = ["ARG001"]
|
||||
"services/enterprise/test_rbac_service.py" = ["ARG002"]
|
||||
"services/enterprise/test_traceparent_propagation.py" = ["ARG002"]
|
||||
"services/hit_service.py" = ["TID251"]
|
||||
"services/plugin/test_plugin_parameter_service.py" = ["ARG002"]
|
||||
"services/rag_pipeline/pipeline_template/test_built_in_retrieval.py" = ["ARG001"]
|
||||
"services/rag_pipeline/test_rag_pipeline_dsl_service.py" = ["ARG001", "ARG005", "T201", "TID251"]
|
||||
"services/rag_pipeline/test_rag_pipeline_service.py" = ["ARG001", "ARG005"]
|
||||
"services/rag_pipeline/test_rag_pipeline_task_proxy.py" = ["ARG001", "ARG005"]
|
||||
"services/rag_pipeline/test_rag_pipeline_transform_service.py" = ["ARG001"]
|
||||
"services/recommend_app/test_remote_retrieval.py" = ["ARG002"]
|
||||
"services/retention/workflow_run/test_archive_download_preparation.py" = ["ARG002"]
|
||||
"services/retention/workflow_run/test_archive_log_service.py" = ["ARG001", "ARG002"]
|
||||
"services/retention/workflow_run/test_bundle_archive_maintenance.py" = ["TID251"]
|
||||
"services/retention/workflow_run/test_restore_archived_workflow_run.py" = ["ARG002"]
|
||||
"services/test_account_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_annotation_service.py" = ["ANN401", "TID251"]
|
||||
"services/test_api_token_service.py" = ["ARG002"]
|
||||
"services/test_app_generate_service.py" = ["ARG001", "ARG002", "ARG004"]
|
||||
"services/test_app_generate_service_streaming_integration.py" = ["ARG002", "TID251"]
|
||||
"services/test_archive_workflow_run_logs.py" = ["ARG002"]
|
||||
"services/test_audio_service.py" = ["ARG002", "TID251"]
|
||||
"services/test_batch_indexing_base.py" = ["ANN401", "TID251"]
|
||||
"services/test_billing_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_clear_free_plan_expired_workflow_run_logs.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"services/test_clear_free_plan_tenant_expired_logs.py" = ["ARG002", "ARG003"]
|
||||
"services/test_dataset_service_document.py" = ["ARG002"]
|
||||
"services/test_dataset_service_lock_not_owned.py" = ["ARG001", "ARG005"]
|
||||
"services/test_dataset_service_segment.py" = ["ARG002"]
|
||||
"services/test_datasource_provider_service.py" = ["ARG002"]
|
||||
"services/test_external_dataset_service.py" = ["ARG002", "TID251"]
|
||||
"services/test_feature_service_human_input_email_delivery.py" = ["ARG005"]
|
||||
"services/test_feedback_service.py" = ["ARG002"]
|
||||
"services/test_human_input_delivery_test_service.py" = ["ARG005"]
|
||||
"services/test_knowledge_service.py" = ["TID251"]
|
||||
"services/test_message_service.py" = ["ARG002"]
|
||||
"services/test_messages_clean_service.py" = ["TID251"]
|
||||
"services/test_model_load_balancing_service.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"services/test_model_provider_service.py" = ["ANN401", "TID251"]
|
||||
"services/test_model_provider_service_sanitization.py" = ["ARG002", "ARG005"]
|
||||
"services/test_oauth_server_service.py" = ["ARG002"]
|
||||
"services/test_operation_service.py" = ["TID251"]
|
||||
"services/test_rag_pipeline_task_proxy.py" = ["ARG002"]
|
||||
"services/test_recommended_app_service.py" = ["ARG001"]
|
||||
"services/test_schedule_service.py" = ["ANN401", "TID251"]
|
||||
"services/test_snippet_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_summary_index_service.py" = ["ARG001"]
|
||||
"services/test_telemetry_service.py" = ["ARG001", "ARG005"]
|
||||
"services/test_variable_truncator.py" = ["ARG002", "TID251"]
|
||||
"services/test_variable_truncator_additional.py" = ["ANN401", "TID251"]
|
||||
"services/test_vector_service.py" = ["ARG001", "TID251"]
|
||||
"services/test_webhook_service_additional.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"services/test_website_service.py" = ["TID251"]
|
||||
"services/test_workflow_comment_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_workflow_run_service.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"services/test_workflow_service.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"services/tools/test_builtin_tools_manage_service.py" = ["ARG001", "ARG002"]
|
||||
"services/tools/test_tools_manage_service.py" = ["ARG002"]
|
||||
"services/workflow/test_inspector_events.py" = ["ANN401", "TID251"]
|
||||
"services/workflow/test_node_output_inspector_service.py" = ["TID251"]
|
||||
"services/workflow/test_workflow_converter_additional.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"services/workflow/test_workflow_event_snapshot_service.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"services/workflow/test_workflow_event_snapshot_service_additional.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"tasks/test_agent_backend_session_cleanup_task.py" = ["ARG005"]
|
||||
"tasks/test_clean_dataset_task.py" = ["ARG002"]
|
||||
"tasks/test_clean_document_task.py" = ["ARG002"]
|
||||
"tasks/test_dataset_indexing_task.py" = ["ARG001", "ARG002"]
|
||||
"tasks/test_document_indexing_sync_task.py" = ["ARG002"]
|
||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_human_input_timeout_tasks.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"tasks/test_initialize_created_app_rbac_access_task.py" = ["ARG005"]
|
||||
"tasks/test_mail_send_task.py" = ["ARG002"]
|
||||
"tasks/test_ops_trace_task.py" = ["ARG004"]
|
||||
"tasks/test_process_tenant_plugin_autoupgrade_check_task.py" = ["ARG001"]
|
||||
"tasks/test_remove_app_and_related_data_task.py" = ["ARG002"]
|
||||
"tasks/test_trigger_processing_tasks.py" = ["ARG002"]
|
||||
"tasks/test_workflow_execute_task.py" = ["ARG005"]
|
||||
"test_app_factory.py" = ["ARG001"]
|
||||
"test_pytest_dify.py" = ["ARG001"]
|
||||
"tools/test_mcp_tool.py" = ["TID251"]
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."typing.Any"]
|
||||
msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead."
|
||||
@@ -85,6 +85,42 @@ def main_branch_rev(repo: Path) -> str:
|
||||
return git(repo, "rev-parse", "main")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_line", "rule_id"),
|
||||
[
|
||||
(
|
||||
"value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy",
|
||||
"no-new-getattr",
|
||||
),
|
||||
(
|
||||
"session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback",
|
||||
"no-new-controller-sqlalchemy",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_has_reasoned_guard_ignore_accepts_custom_rules(source_line: str, rule_id: str) -> None:
|
||||
module = load_guard_module()
|
||||
|
||||
assert module.has_reasoned_guard_ignore(source_line, rule_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_line", "rule_id"),
|
||||
[
|
||||
("value = getattr(module, name) # noqa: no-new-getattr legacy marker", "no-new-getattr"),
|
||||
("value = getattr(module, name) # guard-ignore: no-new-getattr", "no-new-getattr"),
|
||||
(
|
||||
"value = getattr(module, name) # guard-ignore: another-rule -- wrong rule",
|
||||
"no-new-getattr",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_has_reasoned_guard_ignore_rejects_invalid_markers(source_line: str, rule_id: str) -> None:
|
||||
module = load_guard_module()
|
||||
|
||||
assert not module.has_reasoned_guard_ignore(source_line, rule_id)
|
||||
|
||||
|
||||
def test_resolve_ast_grep_command_prefers_ast_grep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
module = load_guard_module()
|
||||
monkeypatch.setattr(
|
||||
@@ -776,7 +812,7 @@ def test_modified_hunk_with_increased_getattr_count_fails(tmp_path: Path) -> Non
|
||||
assert "net-new getattr" in result.stderr
|
||||
|
||||
|
||||
def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None:
|
||||
def test_inline_guard_ignore_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None:
|
||||
init_repo(tmp_path)
|
||||
write_repo_file(
|
||||
tmp_path,
|
||||
@@ -795,20 +831,20 @@ def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_p
|
||||
"pkg/existing.py",
|
||||
"""
|
||||
def read_value(obj):
|
||||
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr needed for plugin-defined attributes
|
||||
return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr -- plugin-defined attributes
|
||||
""",
|
||||
)
|
||||
commit_all(tmp_path, "add suppressed getattr")
|
||||
|
||||
result = run_script(tmp_path, "--base-rev", base_rev)
|
||||
|
||||
assert "no-new-getattr needed for plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text(
|
||||
assert "guard-ignore: no-new-getattr -- plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert result.returncode == 0, stderr_lines(result)
|
||||
|
||||
|
||||
def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None:
|
||||
def test_inline_guard_ignore_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None:
|
||||
init_repo(tmp_path)
|
||||
write_repo_file(
|
||||
tmp_path,
|
||||
@@ -827,10 +863,10 @@ def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path)
|
||||
"pkg/existing.py",
|
||||
"""
|
||||
def read_value(obj):
|
||||
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr
|
||||
return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr
|
||||
""",
|
||||
)
|
||||
commit_all(tmp_path, "add bare noqa getattr")
|
||||
commit_all(tmp_path, "add bare guard ignore getattr")
|
||||
|
||||
result = run_script(tmp_path, "--base-rev", base_rev)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -15,9 +16,12 @@ import pytest
|
||||
import sqlalchemy as sa
|
||||
from click.testing import CliRunner
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models import Dataset, DatasetPermission, DatasetPermissionEnum
|
||||
from models.account import Tenant
|
||||
from models.base import TypeBase
|
||||
from models.enums import CredentialSourceType
|
||||
from models.provider import ProviderModel
|
||||
from tests.helpers.legacy_model_type_migration import (
|
||||
@@ -59,6 +63,40 @@ def command_module():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rbac_session(sqlite_engine: sa.Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
|
||||
"""Bind RBAC command reads to persisted SQLite dataset rows."""
|
||||
|
||||
TypeBase.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[Dataset.__table__, DatasetPermission.__table__],
|
||||
)
|
||||
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr("commands.rbac.session_factory.create_session", factory)
|
||||
with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def _persist_dataset(
|
||||
session: Session,
|
||||
*,
|
||||
dataset_id: str = "dataset-1",
|
||||
tenant_id: str = "tenant-1",
|
||||
permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME,
|
||||
created_by: str = "creator-account-1",
|
||||
) -> Dataset:
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
tenant_id=tenant_id,
|
||||
name=f"Dataset {dataset_id}",
|
||||
permission=permission,
|
||||
created_by=created_by,
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
return dataset
|
||||
|
||||
|
||||
def _parse_json_lines(output: io.StringIO) -> list[dict[str, object]]:
|
||||
return [json.loads(line) for line in output.getvalue().splitlines() if line.strip()]
|
||||
|
||||
@@ -363,56 +401,35 @@ def test_dataset_permission_rbac_migration_maps_legacy_permissions_to_enum_scope
|
||||
|
||||
def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator(
|
||||
command_module,
|
||||
rbac_session: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
rbac_module = importlib.import_module("commands.rbac")
|
||||
dataset_row = SimpleNamespace(
|
||||
id="dataset-1",
|
||||
tenant_id="tenant-1",
|
||||
permission="only_me",
|
||||
created_by="creator-account-1",
|
||||
)
|
||||
execute_results = [[dataset_row], [], []]
|
||||
_persist_dataset(rbac_session)
|
||||
calls: list[dict[str, object]] = []
|
||||
session_closed = False
|
||||
|
||||
class FakeExecuteResult:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._rows
|
||||
|
||||
class FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
nonlocal session_closed
|
||||
session_closed = True
|
||||
pass
|
||||
|
||||
def execute(self, stmt):
|
||||
return FakeExecuteResult(execute_results.pop(0))
|
||||
|
||||
class FakeSessionFactory:
|
||||
@staticmethod
|
||||
def create_session() -> FakeSession:
|
||||
return FakeSession()
|
||||
read_transaction_ended = False
|
||||
|
||||
def fake_replace_whitelist(**kwargs):
|
||||
assert session_closed is True
|
||||
assert read_transaction_ended is True
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
|
||||
monkeypatch.setattr(rbac_module.RBACService.DatasetAccess, "replace_whitelist", fake_replace_whitelist)
|
||||
def _record_transaction_end(session: Session, transaction: object) -> None:
|
||||
nonlocal read_transaction_ended
|
||||
del transaction
|
||||
if session.get_bind() is rbac_session.get_bind():
|
||||
read_transaction_ended = True
|
||||
|
||||
command_module.migrate_dataset_permissions_to_rbac.callback(
|
||||
tenant_id=None,
|
||||
dataset_id=None,
|
||||
batch_size=500,
|
||||
dry_run=False,
|
||||
)
|
||||
sa.event.listen(Session, "after_transaction_end", _record_transaction_end)
|
||||
monkeypatch.setattr(rbac_module.RBACService.DatasetAccess, "replace_whitelist", fake_replace_whitelist)
|
||||
try:
|
||||
command_module.migrate_dataset_permissions_to_rbac.callback(
|
||||
tenant_id=None,
|
||||
dataset_id=None,
|
||||
batch_size=500,
|
||||
dry_run=False,
|
||||
)
|
||||
finally:
|
||||
sa.event.remove(Session, "after_transaction_end", _record_transaction_end)
|
||||
|
||||
assert calls[0]["tenant_id"] == "tenant-1"
|
||||
assert calls[0]["account_id"] == "creator-account-1"
|
||||
@@ -422,41 +439,19 @@ def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator(
|
||||
|
||||
def test_dataset_permission_rbac_migration_dry_run_outputs_structured_proposed_changes(
|
||||
command_module,
|
||||
rbac_session: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
rbac_module = importlib.import_module("commands.rbac")
|
||||
dataset_row = SimpleNamespace(
|
||||
id="dataset-1",
|
||||
tenant_id="tenant-1",
|
||||
permission="partial_members",
|
||||
created_by="creator-account-1",
|
||||
dataset = _persist_dataset(rbac_session, permission=DatasetPermissionEnum.PARTIAL_TEAM)
|
||||
rbac_session.add(
|
||||
DatasetPermission(
|
||||
dataset_id=dataset.id,
|
||||
account_id="member-account-1",
|
||||
tenant_id=dataset.tenant_id,
|
||||
)
|
||||
)
|
||||
permission_row = SimpleNamespace(dataset_id="dataset-1", account_id="member-account-1")
|
||||
execute_results = [[dataset_row], [permission_row], []]
|
||||
|
||||
class FakeExecuteResult:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._rows
|
||||
|
||||
class FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
pass
|
||||
|
||||
def execute(self, stmt):
|
||||
return FakeExecuteResult(execute_results.pop(0))
|
||||
|
||||
class FakeSessionFactory:
|
||||
@staticmethod
|
||||
def create_session() -> FakeSession:
|
||||
return FakeSession()
|
||||
|
||||
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
|
||||
rbac_session.commit()
|
||||
monkeypatch.setattr(
|
||||
rbac_module.RBACService.DatasetAccess,
|
||||
"replace_whitelist",
|
||||
@@ -1306,50 +1301,36 @@ def test_provider_models_processing_uses_same_plan_locking_and_transaction_entry
|
||||
begin_calls: list[str] = []
|
||||
configure_calls: list[str] = []
|
||||
|
||||
class _FakeBeginContext:
|
||||
def __init__(self, phase: str) -> None:
|
||||
self._phase = phase
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
begin_calls.append(current_phase["name"])
|
||||
|
||||
def __enter__(self) -> None:
|
||||
begin_calls.append(self._phase)
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, phase: str) -> None:
|
||||
self._phase = phase
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def begin(self) -> _FakeBeginContext:
|
||||
return _FakeBeginContext(self._phase)
|
||||
|
||||
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
|
||||
return _FakeSession(current_phase["name"])
|
||||
|
||||
def _fake_build_plan(self, session, candidate, *, lock_rows: bool):
|
||||
def _fake_build_plan(self, session: Session, candidate, *, lock_rows: bool):
|
||||
assert session.get_bind() is sqlite_engine
|
||||
lock_rows_seen.append((current_phase["name"], lock_rows))
|
||||
return SimpleNamespace(group_row_ids=[str(candidate.row.id)], winner=None, loser_rows=[])
|
||||
return migration_module._ProviderModelGroupPlan(
|
||||
group_row_ids=[str(candidate.row.id)],
|
||||
winner=None,
|
||||
loser_rows=[],
|
||||
)
|
||||
|
||||
def _fake_emit_plan(self, plan, *, session, tx_id: str, business_key: dict[str, object]) -> None:
|
||||
return None
|
||||
|
||||
def _fake_configure(self, session) -> None:
|
||||
def _fake_configure(self, session: Session) -> None:
|
||||
assert session.get_bind() is sqlite_engine
|
||||
configure_calls.append(current_phase["name"])
|
||||
|
||||
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
|
||||
monkeypatch.setattr(migration_module.Migration, "_build_provider_model_group_plan", _fake_build_plan)
|
||||
monkeypatch.setattr(migration_module.Migration, "_emit_provider_model_group_plan", _fake_emit_plan)
|
||||
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure)
|
||||
|
||||
dry_migration._process_provider_model_group(candidate, business_key)
|
||||
current_phase["name"] = "apply"
|
||||
apply_migration._process_provider_model_group(candidate, business_key)
|
||||
sa.event.listen(Session, "after_transaction_create", _record_begin)
|
||||
try:
|
||||
dry_migration._process_provider_model_group(candidate, business_key)
|
||||
current_phase["name"] = "apply"
|
||||
apply_migration._process_provider_model_group(candidate, business_key)
|
||||
finally:
|
||||
sa.event.remove(Session, "after_transaction_create", _record_begin)
|
||||
|
||||
assert [phase for phase, _ in lock_rows_seen] == ["dry", "apply"]
|
||||
assert lock_rows_seen[0][1] == lock_rows_seen[1][1]
|
||||
@@ -1392,6 +1373,22 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
sqlite_engine: sa.Engine,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
create_minimal_legacy_model_type_schema(sqlite_engine)
|
||||
created_at = datetime(2025, 1, 1, 12, 0, 0)
|
||||
_insert_load_balancing_model_config(
|
||||
sqlite_engine,
|
||||
row_id="40000000-0000-0000-0000-000000000001",
|
||||
tenant_id="tenant-1",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
model_type="text-generation",
|
||||
name="credential",
|
||||
encrypted_config="{}",
|
||||
credential_id="50000000-0000-0000-0000-000000000001",
|
||||
enabled=True,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
output = io.StringIO()
|
||||
migration = migration_module.Migration(
|
||||
tenant_id="tenant-1",
|
||||
@@ -1401,37 +1398,18 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
model_types=(ModelType.LLM,),
|
||||
orm_models=(migration_module.LoadBalancingModelConfig,),
|
||||
)
|
||||
candidate = migration_module._RowWithRawModelType(
|
||||
row=SimpleNamespace(id="lb-row-1"),
|
||||
raw_model_type="text-generation",
|
||||
canonical_model_type=ModelType.LLM,
|
||||
)
|
||||
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
|
||||
lock_timeout_exc = OperationalError("SELECT 1", {}, SimpleNamespace(pgcode="55P03"))
|
||||
transaction_begins = 0
|
||||
|
||||
class _FakeBeginContext:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def begin(self) -> _FakeBeginContext:
|
||||
return _FakeBeginContext()
|
||||
|
||||
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
|
||||
return _FakeSession()
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
nonlocal transaction_begins
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
transaction_begins += 1
|
||||
|
||||
def _fake_reload(self, session, original_candidate, *, lock_rows: bool):
|
||||
raise lock_timeout_exc
|
||||
|
||||
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
|
||||
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", lambda self, session: None)
|
||||
monkeypatch.setattr(
|
||||
migration_module.Migration,
|
||||
@@ -1439,17 +1417,22 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
_fake_reload,
|
||||
)
|
||||
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
sa.event.listen(Session, "after_transaction_create", _record_begin)
|
||||
try:
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
finally:
|
||||
sa.event.remove(Session, "after_transaction_create", _record_begin)
|
||||
|
||||
lines = _parse_json_lines(output)
|
||||
assert len(lines) == 1
|
||||
assert lines[0]["event"] == "lock_timeout_skipped"
|
||||
attrs = cast(dict[str, object], lines[0]["attrs"])
|
||||
assert attrs["table_name"] == "load_balancing_model_configs"
|
||||
assert attrs["id"] == "lb-row-1"
|
||||
assert attrs["id"] == str(candidate.row.id)
|
||||
assert attrs["error"] == str(lock_timeout_exc)
|
||||
assert isinstance(attrs["stacktrace"], str)
|
||||
assert "OperationalError" in attrs["stacktrace"]
|
||||
assert transaction_begins == 1
|
||||
|
||||
|
||||
def test_process_load_balancing_model_config_row_logs_update_after_sql_execution(
|
||||
@@ -1457,6 +1440,23 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
sqlite_engine: sa.Engine,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
create_minimal_legacy_model_type_schema(sqlite_engine)
|
||||
created_at = datetime(2025, 1, 1, 12, 0, 0)
|
||||
row_id = "40000000-0000-0000-0000-000000000002"
|
||||
_insert_load_balancing_model_config(
|
||||
sqlite_engine,
|
||||
row_id=row_id,
|
||||
tenant_id="tenant-1",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
model_type="text-generation",
|
||||
name="credential",
|
||||
encrypted_config="{}",
|
||||
credential_id="50000000-0000-0000-0000-000000000002",
|
||||
enabled=True,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
migration = migration_module.Migration(
|
||||
tenant_id="tenant-1",
|
||||
engine=sqlite_engine,
|
||||
@@ -1465,42 +1465,33 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
model_types=(ModelType.LLM,),
|
||||
orm_models=(migration_module.LoadBalancingModelConfig,),
|
||||
)
|
||||
candidate = migration_module._RowWithRawModelType(
|
||||
row=SimpleNamespace(id="lb-row-1"),
|
||||
raw_model_type="text-generation",
|
||||
canonical_model_type=ModelType.LLM,
|
||||
)
|
||||
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
|
||||
action_log: list[str] = []
|
||||
|
||||
class _FakeBeginContext:
|
||||
def __enter__(self) -> None:
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
action_log.append("begin")
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def begin(self) -> _FakeBeginContext:
|
||||
return _FakeBeginContext()
|
||||
|
||||
def execute(self, stmt) -> None:
|
||||
def _record_sql(
|
||||
connection: sa.Connection,
|
||||
cursor: object,
|
||||
statement: str,
|
||||
parameters: object,
|
||||
context: object,
|
||||
executemany: bool,
|
||||
) -> None:
|
||||
del connection, cursor, parameters, context, executemany
|
||||
if statement.lstrip().upper().startswith("UPDATE"):
|
||||
action_log.append("sql_execute")
|
||||
|
||||
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
|
||||
return _FakeSession()
|
||||
|
||||
def _fake_configure(self, session) -> None:
|
||||
action_log.append("configure_lock_timeout")
|
||||
|
||||
def _fake_reload(self, session, original_candidate, *, lock_rows: bool):
|
||||
original_reload = migration_module.Migration._reload_load_balancing_model_config_candidate
|
||||
|
||||
def _record_reload(self, session: Session, original_candidate, *, lock_rows: bool):
|
||||
action_log.append(f"reload_candidate:{lock_rows}")
|
||||
return candidate
|
||||
return original_reload(self, session, original_candidate, lock_rows=lock_rows)
|
||||
|
||||
def _fake_log_row_updated(self, *args, **kwargs) -> None:
|
||||
action_log.append("log_row_updated")
|
||||
@@ -1508,12 +1499,11 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
def _fake_cache_cleanup(self, *, row_id: str, tx_id: str) -> None:
|
||||
action_log.append("cache_cleanup")
|
||||
|
||||
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
|
||||
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure)
|
||||
monkeypatch.setattr(
|
||||
migration_module.Migration,
|
||||
"_reload_load_balancing_model_config_candidate",
|
||||
_fake_reload,
|
||||
_record_reload,
|
||||
)
|
||||
monkeypatch.setattr(migration_module.Migration, "_log_row_updated", _fake_log_row_updated)
|
||||
monkeypatch.setattr(
|
||||
@@ -1522,7 +1512,13 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
_fake_cache_cleanup,
|
||||
)
|
||||
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
sa.event.listen(Session, "after_transaction_create", _record_begin)
|
||||
sa.event.listen(sqlite_engine, "before_cursor_execute", _record_sql)
|
||||
try:
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
finally:
|
||||
sa.event.remove(sqlite_engine, "before_cursor_execute", _record_sql)
|
||||
sa.event.remove(Session, "after_transaction_create", _record_begin)
|
||||
|
||||
assert action_log == [
|
||||
"begin",
|
||||
@@ -1532,6 +1528,10 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
"log_row_updated",
|
||||
"cache_cleanup",
|
||||
]
|
||||
with Session(sqlite_engine) as session:
|
||||
persisted = session.get(migration_module.LoadBalancingModelConfig, row_id)
|
||||
assert persisted is not None
|
||||
assert persisted.model_type == ModelType.LLM
|
||||
|
||||
|
||||
def test_load_balancing_model_config_cache_delete_failure_logs_stacktrace(
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
from typing import Literal, cast
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from uuid import uuid4
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets import data_source as module
|
||||
from controllers.console.datasets.data_source import DataSourceApi, DataSourceNotionListApi
|
||||
from models import Account, DataSourceOauthBinding
|
||||
from models.engine import db
|
||||
|
||||
ControllerMethod = Callable[..., tuple[dict[str, object], int]]
|
||||
|
||||
@@ -22,10 +26,15 @@ def unwrap(func: object) -> ControllerMethod:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app() -> Flask:
|
||||
def flask_app() -> Iterator[Flask]:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
DataSourceOauthBinding.__table__.create(db.engine)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -35,9 +44,13 @@ def current_user() -> Account:
|
||||
return account
|
||||
|
||||
|
||||
def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) -> None:
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
BINDING_ID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
|
||||
def _add_binding(session: Session, *, disabled: bool) -> DataSourceOauthBinding:
|
||||
binding = DataSourceOauthBinding(
|
||||
tenant_id="tenant-1",
|
||||
tenant_id=TENANT_ID,
|
||||
access_token="token",
|
||||
provider="notion",
|
||||
source_info={
|
||||
@@ -55,24 +68,31 @@ def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) ->
|
||||
}
|
||||
],
|
||||
},
|
||||
disabled=disabled,
|
||||
)
|
||||
binding.id = "binding-1"
|
||||
binding.id = BINDING_ID
|
||||
binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC)
|
||||
binding.disabled = False
|
||||
session.add(binding)
|
||||
session.commit()
|
||||
return binding
|
||||
|
||||
with (
|
||||
flask_app.test_request_context("/"),
|
||||
patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [binding])),
|
||||
):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1")
|
||||
|
||||
def test_get_data_source_integrates_serializes_orm_binding(
|
||||
flask_app: Flask,
|
||||
) -> None:
|
||||
binding = _add_binding(db.session, disabled=False)
|
||||
expected_created_at = int(binding.created_at.timestamp())
|
||||
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID)
|
||||
|
||||
assert status == 200
|
||||
assert response == {
|
||||
"data": [
|
||||
{
|
||||
"id": "binding-1",
|
||||
"id": BINDING_ID,
|
||||
"provider": "notion",
|
||||
"created_at": 1779670923,
|
||||
"created_at": expected_created_at,
|
||||
"is_bound": True,
|
||||
"disabled": False,
|
||||
"source_info": {
|
||||
@@ -96,34 +116,75 @@ def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) ->
|
||||
}
|
||||
|
||||
|
||||
def test_get_data_source_integrates_preserves_empty_list_when_no_binding(flask_app: Flask) -> None:
|
||||
with (
|
||||
flask_app.test_request_context("/"),
|
||||
patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [])),
|
||||
):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1")
|
||||
def test_get_data_source_integrates_preserves_empty_list_when_no_binding(
|
||||
flask_app: Flask,
|
||||
) -> None:
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID)
|
||||
|
||||
assert status == 200
|
||||
assert response == {"data": []}
|
||||
|
||||
|
||||
def test_patch_data_source_binding_uses_injected_session(flask_app: Flask) -> None:
|
||||
binding = MagicMock(disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
@pytest.mark.parametrize(
|
||||
("disabled", "action", "expected_disabled"),
|
||||
[(True, "enable", False), (False, "disable", True)],
|
||||
)
|
||||
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
|
||||
def test_patch_data_source_binding_updates_state(
|
||||
flask_app: Flask,
|
||||
sqlite_session: Session,
|
||||
disabled: bool,
|
||||
action: Literal["enable", "disable"],
|
||||
expected_disabled: bool,
|
||||
) -> None:
|
||||
_add_binding(sqlite_session, disabled=disabled)
|
||||
sqlite_session.expunge_all()
|
||||
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().patch)(DataSourceApi(), session, "tenant-1", uuid4(), "enable")
|
||||
response, status = unwrap(DataSourceApi().patch)(
|
||||
DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action
|
||||
)
|
||||
|
||||
sqlite_session.flush()
|
||||
sqlite_session.expire_all()
|
||||
binding = sqlite_session.scalar(select(DataSourceOauthBinding).where(DataSourceOauthBinding.id == BINDING_ID))
|
||||
assert status == 200
|
||||
assert response == {"result": "success"}
|
||||
assert binding.disabled is False
|
||||
session.scalar.assert_called_once()
|
||||
session.add.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
assert binding is not None
|
||||
assert binding.disabled is expected_disabled
|
||||
|
||||
|
||||
def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask, current_user: Account) -> None:
|
||||
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
|
||||
def test_patch_data_source_binding_rejects_unknown_binding(
|
||||
flask_app: Flask,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
with flask_app.test_request_context("/"), pytest.raises(NotFound, match="Data source binding not found"):
|
||||
unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), "enable")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("disabled", "action"), [(False, "enable"), (True, "disable")])
|
||||
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
|
||||
def test_patch_data_source_binding_rejects_current_state(
|
||||
flask_app: Flask,
|
||||
sqlite_session: Session,
|
||||
disabled: bool,
|
||||
action: Literal["enable", "disable"],
|
||||
) -> None:
|
||||
_add_binding(sqlite_session, disabled=disabled)
|
||||
sqlite_session.expunge_all()
|
||||
|
||||
with flask_app.test_request_context("/"), pytest.raises(ValueError):
|
||||
unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_notion_pre_import_pages_serializes_frontend_list_shape(
|
||||
flask_app: Flask,
|
||||
current_user: Account,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
page = MagicMock(
|
||||
page_id="page-1",
|
||||
page_name="Page",
|
||||
@@ -145,8 +206,6 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask
|
||||
get_online_document_pages=MagicMock(return_value=iter([online_document_message])),
|
||||
datasource_provider_type=MagicMock(return_value="online_document"),
|
||||
)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
flask_app.test_request_context("/?credential_id=credential-1"),
|
||||
patch.object(
|
||||
@@ -158,7 +217,7 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask
|
||||
patch("core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", return_value=runtime),
|
||||
):
|
||||
response, status = unwrap(DataSourceNotionListApi().get)(
|
||||
DataSourceNotionListApi(), session, "tenant-1", current_user
|
||||
DataSourceNotionListApi(), sqlite_session, "tenant-1", current_user
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
@@ -183,3 +242,38 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask
|
||||
}
|
||||
runtime.get_online_document_pages.assert_called_once()
|
||||
assert runtime.get_online_document_pages.call_args.kwargs["datasource_parameters"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_notion_pre_import_pages_rejects_missing_credential(
|
||||
flask_app: Flask,
|
||||
current_user: Account,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
with (
|
||||
flask_app.test_request_context("/?credential_id=credential-1"),
|
||||
patch.object(module.DatasourceProviderService, "get_datasource_credentials", return_value=None),
|
||||
pytest.raises(NotFound, match="Credential not found"),
|
||||
):
|
||||
unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_notion_pre_import_pages_rejects_non_notion_dataset(
|
||||
flask_app: Flask,
|
||||
current_user: Account,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
dataset = MagicMock(data_source_type="other_type")
|
||||
|
||||
with (
|
||||
flask_app.test_request_context("/?credential_id=credential-1&dataset_id=dataset-1"),
|
||||
patch.object(
|
||||
module.DatasourceProviderService,
|
||||
"get_datasource_credentials",
|
||||
return_value={"token": "token"},
|
||||
),
|
||||
patch.object(module.DatasetService, "get_dataset", return_value=dataset),
|
||||
pytest.raises(ValueError, match="Dataset is not notion type"),
|
||||
):
|
||||
unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Unit tests for controllers.console.datasets.data_source Notion endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets.data_source import (
|
||||
DataSourceNotionDatasetSyncApi,
|
||||
DataSourceNotionDocumentSyncApi,
|
||||
DataSourceNotionIndexingEstimateApi,
|
||||
DataSourceNotionPreviewApi,
|
||||
)
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from models import Account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
account = Account(name="Test User", email="[email protected]")
|
||||
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")
|
||||
@@ -1,64 +0,0 @@
|
||||
import inspect
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.inner_api.workspace.plugin_model_providers import (
|
||||
EnterprisePluginModelProvidersCacheInvalidate,
|
||||
InvalidatePluginModelProvidersCachePayload,
|
||||
)
|
||||
|
||||
|
||||
class TestInvalidatePluginModelProvidersCachePayload:
|
||||
def test_valid_payload(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate(
|
||||
{"tenant_ids": ["tenant-alpha", "tenant-beta"]}
|
||||
)
|
||||
assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"]
|
||||
|
||||
def test_missing_tenant_ids_defaults_to_empty(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate({})
|
||||
assert payload.tenant_ids == []
|
||||
|
||||
def test_unknown_field_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7})
|
||||
|
||||
|
||||
class TestEnterprisePluginModelProvidersCacheInvalidate:
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return EnterprisePluginModelProvidersCacheInvalidate()
|
||||
|
||||
def _post(self, api_instance, app: Flask, payload):
|
||||
unwrapped_post = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = payload
|
||||
return unwrapped_post(api_instance)
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [
|
||||
call("tenant-alpha"),
|
||||
call("tenant-beta"),
|
||||
]
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": []})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, None)
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
@@ -1,17 +1,31 @@
|
||||
"""
|
||||
Unit tests for Service API File Preview endpoint
|
||||
"""Unit tests for the Service API file-preview endpoint.
|
||||
|
||||
Ownership checks run against persisted message, file, app, and upload rows so the
|
||||
tests exercise the same SQLAlchemy statements and tenant boundary as production.
|
||||
Storage remains mocked because it is the external I/O boundary of the endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Protocol, cast
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.service_api.app.error import FileAccessDeniedError, FileNotFoundError
|
||||
from controllers.service_api.app.file_preview import FilePreviewApi
|
||||
from models.model import App, EndUser, Message, MessageFile, UploadFile
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from models.base import TypeBase
|
||||
from models.enums import ConversationFromSource, CreatorUserRole
|
||||
from models.model import App, AppMode, Message, MessageFile, UploadFile
|
||||
|
||||
|
||||
class _FilePreviewLogRecord(Protocol):
|
||||
@@ -20,367 +34,252 @@ class _FilePreviewLogRecord(Protocol):
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Database:
|
||||
"""Expose the real test session through the interface used by the controller."""
|
||||
|
||||
session: Session
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PreviewRecords:
|
||||
app: App
|
||||
message: Message
|
||||
message_file: MessageFile
|
||||
upload_file: UploadFile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine) -> Iterator[_Database]:
|
||||
"""Create only the tables required by file ownership validation."""
|
||||
|
||||
models = (App, Message, MessageFile, UploadFile)
|
||||
tables = [TypeBase.metadata.tables[model.__tablename__] for model in models]
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
yield _Database(session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_preview_api() -> FilePreviewApi:
|
||||
"""Create the resource instance under test."""
|
||||
|
||||
return FilePreviewApi()
|
||||
|
||||
|
||||
def _upload_file(*, tenant_id: str, file_id: str | None = None) -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id,
|
||||
storage_type=StorageType.LOCAL,
|
||||
key="storage/key/test_file.jpg",
|
||||
name="test_file.jpg",
|
||||
size=1024,
|
||||
extension="jpg",
|
||||
mime_type="image/jpeg",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid4()),
|
||||
created_at=datetime(2026, 1, 1),
|
||||
used=True,
|
||||
)
|
||||
if file_id is not None:
|
||||
upload_file.id = file_id
|
||||
return upload_file
|
||||
|
||||
|
||||
def _persist_preview_records(
|
||||
session: Session,
|
||||
*,
|
||||
app_id: str | None = None,
|
||||
app_tenant_id: str | None = None,
|
||||
upload_tenant_id: str | None = None,
|
||||
) -> _PreviewRecords:
|
||||
app_id = app_id or str(uuid4())
|
||||
app_tenant_id = app_tenant_id or str(uuid4())
|
||||
upload_file = _upload_file(tenant_id=upload_tenant_id or app_tenant_id)
|
||||
app = App(
|
||||
id=app_id,
|
||||
tenant_id=app_tenant_id,
|
||||
name="Preview app",
|
||||
description="",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=None,
|
||||
icon="",
|
||||
icon_background=None,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
)
|
||||
message = Message(
|
||||
id=str(uuid4()),
|
||||
app_id=app_id,
|
||||
conversation_id=str(uuid4()),
|
||||
_inputs={},
|
||||
query="preview",
|
||||
message={},
|
||||
message_unit_price=Decimal(0),
|
||||
answer="answer",
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
)
|
||||
message_file = MessageFile(
|
||||
message_id=message.id,
|
||||
type=FileType.IMAGE,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid4()),
|
||||
upload_file_id=upload_file.id,
|
||||
)
|
||||
session.add_all([app, message, message_file, upload_file])
|
||||
session.commit()
|
||||
return _PreviewRecords(app=app, message=message, message_file=message_file, upload_file=upload_file)
|
||||
|
||||
|
||||
class TestFilePreviewApi:
|
||||
"""Test suite for FilePreviewApi"""
|
||||
"""Exercise ownership validation and response construction."""
|
||||
|
||||
@pytest.fixture
|
||||
def file_preview_api(self):
|
||||
"""Create FilePreviewApi instance for testing"""
|
||||
return FilePreviewApi()
|
||||
def test_validate_file_ownership_success(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
records = _persist_preview_records(database.session)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Mock App model"""
|
||||
app = Mock(spec=App)
|
||||
app.id = str(uuid.uuid4())
|
||||
app.tenant_id = str(uuid.uuid4())
|
||||
return app
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
message_file, upload_file = file_preview_api._validate_file_ownership(
|
||||
records.upload_file.id, records.app.id
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_end_user(self):
|
||||
"""Mock EndUser model"""
|
||||
end_user = Mock(spec=EndUser)
|
||||
end_user.id = str(uuid.uuid4())
|
||||
return end_user
|
||||
assert message_file.id == records.message_file.id
|
||||
assert upload_file.id == records.upload_file.id
|
||||
assert upload_file.tenant_id == records.app.tenant_id
|
||||
|
||||
@pytest.fixture
|
||||
def mock_upload_file(self):
|
||||
"""Mock UploadFile model"""
|
||||
upload_file = Mock(spec=UploadFile)
|
||||
upload_file.id = str(uuid.uuid4())
|
||||
upload_file.name = "test_file.jpg"
|
||||
upload_file.extension = "jpg"
|
||||
upload_file.mime_type = "image/jpeg"
|
||||
upload_file.size = 1024
|
||||
upload_file.key = "storage/key/test_file.jpg"
|
||||
upload_file.tenant_id = str(uuid.uuid4())
|
||||
return upload_file
|
||||
def test_validate_file_ownership_file_not_found(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with pytest.raises(FileNotFoundError, match="File not found in message context"):
|
||||
file_preview_api._validate_file_ownership(str(uuid4()), str(uuid4()))
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message_file(self):
|
||||
"""Mock MessageFile model"""
|
||||
message_file = Mock(spec=MessageFile)
|
||||
message_file.id = str(uuid.uuid4())
|
||||
message_file.upload_file_id = str(uuid.uuid4())
|
||||
message_file.message_id = str(uuid.uuid4())
|
||||
return message_file
|
||||
def test_validate_file_ownership_access_denied(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
records = _persist_preview_records(database.session)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
"""Mock Message model"""
|
||||
message = Mock(spec=Message)
|
||||
message.id = str(uuid.uuid4())
|
||||
message.app_id = str(uuid.uuid4())
|
||||
return message
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with pytest.raises(FileAccessDeniedError, match="not owned by requesting app"):
|
||||
file_preview_api._validate_file_ownership(records.upload_file.id, str(uuid4()))
|
||||
|
||||
def test_validate_file_ownership_success(
|
||||
self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message
|
||||
):
|
||||
"""Test successful file ownership validation"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = mock_app.id
|
||||
def test_validate_file_ownership_upload_file_not_found(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
records = _persist_preview_records(database.session)
|
||||
database.session.delete(records.upload_file)
|
||||
database.session.commit()
|
||||
|
||||
# Set up the mocks
|
||||
mock_upload_file.tenant_id = mock_app.tenant_id
|
||||
mock_message.app_id = app_id
|
||||
mock_message_file.upload_file_id = file_id
|
||||
mock_message_file.message_id = mock_message.id
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with pytest.raises(FileNotFoundError, match="Upload file record not found"):
|
||||
file_preview_api._validate_file_ownership(records.upload_file.id, records.app.id)
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message queries
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query
|
||||
mock_message, # Message query
|
||||
]
|
||||
# Mock get() for UploadFile and App PK lookups
|
||||
mock_db.session.get.side_effect = [
|
||||
mock_upload_file, # UploadFile query
|
||||
mock_app, # App query for tenant validation
|
||||
]
|
||||
def test_validate_file_ownership_tenant_mismatch(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
records = _persist_preview_records(database.session, upload_tenant_id=str(uuid4()))
|
||||
|
||||
# Execute the method
|
||||
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
# Assertions
|
||||
assert result_message_file == mock_message_file
|
||||
assert result_upload_file == mock_upload_file
|
||||
|
||||
def test_validate_file_ownership_file_not_found(self, file_preview_api: FilePreviewApi):
|
||||
"""Test file ownership validation when MessageFile not found"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock MessageFile not found via scalar()
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
# Execute and assert exception
|
||||
with pytest.raises(FileNotFoundError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
assert "File not found in message context" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_access_denied(self, file_preview_api: FilePreviewApi, mock_message_file):
|
||||
"""Test file ownership validation when Message not owned by app"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock MessageFile found but Message not owned by app via scalar()
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query - found
|
||||
None, # Message query - not found (access denied)
|
||||
]
|
||||
|
||||
# Execute and assert exception
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
assert "not owned by requesting app" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_upload_file_not_found(
|
||||
self, file_preview_api: FilePreviewApi, mock_message_file, mock_message
|
||||
):
|
||||
"""Test file ownership validation when UploadFile not found"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query - found
|
||||
mock_message, # Message query - found
|
||||
]
|
||||
# Mock get() for UploadFile - not found
|
||||
mock_db.session.get.return_value = None
|
||||
|
||||
# Execute and assert exception
|
||||
with pytest.raises(FileNotFoundError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
assert "Upload file record not found" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_tenant_mismatch(
|
||||
self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message
|
||||
):
|
||||
"""Test file ownership validation with tenant mismatch"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = mock_app.id
|
||||
|
||||
# Set up tenant mismatch
|
||||
mock_upload_file.tenant_id = "different_tenant_id"
|
||||
mock_app.tenant_id = "app_tenant_id"
|
||||
mock_message.app_id = app_id
|
||||
mock_message_file.upload_file_id = file_id
|
||||
mock_message_file.message_id = mock_message.id
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message queries
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query
|
||||
mock_message, # Message query
|
||||
]
|
||||
# Mock get() for UploadFile and App PK lookups
|
||||
mock_db.session.get.side_effect = [
|
||||
mock_upload_file, # UploadFile query
|
||||
mock_app, # App query for tenant validation
|
||||
]
|
||||
|
||||
# Execute and assert exception
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
assert "tenant mismatch" in str(exc_info.value)
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with pytest.raises(FileAccessDeniedError, match="tenant mismatch"):
|
||||
file_preview_api._validate_file_ownership(records.upload_file.id, records.app.id)
|
||||
|
||||
def test_validate_file_ownership_invalid_input(self, file_preview_api: FilePreviewApi):
|
||||
"""Test file ownership validation with invalid input"""
|
||||
|
||||
# Test with empty file_id
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
with pytest.raises(FileAccessDeniedError, match="Invalid file or app identifier"):
|
||||
file_preview_api._validate_file_ownership("", "app_id")
|
||||
assert "Invalid file or app identifier" in str(exc_info.value)
|
||||
|
||||
# Test with empty app_id
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
with pytest.raises(FileAccessDeniedError, match="Invalid file or app identifier"):
|
||||
file_preview_api._validate_file_ownership("file_id", "")
|
||||
assert "Invalid file or app identifier" in str(exc_info.value)
|
||||
|
||||
def test_build_file_response_basic(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test basic file response building"""
|
||||
mock_generator = Mock()
|
||||
@pytest.mark.parametrize(
|
||||
("as_attachment", "mime_type", "name", "extension", "size"),
|
||||
[
|
||||
(False, "image/jpeg", "test_file.jpg", "jpg", 1024),
|
||||
(True, "image/jpeg", "test_file.jpg", "jpg", 1024),
|
||||
(False, "text/html", "unsafe.html", "html", 1024),
|
||||
(False, "video/mp4", "test_file.mp4", "mp4", 1024),
|
||||
(False, "image/jpeg", "test_file.jpg", "jpg", 0),
|
||||
],
|
||||
)
|
||||
def test_build_file_response(
|
||||
self,
|
||||
file_preview_api: FilePreviewApi,
|
||||
as_attachment: bool,
|
||||
mime_type: str,
|
||||
name: str,
|
||||
extension: str,
|
||||
size: int,
|
||||
):
|
||||
upload_file = _upload_file(tenant_id=str(uuid4()))
|
||||
upload_file.mime_type = mime_type
|
||||
upload_file.name = name
|
||||
upload_file.extension = extension
|
||||
upload_file.size = size
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
response = file_preview_api._build_file_response(Mock(), upload_file, as_attachment)
|
||||
|
||||
# Check response properties
|
||||
assert response.mimetype == mock_upload_file.mime_type
|
||||
assert response.direct_passthrough is True
|
||||
assert response.headers["Content-Length"] == str(mock_upload_file.size)
|
||||
assert "Cache-Control" in response.headers
|
||||
|
||||
def test_build_file_response_as_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test file response building with attachment flag"""
|
||||
mock_generator = Mock()
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, True)
|
||||
|
||||
# Check attachment-specific headers
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
assert mock_upload_file.name in response.headers["Content-Disposition"]
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
|
||||
def test_build_file_response_html_forces_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test HTML files are forced to download"""
|
||||
mock_generator = Mock()
|
||||
mock_upload_file.mime_type = "text/html"
|
||||
mock_upload_file.name = "unsafe.html"
|
||||
mock_upload_file.extension = "html"
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
def test_build_file_response_audio_video(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test file response building for audio/video files"""
|
||||
mock_generator = Mock()
|
||||
mock_upload_file.mime_type = "video/mp4"
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
|
||||
# Check Range support for media files
|
||||
assert response.headers["Accept-Ranges"] == "bytes"
|
||||
|
||||
def test_build_file_response_no_size(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test file response building when size is unknown"""
|
||||
mock_generator = Mock()
|
||||
mock_upload_file.size = 0 # Unknown size
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
|
||||
# Content-Length should not be set when size is unknown
|
||||
assert "Content-Length" not in response.headers
|
||||
assert ("Content-Length" in response.headers) is bool(size)
|
||||
if as_attachment or mime_type == "text/html":
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
else:
|
||||
assert response.mimetype == mime_type
|
||||
if mime_type == "text/html":
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
if mime_type.startswith("video/"):
|
||||
assert response.headers["Accept-Ranges"] == "bytes"
|
||||
|
||||
@patch("controllers.service_api.app.file_preview.storage")
|
||||
def test_get_method_integration(
|
||||
self,
|
||||
mock_storage,
|
||||
file_preview_api: FilePreviewApi,
|
||||
mock_app,
|
||||
mock_end_user,
|
||||
mock_upload_file,
|
||||
mock_message_file,
|
||||
mock_message,
|
||||
def test_components_use_validated_file(
|
||||
self, mock_storage: Mock, file_preview_api: FilePreviewApi, database: _Database
|
||||
):
|
||||
"""Test the full GET method integration (without decorator)"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = mock_app.id
|
||||
records = _persist_preview_records(database.session)
|
||||
generator = Mock()
|
||||
|
||||
# Set up mocks
|
||||
mock_upload_file.tenant_id = mock_app.tenant_id
|
||||
mock_message.app_id = app_id
|
||||
mock_message_file.upload_file_id = file_id
|
||||
mock_message_file.message_id = mock_message.id
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
message_file, upload_file = file_preview_api._validate_file_ownership(
|
||||
records.upload_file.id, records.app.id
|
||||
)
|
||||
response = file_preview_api._build_file_response(generator, upload_file, False)
|
||||
|
||||
mock_generator = Mock()
|
||||
mock_storage.load.return_value = mock_generator
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message queries
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query
|
||||
mock_message, # Message query
|
||||
]
|
||||
# Mock get() for UploadFile and App PK lookups
|
||||
mock_db.session.get.side_effect = [
|
||||
mock_upload_file, # UploadFile query
|
||||
mock_app, # App query for tenant validation
|
||||
]
|
||||
|
||||
# Test the core logic directly without Flask decorators
|
||||
# Validate file ownership
|
||||
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
assert result_message_file == mock_message_file
|
||||
assert result_upload_file == mock_upload_file
|
||||
|
||||
# Test file response building
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
assert response is not None
|
||||
|
||||
# Verify storage was called correctly
|
||||
mock_storage.load.assert_not_called() # Since we're testing components separately
|
||||
assert message_file.id == records.message_file.id
|
||||
assert response.mimetype == "image/jpeg"
|
||||
mock_storage.load.assert_not_called()
|
||||
|
||||
@patch("controllers.service_api.app.file_preview.storage")
|
||||
def test_storage_error_handling(
|
||||
self,
|
||||
mock_storage,
|
||||
file_preview_api: FilePreviewApi,
|
||||
mock_app,
|
||||
mock_upload_file,
|
||||
mock_message_file,
|
||||
mock_message,
|
||||
def test_storage_error_remains_external(
|
||||
self, mock_storage: Mock, file_preview_api: FilePreviewApi, database: _Database
|
||||
):
|
||||
"""Test storage error handling in the core logic"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = mock_app.id
|
||||
records = _persist_preview_records(database.session)
|
||||
mock_storage.load.side_effect = OSError("Storage error")
|
||||
|
||||
# Set up mocks
|
||||
mock_upload_file.tenant_id = mock_app.tenant_id
|
||||
mock_message.app_id = app_id
|
||||
mock_message_file.upload_file_id = file_id
|
||||
mock_message_file.message_id = mock_message.id
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
_, upload_file = file_preview_api._validate_file_ownership(records.upload_file.id, records.app.id)
|
||||
|
||||
# Mock storage error
|
||||
mock_storage.load.side_effect = Exception("Storage error")
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message queries
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query
|
||||
mock_message, # Message query
|
||||
]
|
||||
# Mock get() for UploadFile and App PK lookups
|
||||
mock_db.session.get.side_effect = [
|
||||
mock_upload_file, # UploadFile query
|
||||
mock_app, # App query for tenant validation
|
||||
]
|
||||
|
||||
# First validate file ownership works
|
||||
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
assert result_message_file == mock_message_file
|
||||
assert result_upload_file == mock_upload_file
|
||||
|
||||
# Test storage error handling
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
mock_storage.load(mock_upload_file.key, stream=True)
|
||||
|
||||
assert "Storage error" in str(exc_info.value)
|
||||
with pytest.raises(OSError, match="Storage error"):
|
||||
mock_storage.load(upload_file.key, stream=True)
|
||||
|
||||
def test_validate_file_ownership_unexpected_error_logging(
|
||||
self, file_preview_api: FilePreviewApi, caplog: pytest.LogCaptureFixture
|
||||
self,
|
||||
file_preview_api: FilePreviewApi,
|
||||
database: _Database,
|
||||
sqlite_engine: Engine,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""Test that unexpected errors are logged properly"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
file_id = str(uuid4())
|
||||
app_id = str(uuid4())
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock database scalar to raise unexpected exception
|
||||
mock_db.session.scalar.side_effect = Exception("Unexpected database error")
|
||||
def fail_statement(*_args: object) -> None:
|
||||
raise RuntimeError("Unexpected database error")
|
||||
|
||||
# Execute and assert exception
|
||||
with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"):
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
event.listen(sqlite_engine, "before_cursor_execute", fail_statement)
|
||||
try:
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"):
|
||||
with pytest.raises(FileAccessDeniedError, match="File access validation failed"):
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", fail_statement)
|
||||
|
||||
# Verify error message
|
||||
assert "File access validation failed" in str(exc_info.value)
|
||||
|
||||
# Verify logging was called with the structured context fields. The ``extra`` keys
|
||||
# are attached to the LogRecord as attributes, so they are not in ``caplog.text``.
|
||||
assert len(caplog.records) == 1
|
||||
log_record = caplog.records[0]
|
||||
assert log_record.getMessage() == "Unexpected error during file ownership validation"
|
||||
record = cast(_FilePreviewLogRecord, log_record)
|
||||
assert record.file_id == file_id
|
||||
assert record.app_id == app_id
|
||||
assert record.error == "Unexpected database error"
|
||||
assert len(caplog.records) == 1
|
||||
log_record = caplog.records[0]
|
||||
assert log_record.getMessage() == "Unexpected error during file ownership validation"
|
||||
record = cast(_FilePreviewLogRecord, log_record)
|
||||
assert record.file_id == file_id
|
||||
assert record.app_id == app_id
|
||||
assert record.error == "Unexpected database error"
|
||||
|
||||
@@ -15,12 +15,15 @@ Focus on:
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from controllers.service_api.app.error import NotChatAppError
|
||||
@@ -44,6 +47,14 @@ from services.errors.message import (
|
||||
from services.message_service import MessageService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orm_session(sqlite_engine: Engine) -> Iterator[Session]:
|
||||
"""Provide a real caller-owned session for MessageService interface tests."""
|
||||
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
|
||||
class TestMessageListQuery:
|
||||
"""Test suite for MessageListQuery Pydantic model."""
|
||||
|
||||
@@ -253,7 +264,7 @@ class TestMessageService:
|
||||
assert callable(MessageService.get_suggested_questions_after_answer)
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination):
|
||||
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination, orm_session: Session):
|
||||
"""Test pagination_by_first_id returns expected format."""
|
||||
mock_result = Mock()
|
||||
mock_result.data = []
|
||||
@@ -267,7 +278,7 @@ class TestMessageService:
|
||||
conversation_id=str(uuid.uuid4()),
|
||||
first_id=None,
|
||||
limit=20,
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
assert hasattr(result, "data")
|
||||
@@ -275,7 +286,7 @@ class TestMessageService:
|
||||
assert hasattr(result, "has_more")
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_raises_conversation_not_exists_error(self, mock_pagination):
|
||||
def test_pagination_raises_conversation_not_exists_error(self, mock_pagination, orm_session: Session):
|
||||
"""Test pagination raises ConversationNotExistsError."""
|
||||
import services.errors.conversation
|
||||
|
||||
@@ -288,11 +299,11 @@ class TestMessageService:
|
||||
conversation_id="invalid_id",
|
||||
first_id=None,
|
||||
limit=20,
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination):
|
||||
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination, orm_session: Session):
|
||||
"""Test pagination raises FirstMessageNotExistsError."""
|
||||
mock_pagination.side_effect = FirstMessageNotExistsError()
|
||||
|
||||
@@ -303,11 +314,11 @@ class TestMessageService:
|
||||
conversation_id=str(uuid.uuid4()),
|
||||
first_id="invalid_first_id",
|
||||
limit=20,
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "create_feedback")
|
||||
def test_create_feedback_with_rating_and_content(self, mock_create_feedback):
|
||||
def test_create_feedback_with_rating_and_content(self, mock_create_feedback, orm_session: Session):
|
||||
"""Test create_feedback with rating and content."""
|
||||
mock_create_feedback.return_value = None
|
||||
|
||||
@@ -317,13 +328,13 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
rating=FeedbackRating.LIKE,
|
||||
content="Great response!",
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
mock_create_feedback.assert_called_once()
|
||||
|
||||
@patch.object(MessageService, "create_feedback")
|
||||
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback):
|
||||
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback, orm_session: Session):
|
||||
"""Test create_feedback raises MessageNotExistsError."""
|
||||
mock_create_feedback.side_effect = MessageNotExistsError()
|
||||
|
||||
@@ -334,11 +345,11 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
rating=FeedbackRating.LIKE,
|
||||
content=None,
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "get_all_messages_feedbacks")
|
||||
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks):
|
||||
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks, orm_session: Session):
|
||||
"""Test get_all_messages_feedbacks returns list of feedbacks."""
|
||||
mock_feedbacks = [
|
||||
{"message_id": str(uuid.uuid4()), "rating": "like"},
|
||||
@@ -346,13 +357,15 @@ class TestMessageService:
|
||||
]
|
||||
mock_get_feedbacks.return_value = mock_feedbacks
|
||||
|
||||
result = MessageService.get_all_messages_feedbacks(app_model=Mock(spec=App), page=1, limit=20, session=Mock())
|
||||
result = MessageService.get_all_messages_feedbacks(
|
||||
app_model=Mock(spec=App), page=1, limit=20, session=orm_session
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["rating"] == "like"
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions):
|
||||
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions, orm_session: Session):
|
||||
"""Test get_suggested_questions_after_answer returns list of questions."""
|
||||
mock_questions = ["What about this aspect?", "Can you elaborate on that?", "How does this relate to...?"]
|
||||
mock_get_questions.return_value = mock_questions
|
||||
@@ -362,14 +375,14 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
assert len(result) == 3
|
||||
assert isinstance(result[0], str)
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions):
|
||||
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions, orm_session: Session):
|
||||
"""Test get_suggested_questions_after_answer raises SuggestedQuestionsAfterAnswerDisabledError."""
|
||||
mock_get_questions.side_effect = SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
@@ -379,11 +392,11 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions):
|
||||
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions, orm_session: Session):
|
||||
"""Test get_suggested_questions_after_answer raises MessageNotExistsError."""
|
||||
mock_get_questions.side_effect = MessageNotExistsError()
|
||||
|
||||
@@ -393,7 +406,7 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id="invalid_message_id",
|
||||
invoke_from=Mock(),
|
||||
session=Mock(),
|
||||
session=orm_session,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+34
-16
@@ -24,6 +24,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
@@ -38,6 +39,7 @@ from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import (
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.account import Account
|
||||
from models.dataset import Dataset
|
||||
from services.errors.file import FileTooLargeError, UnsupportedFileTypeError
|
||||
from services.rag_pipeline.entity.pipeline_service_api_entities import (
|
||||
DatasourceNodeRunApiEntity,
|
||||
@@ -46,6 +48,20 @@ from services.rag_pipeline.entity.pipeline_service_api_entities import (
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
|
||||
def _persist_dataset(session: Session, *, tenant_id: str, dataset_id: str) -> Dataset:
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Pipeline dataset",
|
||||
created_by="account-1",
|
||||
data_source_type=None,
|
||||
indexing_technique=None,
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
return dataset
|
||||
|
||||
|
||||
class TestDatasourceNodeRunPayload:
|
||||
"""Test suite for DatasourceNodeRunPayload Pydantic model."""
|
||||
|
||||
@@ -550,13 +566,15 @@ class TestPipelineRunApiPost:
|
||||
)
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService")
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns")
|
||||
def test_post_success_streaming(self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app):
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_success_streaming(
|
||||
self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app, sqlite_session: Session
|
||||
):
|
||||
"""Test successful pipeline run with streaming response."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
|
||||
session = Mock()
|
||||
session.scalar.return_value = Mock()
|
||||
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
|
||||
mock_ns.payload = {
|
||||
"inputs": {"key": "val"},
|
||||
@@ -577,33 +595,33 @@ class TestPipelineRunApiPost:
|
||||
|
||||
with app.test_request_context("/datasets/test/pipeline/run", method="POST"):
|
||||
api = PipelineRunApi()
|
||||
response = api.post.__wrapped__(api, session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
response = api.post.__wrapped__(api, sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
|
||||
assert response == {"result": "ok"}
|
||||
mock_svc_cls.assert_called_once_with(session)
|
||||
mock_svc_cls.assert_called_once_with(sqlite_session)
|
||||
mock_gen_svc.generate.assert_called_once()
|
||||
|
||||
def test_post_not_found(self, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_not_found(self, app: Flask, sqlite_session: Session):
|
||||
"""Test NotFound when dataset check fails."""
|
||||
session = Mock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with app.test_request_context("/datasets/test/pipeline/run", method="POST"):
|
||||
api = PipelineRunApi()
|
||||
with pytest.raises(NotFound):
|
||||
api.post.__wrapped__(
|
||||
api,
|
||||
session,
|
||||
sqlite_session,
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
dataset_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user", new="not_account")
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns")
|
||||
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask, sqlite_session: Session):
|
||||
"""Test Forbidden when current_user is not an Account."""
|
||||
session = Mock()
|
||||
session.scalar.return_value = Mock()
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
mock_ns.payload = {
|
||||
"inputs": {},
|
||||
"datasource_type": "online_document",
|
||||
@@ -618,9 +636,9 @@ class TestPipelineRunApiPost:
|
||||
with pytest.raises(Forbidden):
|
||||
api.post.__wrapped__(
|
||||
api,
|
||||
session,
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
dataset_id=str(uuid.uuid4()),
|
||||
sqlite_session,
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ Unit tests for Service API wraps (authentication decorators)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
|
||||
|
||||
from controllers.service_api.wraps import (
|
||||
@@ -21,12 +24,11 @@ from controllers.service_api.wraps import (
|
||||
validate_dataset_token,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from models.account import TenantStatus
|
||||
from models.model import ApiToken
|
||||
from tests.unit_tests.conftest import (
|
||||
setup_mock_dataset_owner_execute_result,
|
||||
setup_mock_tenant_owner_execute_result,
|
||||
)
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
from models.account import TenantAccountRole
|
||||
from models.dataset import Dataset, RateLimitLog
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, AppMode, IconType
|
||||
|
||||
|
||||
def _configure_current_app_mock(mock_current_app):
|
||||
@@ -34,6 +36,51 @@ def _configure_current_app_mock(mock_current_app):
|
||||
mock_current_app._get_current_object = Mock(return_value=Mock())
|
||||
|
||||
|
||||
def _session_proxy(session: Session) -> MagicMock:
|
||||
"""Emulate Flask-SQLAlchemy's callable scoped-session proxy around a test session."""
|
||||
proxy = MagicMock(wraps=session)
|
||||
proxy.return_value = session
|
||||
return proxy
|
||||
|
||||
|
||||
def _api_token(*, tenant_id: str, app_id: str | None = None, token_type: ApiTokenType) -> ApiToken:
|
||||
return ApiToken(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
type=token_type,
|
||||
token="test_token",
|
||||
)
|
||||
|
||||
|
||||
def _persist_workspace(session: Session) -> tuple[Tenant, Account, TenantAccountJoin]:
|
||||
tenant = Tenant(name="Workspace")
|
||||
account = Account(name="Owner", email=f"owner-{uuid.uuid4()}@example.com")
|
||||
membership = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
current=True,
|
||||
role=TenantAccountRole.OWNER,
|
||||
)
|
||||
session.add_all([tenant, account, membership])
|
||||
session.commit()
|
||||
return tenant, account, membership
|
||||
|
||||
|
||||
def _app_model(*, tenant_id: str, enable_api: bool = True) -> App:
|
||||
return App(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
name="Service API App",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="chat",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=enable_api,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateAndGetApiToken:
|
||||
"""Test suite for validate_and_get_api_token function"""
|
||||
|
||||
@@ -70,21 +117,24 @@ class TestValidateAndGetApiToken:
|
||||
def test_valid_token_returns_api_token(self, mock_fetch_token, mock_cache_cls, mock_record_usage, app: Flask):
|
||||
"""Test that valid token returns the ApiToken object."""
|
||||
# Arrange
|
||||
mock_api_token = Mock(spec=ApiToken)
|
||||
mock_api_token.token = "valid_token_123"
|
||||
mock_api_token.type = "app"
|
||||
api_token = _api_token(
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
app_id=str(uuid.uuid4()),
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
api_token.token = "valid_token_123"
|
||||
|
||||
mock_cache_instance = Mock()
|
||||
mock_cache_instance.get.return_value = None # Cache miss
|
||||
mock_cache_cls.get = mock_cache_instance.get
|
||||
mock_fetch_token.return_value = mock_api_token
|
||||
mock_fetch_token.return_value = api_token
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer valid_token_123"}):
|
||||
result = validate_and_get_api_token("app")
|
||||
|
||||
# Assert
|
||||
assert result == mock_api_token
|
||||
assert result == api_token
|
||||
|
||||
@patch("controllers.service_api.wraps.record_token_usage")
|
||||
@patch("controllers.service_api.wraps.ApiTokenCache")
|
||||
@@ -117,116 +167,124 @@ class TestValidateAppToken:
|
||||
return app
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(App, ApiToken, Tenant, Account, TenantAccountJoin)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_valid_app_token_allows_access(
|
||||
self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app
|
||||
self,
|
||||
mock_current_app,
|
||||
mock_validate_token,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test that valid app token allows access to decorated view."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_api_token.tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.id = mock_api_token.app_id
|
||||
mock_app.status = "normal"
|
||||
mock_app.enable_api = True
|
||||
mock_app.tenant_id = mock_api_token.tenant_id
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
mock_tenant.id = mock_api_token.tenant_id
|
||||
|
||||
mock_account = Mock()
|
||||
mock_account.id = str(uuid.uuid4())
|
||||
|
||||
# Use side_effect to return app first, then tenant via session.get()
|
||||
mock_db.session.get.side_effect = [mock_app, mock_tenant]
|
||||
|
||||
# Mock the tenant owner execute result (execute(select(...)).one_or_none())
|
||||
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account)
|
||||
tenant, account, _ = _persist_workspace(sqlite_session)
|
||||
app_model = _app_model(tenant_id=tenant.id)
|
||||
api_token = _api_token(tenant_id=tenant.id, app_id=app_model.id, token_type=ApiTokenType.APP)
|
||||
sqlite_session.add_all([app_model, api_token])
|
||||
sqlite_session.commit()
|
||||
mock_validate_token.return_value = api_token
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(app_model):
|
||||
return {"success": True, "app_id": app_model.id}
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
with (
|
||||
app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
result = protected_view()
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
assert result["app_id"] == mock_app.id
|
||||
assert result["app_id"] == app_model.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_app_not_found_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_not_found_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
"""Test that Forbidden is raised when app no longer exists."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_db.session.get.return_value = None
|
||||
api_token = _api_token(
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
app_id=str(uuid.uuid4()),
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
mock_validate_token.return_value = api_token
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "no longer exists" in str(exc_info.value)
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
"""Test that Forbidden is raised when app status is abnormal."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.status = "abnormal"
|
||||
mock_db.session.get.return_value = mock_app
|
||||
app_model = _app_model(tenant_id=str(uuid.uuid4()))
|
||||
sqlite_session.add(app_model)
|
||||
sqlite_session.commit()
|
||||
app_model.status = "abnormal"
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "status is abnormal" in str(exc_info.value)
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
"""Test that Forbidden is raised when app API is disabled."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.status = "normal"
|
||||
mock_app.enable_api = False
|
||||
mock_db.session.get.return_value = mock_app
|
||||
app_model = _app_model(tenant_id=str(uuid.uuid4()), enable_api=False)
|
||||
sqlite_session.add(app_model)
|
||||
sqlite_session.commit()
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "API service has been disabled" in str(exc_info.value)
|
||||
@@ -468,26 +526,35 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.sessionmaker")
|
||||
@pytest.mark.parametrize("sqlite_session", [(RateLimitLog,)], indirect=True)
|
||||
def test_rejects_over_rate_limit(
|
||||
self, mock_sessionmaker, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask
|
||||
self,
|
||||
mock_get_rate_limit,
|
||||
mock_validate_token,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test that Forbidden is raised when over rate limit."""
|
||||
# Arrange
|
||||
mock_validate_token.return_value = Mock(tenant_id="tenant123")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=tenant_id,
|
||||
token_type=ApiTokenType.DATASET,
|
||||
)
|
||||
|
||||
mock_rate_limit = Mock()
|
||||
mock_rate_limit.enabled = True
|
||||
mock_rate_limit.limit = 10
|
||||
mock_rate_limit.subscription_plan = "pro"
|
||||
mock_get_rate_limit.return_value = mock_rate_limit
|
||||
rate_limit_log_session = MagicMock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.begin.return_value.__enter__.return_value = rate_limit_log_session
|
||||
mock_sessionmaker.return_value = session_factory
|
||||
|
||||
with patch("controllers.service_api.wraps.redis_client") as mock_redis:
|
||||
with (
|
||||
patch("controllers.service_api.wraps.redis_client") as mock_redis,
|
||||
patch(
|
||||
"controllers.service_api.wraps.db",
|
||||
SimpleNamespace(engine=sqlite_session.get_bind()),
|
||||
),
|
||||
):
|
||||
mock_redis.zcard.return_value = 15 # Over limit
|
||||
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
@@ -499,9 +566,12 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
knowledge_request()
|
||||
assert "rate limit" in str(exc_info.value)
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
|
||||
rate_limit_log_session.add.assert_called_once()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
persisted_logs = sqlite_session.scalars(select(RateLimitLog)).all()
|
||||
assert len(persisted_logs) == 1
|
||||
assert persisted_logs[0].tenant_id == tenant_id
|
||||
assert persisted_logs[0].subscription_plan == "pro"
|
||||
assert persisted_logs[0].operation == "knowledge"
|
||||
|
||||
|
||||
class TestValidateDatasetToken:
|
||||
@@ -515,65 +585,62 @@ class TestValidateDatasetToken:
|
||||
return app
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
def test_valid_dataset_token(self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app: Flask):
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Tenant, Account, TenantAccountJoin)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_valid_dataset_token(
|
||||
self,
|
||||
mock_current_app,
|
||||
mock_validate_token,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test that valid dataset token allows access."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = tenant_id
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.id = tenant_id
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
|
||||
mock_ta = Mock()
|
||||
mock_ta.account_id = str(uuid.uuid4())
|
||||
|
||||
mock_account = Mock()
|
||||
mock_account.id = mock_ta.account_id
|
||||
mock_account.current_tenant = mock_tenant
|
||||
|
||||
# Mock the tenant account join query (execute(select(...)).one_or_none())
|
||||
setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta)
|
||||
|
||||
# Mock the account lookup via session.get()
|
||||
mock_db.session.get.return_value = mock_account
|
||||
tenant, account, _ = _persist_workspace(sqlite_session)
|
||||
api_token = _api_token(tenant_id=tenant.id, token_type=ApiTokenType.DATASET)
|
||||
mock_validate_token.return_value = api_token
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(tenant_id):
|
||||
return {"success": True, "tenant_id": tenant_id}
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
with (
|
||||
app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
result = protected_view()
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
assert result["tenant_id"] == tenant_id
|
||||
assert result["tenant_id"] == tenant.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_dataset_not_found_raises_not_found(self, mock_validate_token, mock_db, app: Flask):
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_dataset_not_found_raises_not_found(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
"""Test that NotFound is raised when dataset doesn't exist."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_db.session.scalar.return_value = None
|
||||
api_token = _api_token(tenant_id=str(uuid.uuid4()), token_type=ApiTokenType.DATASET)
|
||||
mock_validate_token.return_value = api_token
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(dataset_id=None, **kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with pytest.raises(NotFound) as exc_info:
|
||||
protected_view(dataset_id=str(uuid.uuid4()))
|
||||
assert "Dataset not found" in str(exc_info.value)
|
||||
|
||||
@@ -6,13 +6,19 @@ from unittest.mock import ANY, MagicMock, patch
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from jwt import InvalidTokenError
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
import services.errors.account
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from models.model import DifySetup
|
||||
from services.entities.auth_entities import LoginFailureReason
|
||||
|
||||
pytestmark = pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
|
||||
|
||||
def encode_code(code: str) -> str:
|
||||
return base64.b64encode(code.encode("utf-8")).decode()
|
||||
@@ -33,17 +39,27 @@ def app():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_wraps():
|
||||
def _patch_wraps(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
wraps_features = SimpleNamespace(enable_email_password_login=True)
|
||||
console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
||||
web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True)
|
||||
sqlite_session.add(DifySetup(version="test"))
|
||||
sqlite_session.commit()
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
session_registry = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False))
|
||||
monkeypatch.setattr(console_wraps.db, "session", session_registry)
|
||||
with (
|
||||
patch("controllers.console.wraps.db") as mock_db,
|
||||
patch("controllers.console.wraps.dify_config", console_dify),
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
|
||||
patch("controllers.web.login.dify_config", web_dify),
|
||||
):
|
||||
yield
|
||||
session_registry.remove()
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
|
||||
|
||||
class TestEmailCodeLoginSendEmailApi:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
|
||||
from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline
|
||||
@@ -54,6 +54,7 @@ from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import AppMode, EndUser
|
||||
from models.workflow import WorkflowAppLog
|
||||
from tests.workflow_test_utils import build_test_variable_pool
|
||||
|
||||
|
||||
@@ -193,7 +194,7 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert isinstance(responses[0], ValueError)
|
||||
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._graph_runtime_state = GraphRuntimeState(
|
||||
variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")),
|
||||
@@ -201,11 +202,10 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
)
|
||||
pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started"
|
||||
|
||||
@contextmanager
|
||||
def _fake_session():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.generate_task_pipeline.db",
|
||||
SimpleNamespace(engine=sqlite_engine),
|
||||
)
|
||||
monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None)
|
||||
|
||||
responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))
|
||||
@@ -339,19 +339,18 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert responses == ["finish"]
|
||||
|
||||
def test_save_workflow_app_log_created_from(self):
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_created_from(self, sqlite_session: Session):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
pipeline._user_id = "user"
|
||||
added: list[object] = []
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
|
||||
sqlite_session.flush()
|
||||
|
||||
class _Session:
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
|
||||
assert added
|
||||
saved_log = sqlite_session.scalar(select(WorkflowAppLog))
|
||||
assert saved_log is not None
|
||||
assert saved_log.workflow_run_id == "run-id"
|
||||
assert saved_log.created_from == "service-api"
|
||||
|
||||
def test_iteration_loop_and_human_input_handlers(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -674,35 +673,29 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
assert "Fails to get audio trunk, task_id: task" in caplog.messages
|
||||
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
|
||||
|
||||
def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_database_session_rolls_back_on_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch, sqlite_engine, sqlite_session: Session
|
||||
):
|
||||
pipeline = _make_pipeline()
|
||||
calls = {"enter": 0, "exit_exc": None}
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
pipeline._user_id = "user"
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.generate_task_pipeline.db",
|
||||
SimpleNamespace(engine=sqlite_engine),
|
||||
)
|
||||
|
||||
class _BeginContext:
|
||||
def __enter__(self):
|
||||
calls["enter"] += 1
|
||||
return MagicMock()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
calls["exit_exc"] = exc_type
|
||||
return False
|
||||
|
||||
class _Sessionmaker:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def begin(self):
|
||||
return _BeginContext()
|
||||
|
||||
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.sessionmaker", _Sessionmaker)
|
||||
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object()))
|
||||
|
||||
with pytest.raises(RuntimeError, match="db error"):
|
||||
with pipeline._database_session():
|
||||
def persist_then_fail() -> None:
|
||||
with pipeline._database_session() as session:
|
||||
pipeline._save_workflow_app_log(session=session, workflow_run_id="run-id")
|
||||
session.flush()
|
||||
raise RuntimeError("db error")
|
||||
|
||||
assert calls["enter"] == 1
|
||||
assert calls["exit_exc"] is RuntimeError
|
||||
with pytest.raises(RuntimeError, match="db error"):
|
||||
persist_then_fail()
|
||||
|
||||
sqlite_session.expire_all()
|
||||
assert sqlite_session.scalar(select(WorkflowAppLog)) is None
|
||||
|
||||
def test_node_retry_and_started_handlers_cover_none_and_value(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -862,31 +855,30 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["stopped"])
|
||||
assert list(pipeline._process_stream_response()) == ["stopped"]
|
||||
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self):
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self, sqlite_session: Session):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._user_id = "user-id"
|
||||
added: list[object] = []
|
||||
|
||||
class _Session:
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.EXPLORE
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "installed-app"
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "web-app"
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-2")
|
||||
sqlite_session.flush()
|
||||
saved_logs = sqlite_session.scalars(select(WorkflowAppLog).order_by(WorkflowAppLog.workflow_run_id)).all()
|
||||
assert [log.created_from for log in saved_logs] == ["installed-app", "web-app"]
|
||||
|
||||
count_before = len(added)
|
||||
count_before = len(saved_logs)
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert len(added) == count_before
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-3")
|
||||
sqlite_session.flush()
|
||||
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id=None)
|
||||
assert len(added) == count_before
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id=None)
|
||||
sqlite_session.flush()
|
||||
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
|
||||
|
||||
def test_save_output_for_event_writes_draft_variables(self):
|
||||
pipeline = _make_pipeline()
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.layers.trigger_post_layer import TriggerPostLayer
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
@@ -13,19 +17,63 @@ from graphon.graph_events import (
|
||||
GraphRunSucceededEvent,
|
||||
)
|
||||
from graphon.runtime import VariablePool
|
||||
from models.enums import WorkflowTriggerStatus
|
||||
from models.enums import AppTriggerType, CreatorUserRole, WorkflowTriggerStatus
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TriggerDatabase:
|
||||
session: Session
|
||||
statements: list[str]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def trigger_database(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[TriggerDatabase]:
|
||||
"""Create the trigger-log table and bind layer-owned sessions to SQLite."""
|
||||
WorkflowTriggerLog.metadata.create_all(sqlite_engine, tables=[WorkflowTriggerLog.__table__])
|
||||
sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker)
|
||||
statements: list[str] = []
|
||||
|
||||
def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
with sqlite_session_maker() as session:
|
||||
try:
|
||||
yield TriggerDatabase(session=session, statements=statements)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
|
||||
def _persist_trigger_log(database: TriggerDatabase, *, trigger_log_id: str = "log-1") -> WorkflowTriggerLog:
|
||||
trigger_log = WorkflowTriggerLog(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id=None,
|
||||
root_node_id=None,
|
||||
trigger_metadata="{}",
|
||||
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
|
||||
trigger_data="{}",
|
||||
inputs="{}",
|
||||
outputs=None,
|
||||
status=WorkflowTriggerStatus.RUNNING,
|
||||
error=None,
|
||||
queue_name="workflow",
|
||||
celery_task_id=None,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
)
|
||||
trigger_log.id = trigger_log_id
|
||||
database.session.add(trigger_log)
|
||||
database.session.commit()
|
||||
return trigger_log
|
||||
|
||||
|
||||
class TestTriggerPostLayer:
|
||||
def test_on_event_updates_trigger_log(self):
|
||||
trigger_log = SimpleNamespace(
|
||||
status=None,
|
||||
workflow_run_id=None,
|
||||
outputs=None,
|
||||
elapsed_time=None,
|
||||
total_tokens=None,
|
||||
finished_at=None,
|
||||
)
|
||||
def test_on_event_updates_trigger_log(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"answer": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -35,19 +83,10 @@ class TestTriggerPostLayer:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
|
||||
):
|
||||
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
|
||||
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = trigger_log
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
|
||||
@@ -57,25 +96,18 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent())
|
||||
|
||||
assert trigger_log.status == WorkflowTriggerStatus.SUCCEEDED
|
||||
assert trigger_log.workflow_run_id == "run-1"
|
||||
assert trigger_log.outputs is not None
|
||||
assert trigger_log.elapsed_time is not None
|
||||
assert trigger_log.total_tokens == 12
|
||||
assert trigger_log.finished_at is not None
|
||||
repo.update.assert_called_once_with(trigger_log)
|
||||
session.commit.assert_called_once()
|
||||
trigger_database.session.expire_all()
|
||||
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
|
||||
assert persisted_log is not None
|
||||
assert persisted_log.status == WorkflowTriggerStatus.SUCCEEDED
|
||||
assert persisted_log.workflow_run_id == "run-1"
|
||||
assert persisted_log.outputs == '{"answer":"ok"}'
|
||||
assert persisted_log.elapsed_time == 10
|
||||
assert persisted_log.total_tokens == 12
|
||||
assert persisted_log.finished_at is not None
|
||||
|
||||
def test_on_event_updates_trigger_log_for_aborted_event(self):
|
||||
trigger_log = SimpleNamespace(
|
||||
status=None,
|
||||
workflow_run_id=None,
|
||||
outputs=None,
|
||||
error=None,
|
||||
elapsed_time=None,
|
||||
total_tokens=None,
|
||||
finished_at=None,
|
||||
)
|
||||
def test_on_event_updates_trigger_log_for_aborted_event(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"partial": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -85,19 +117,10 @@ class TestTriggerPostLayer:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
|
||||
):
|
||||
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
|
||||
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = trigger_log
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
|
||||
@@ -107,17 +130,22 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunAbortedEvent(reason="timeout"))
|
||||
|
||||
assert trigger_log.status == WorkflowTriggerStatus.FAILED
|
||||
assert trigger_log.workflow_run_id == "run-1"
|
||||
assert trigger_log.outputs is not None
|
||||
assert trigger_log.error == "timeout"
|
||||
assert trigger_log.elapsed_time is not None
|
||||
assert trigger_log.total_tokens == 7
|
||||
assert trigger_log.finished_at is not None
|
||||
repo.update.assert_called_once_with(trigger_log)
|
||||
session.commit.assert_called_once()
|
||||
trigger_database.session.expire_all()
|
||||
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
|
||||
assert persisted_log is not None
|
||||
assert persisted_log.status == WorkflowTriggerStatus.FAILED
|
||||
assert persisted_log.workflow_run_id == "run-1"
|
||||
assert persisted_log.outputs == '{"partial":"ok"}'
|
||||
assert persisted_log.error == "timeout"
|
||||
assert persisted_log.elapsed_time == 10
|
||||
assert persisted_log.total_tokens == 7
|
||||
assert persisted_log.finished_at is not None
|
||||
|
||||
def test_on_event_handles_missing_trigger_log(self, caplog: pytest.LogCaptureFixture):
|
||||
def test_on_event_handles_missing_trigger_log(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
trigger_database: TriggerDatabase,
|
||||
):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -126,31 +154,20 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
):
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="missing",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = None
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="missing",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
|
||||
layer.on_event(GraphRunFailedEvent(error="boom"))
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
|
||||
layer.on_event(GraphRunFailedEvent(error="boom"))
|
||||
|
||||
assert any(record.levelno == logging.ERROR for record in caplog.records)
|
||||
session.commit.assert_not_called()
|
||||
assert trigger_database.session.get(WorkflowTriggerLog, "missing") is None
|
||||
|
||||
def test_on_event_ignores_non_status_events(self):
|
||||
def test_on_event_ignores_non_status_events(self, trigger_database: TriggerDatabase):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -159,14 +176,14 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
with patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory:
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="log-1",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="log-1",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
layer.on_event(Mock())
|
||||
trigger_database.statements.clear()
|
||||
layer.on_event(Mock())
|
||||
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
assert trigger_database.statements == []
|
||||
|
||||
+235
-231
@@ -1,24 +1,95 @@
|
||||
"""Unit tests for the message cycle manager optimization."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy import Engine, event, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.entities.queue_entities import QueueAnnotationReplyEvent, QueueRetrieverResourcesEvent
|
||||
from core.app.entities.task_entities import MessageStreamResponse, StreamEvent, TaskStateMetadata
|
||||
from core.app.task_pipeline import message_cycle_manager as message_cycle_manager_module
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from core.rag.entities import RetrievalSourceMetadata
|
||||
from models.model import App, AppMode
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from models import model as model_module
|
||||
from models.base import TypeBase
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo
|
||||
from models.model import App, AppMode, Conversation, MessageFile
|
||||
|
||||
|
||||
def _patch_create_session(mock_session):
|
||||
session_cm = Mock()
|
||||
session_cm.__enter__ = Mock(return_value=mock_session)
|
||||
session_cm.__exit__ = Mock(return_value=False)
|
||||
return patch("core.app.task_pipeline.message_cycle_manager.session_factory.create_session", return_value=session_cm)
|
||||
@dataclass(frozen=True)
|
||||
class _SQLiteDb:
|
||||
engine: Engine
|
||||
session: Session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cycle_db(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
|
||||
"""Bind request-owned and cycle-manager-owned sessions to isolated SQLite."""
|
||||
TypeBase.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[App.__table__, Conversation.__table__, MessageFile.__table__],
|
||||
)
|
||||
owned_session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with owned_session_factory() as request_session:
|
||||
sqlite_db = _SQLiteDb(engine=sqlite_engine, session=request_session)
|
||||
monkeypatch.setattr(message_cycle_manager_module, "db", sqlite_db)
|
||||
monkeypatch.setattr(model_module, "db", sqlite_db)
|
||||
monkeypatch.setattr(message_cycle_manager_module.session_factory, "create_session", owned_session_factory)
|
||||
yield request_session
|
||||
|
||||
|
||||
def _app(*, app_id: str = "app-id", tenant_id: str = "tenant-1") -> App:
|
||||
return App(
|
||||
id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Test App",
|
||||
description="",
|
||||
mode=AppMode.CHAT,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
|
||||
|
||||
def _conversation(*, conversation_id: str = "conv-1", app_id: str = "app-id") -> Conversation:
|
||||
conversation = Conversation(
|
||||
app_id=app_id,
|
||||
mode=AppMode.CHAT,
|
||||
name="",
|
||||
status="normal",
|
||||
from_source=ConversationFromSource.API,
|
||||
inputs={},
|
||||
)
|
||||
conversation.id = conversation_id
|
||||
return conversation
|
||||
|
||||
|
||||
def _message_file(
|
||||
*,
|
||||
file_id: str = "file-1",
|
||||
message_id: str = "test-message-id",
|
||||
belongs_to: MessageFileBelongsTo | None = MessageFileBelongsTo.ASSISTANT,
|
||||
url: str | None = "http://example.com/image.png",
|
||||
file_type: FileType = FileType.IMAGE,
|
||||
) -> MessageFile:
|
||||
message_file = MessageFile(
|
||||
message_id=message_id,
|
||||
type=file_type,
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-id",
|
||||
belongs_to=belongs_to,
|
||||
url=url,
|
||||
)
|
||||
message_file.id = file_id
|
||||
return message_file
|
||||
|
||||
|
||||
class TestMessageCycleManagerOptimization:
|
||||
@@ -37,30 +108,22 @@ class TestMessageCycleManagerOptimization:
|
||||
task_state = Mock()
|
||||
return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state)
|
||||
|
||||
def test_get_message_event_type_with_assistant_file(self, message_cycle_manager):
|
||||
def test_get_message_event_type_with_assistant_file(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has assistant-generated files.
|
||||
|
||||
This ensures that AI-generated images (belongs_to='assistant') trigger the MESSAGE_FILE event,
|
||||
allowing the frontend to properly display generated image files with url field.
|
||||
"""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
cycle_db.add(_message_file())
|
||||
cycle_db.commit()
|
||||
|
||||
mock_message_file = Mock()
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
assert "test-message-id" in message_cycle_manager._message_has_file
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_with_user_file(self, message_cycle_manager):
|
||||
def test_get_message_event_type_with_user_file(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Test get_message_event_type returns MESSAGE when message only has user-uploaded files.
|
||||
|
||||
This is a regression test for the issue where user-uploaded images (belongs_to='user')
|
||||
@@ -68,90 +131,81 @@ class TestMessageCycleManagerOptimization:
|
||||
resulting in broken images in the chat UI. The query filters for belongs_to='assistant',
|
||||
so when only user files exist, the database query returns None, resulting in MESSAGE event type.
|
||||
"""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
cycle_db.add(_message_file(belongs_to=MessageFileBelongsTo.USER))
|
||||
cycle_db.commit()
|
||||
|
||||
# When querying for assistant files with only user files present, return None
|
||||
# (simulates database query with belongs_to='assistant' filter returning no results)
|
||||
mock_session.scalar.return_value = None
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
assert result == StreamEvent.MESSAGE
|
||||
assert "test-message-id" not in message_cycle_manager._message_has_file
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_without_message_file(self, message_cycle_manager):
|
||||
def test_get_message_event_type_without_message_file(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Test get_message_event_type returns MESSAGE when message has no files."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and no message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_session.scalar.return_value = None
|
||||
assert list(cycle_db.scalars(select(MessageFile)).all()) == []
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE
|
||||
mock_session.scalar.assert_called_once()
|
||||
assert result == StreamEvent.MESSAGE
|
||||
|
||||
def test_get_message_event_type_uses_cache_without_query(self, message_cycle_manager):
|
||||
def test_get_message_event_type_uses_cache_without_query(
|
||||
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""Return MESSAGE_FILE directly from in-memory cache without opening a DB session."""
|
||||
message_cycle_manager._message_has_file.add("cached-message")
|
||||
statements: list[str] = []
|
||||
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
try:
|
||||
result = message_cycle_manager.get_message_event_type("cached-message")
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
assert statements == []
|
||||
|
||||
def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager):
|
||||
def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager, cycle_db: Session):
|
||||
"""MessageCycleManager.message_to_stream_response expects a valid event_type; callers should precompute it."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
cycle_db.add(_message_file())
|
||||
cycle_db.commit()
|
||||
|
||||
mock_message_file = Mock()
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
# Execute: compute event type once, then pass to message_to_stream_response
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE_FILE
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE_FILE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_message_to_stream_response_with_event_type_skips_query(self, message_cycle_manager):
|
||||
def test_message_to_stream_response_with_event_type_skips_query(
|
||||
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""Test that message_to_stream_response skips database query when event_type is provided."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Execute with event_type provided
|
||||
statements: list[str] = []
|
||||
|
||||
def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
try:
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
# Should not open a session when event_type is provided
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
assert statements == []
|
||||
|
||||
def test_message_to_stream_response_with_from_variable_selector(self, message_cycle_manager):
|
||||
"""Test message_to_stream_response with from_variable_selector parameter."""
|
||||
@@ -168,40 +222,32 @@ class TestMessageCycleManagerOptimization:
|
||||
assert result.from_variable_selector == ["var1", "var2"]
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
|
||||
def test_optimization_usage_example(self, message_cycle_manager):
|
||||
def test_optimization_usage_example(self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine):
|
||||
"""Test the optimization pattern that should be used by callers."""
|
||||
# Step 1: Get event type once (this queries database)
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_session.scalar.return_value = None # No files
|
||||
statements: list[str] = []
|
||||
|
||||
def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
try:
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Should open session once
|
||||
mock_session_factory.create_session.assert_called_once()
|
||||
assert event_type == StreamEvent.MESSAGE
|
||||
|
||||
# Step 2: Use event_type for multiple calls (no additional queries)
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = Mock()
|
||||
|
||||
chunk1_response = message_cycle_manager.message_to_stream_response(
|
||||
answer="Chunk 1", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
chunk2_response = message_cycle_manager.message_to_stream_response(
|
||||
answer="Chunk 2", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
# Should not open session again when event_type provided
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
|
||||
assert chunk1_response.event == StreamEvent.MESSAGE
|
||||
assert chunk2_response.event == StreamEvent.MESSAGE
|
||||
assert chunk1_response.answer == "Chunk 1"
|
||||
assert chunk2_response.answer == "Chunk 2"
|
||||
assert event_type == StreamEvent.MESSAGE
|
||||
assert len([statement for statement in statements if statement.lstrip().upper().startswith("SELECT")]) == 1
|
||||
assert chunk1_response.event == StreamEvent.MESSAGE
|
||||
assert chunk2_response.event == StreamEvent.MESSAGE
|
||||
assert chunk1_response.answer == "Chunk 1"
|
||||
assert chunk2_response.answer == "Chunk 2"
|
||||
|
||||
def test_generate_conversation_name_returns_none_for_completion(self, message_cycle_manager):
|
||||
"""Return None when completion entities are used for conversation naming.
|
||||
@@ -269,51 +315,38 @@ class TestMessageCycleManagerOptimization:
|
||||
assert message_cycle_manager._application_generate_entity.is_new_conversation is False
|
||||
mock_timer.assert_not_called()
|
||||
|
||||
def test_generate_conversation_name_worker_returns_when_conversation_missing(self, message_cycle_manager):
|
||||
def test_generate_conversation_name_worker_returns_when_conversation_missing(
|
||||
self, message_cycle_manager, cycle_db: Session
|
||||
):
|
||||
"""Return early when the conversation cannot be found."""
|
||||
flask_app = Flask(__name__)
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = None
|
||||
assert list(cycle_db.scalars(select(Conversation)).all()) == []
|
||||
|
||||
with _patch_create_session(db_session):
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-missing", "hello")
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-missing", "hello")
|
||||
|
||||
db_session.commit.assert_not_called()
|
||||
assert list(cycle_db.scalars(select(Conversation)).all()) == []
|
||||
|
||||
def test_generate_conversation_name_worker_returns_when_app_missing(self, message_cycle_manager):
|
||||
def test_generate_conversation_name_worker_returns_when_app_missing(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Return early when non-completion conversation has no app relation."""
|
||||
flask_app = Flask(__name__)
|
||||
conversation = SimpleNamespace(mode=AppMode.CHAT, app=None, app_id="app-id")
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = conversation
|
||||
db_session.get.return_value = None
|
||||
conversation = _conversation()
|
||||
cycle_db.add(conversation)
|
||||
cycle_db.commit()
|
||||
|
||||
with _patch_create_session(db_session):
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
|
||||
|
||||
db_session.commit.assert_not_called()
|
||||
assert cycle_db.get(Conversation, "conv-1").name == ""
|
||||
assert cycle_db.get(App, "app-id") is None
|
||||
|
||||
def test_generate_conversation_name_worker_uses_cached_name(self, message_cycle_manager):
|
||||
def test_generate_conversation_name_worker_uses_cached_name(
|
||||
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""Use cached conversation name when present and avoid LLM call."""
|
||||
flask_app = Flask(__name__)
|
||||
|
||||
class ConversationWithPoisonedApp:
|
||||
mode = AppMode.CHAT
|
||||
app_id = "app-id"
|
||||
name = ""
|
||||
|
||||
@property
|
||||
def app(self):
|
||||
raise AssertionError("conversation.app must not open an implicit session")
|
||||
|
||||
conversation = ConversationWithPoisonedApp()
|
||||
app_model = SimpleNamespace(tenant_id="tenant-1")
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = conversation
|
||||
db_session.get.return_value = app_model
|
||||
cycle_db.add_all([_app(), _conversation()])
|
||||
cycle_db.commit()
|
||||
|
||||
with (
|
||||
_patch_create_session(db_session) as create_session,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
|
||||
):
|
||||
@@ -321,27 +354,23 @@ class TestMessageCycleManagerOptimization:
|
||||
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
|
||||
|
||||
assert cycle_db.in_transaction() is False
|
||||
with Session(sqlite_engine) as verification_session:
|
||||
conversation = verification_session.get(Conversation, "conv-1")
|
||||
assert conversation is not None
|
||||
assert conversation.name == "cached-title"
|
||||
create_session.assert_called_once_with()
|
||||
db_session.get.assert_called_once_with(App, "app-id")
|
||||
db_session.commit.assert_called_once()
|
||||
mock_llm_generator.generate_conversation_name.assert_not_called()
|
||||
mock_redis.setex.assert_not_called()
|
||||
|
||||
def test_generate_conversation_name_worker_generates_and_caches_name(self, message_cycle_manager):
|
||||
def test_generate_conversation_name_worker_generates_and_caches_name(
|
||||
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""Generate conversation name and write it to redis cache on cache miss."""
|
||||
flask_app = Flask(__name__)
|
||||
conversation = SimpleNamespace(
|
||||
mode=AppMode.CHAT,
|
||||
app=SimpleNamespace(tenant_id="tenant-1"),
|
||||
app_id="app-id",
|
||||
name="",
|
||||
)
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = conversation
|
||||
cycle_db.add_all([_app(), _conversation()])
|
||||
cycle_db.commit()
|
||||
|
||||
with (
|
||||
_patch_create_session(db_session),
|
||||
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
|
||||
):
|
||||
@@ -350,27 +379,27 @@ class TestMessageCycleManagerOptimization:
|
||||
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
|
||||
|
||||
assert cycle_db.in_transaction() is False
|
||||
with Session(sqlite_engine) as verification_session:
|
||||
conversation = verification_session.get(Conversation, "conv-1")
|
||||
assert conversation is not None
|
||||
assert conversation.name == "generated-title"
|
||||
db_session.commit.assert_called_once()
|
||||
mock_redis.setex.assert_called_once()
|
||||
|
||||
def test_generate_conversation_name_worker_falls_back_when_generation_fails(
|
||||
self, message_cycle_manager, caplog: pytest.LogCaptureFixture
|
||||
self,
|
||||
message_cycle_manager,
|
||||
cycle_db: Session,
|
||||
sqlite_engine: Engine,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""Fallback to truncated query when LLM generation fails."""
|
||||
flask_app = Flask(__name__)
|
||||
conversation = SimpleNamespace(
|
||||
mode=AppMode.CHAT,
|
||||
app=SimpleNamespace(tenant_id="tenant-1"),
|
||||
app_id="app-id",
|
||||
name="",
|
||||
)
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = conversation
|
||||
cycle_db.add_all([_app(), _conversation()])
|
||||
cycle_db.commit()
|
||||
long_query = "q" * 60
|
||||
|
||||
with (
|
||||
_patch_create_session(db_session),
|
||||
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.dify_config") as mock_dify_config,
|
||||
@@ -382,8 +411,11 @@ class TestMessageCycleManagerOptimization:
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.task_pipeline.message_cycle_manager"):
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query)
|
||||
|
||||
assert cycle_db.in_transaction() is False
|
||||
with Session(sqlite_engine) as verification_session:
|
||||
conversation = verification_session.get(Conversation, "conv-1")
|
||||
assert conversation is not None
|
||||
assert conversation.name == (long_query[:47] + "...")
|
||||
db_session.commit.assert_called_once()
|
||||
assert any(record.levelno == logging.ERROR for record in caplog.records)
|
||||
|
||||
def test_handle_annotation_reply_sets_metadata(self, message_cycle_manager):
|
||||
@@ -454,33 +486,25 @@ class TestMessageCycleManagerOptimization:
|
||||
assert message_cycle_manager._task_state.metadata.retriever_resources[0].position == 1
|
||||
assert message_cycle_manager._task_state.metadata.retriever_resources[1].position == 2
|
||||
|
||||
def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_manager):
|
||||
def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Build a stream response with a signed tool file URL.
|
||||
|
||||
Args: message_cycle_manager with mocked Session/db and sign_tool_file.
|
||||
Args: message_cycle_manager with a persisted MessageFile and mocked sign_tool_file.
|
||||
Returns: MessageStreamResponse with signed url and belongs_to normalized to user.
|
||||
Side effects: Calls sign_tool_file for tool file ids.
|
||||
"""
|
||||
message_cycle_manager._application_generate_entity.task_id = "task-1"
|
||||
|
||||
message_file = SimpleNamespace(
|
||||
id="file-1",
|
||||
type="image",
|
||||
belongs_to=None,
|
||||
url="tool://file.verylongextension",
|
||||
message_id="msg-1",
|
||||
cycle_db.add(
|
||||
_message_file(
|
||||
file_id="file-1",
|
||||
message_id="msg-1",
|
||||
belongs_to=None,
|
||||
url="tool://file.verylongextension",
|
||||
)
|
||||
)
|
||||
cycle_db.commit()
|
||||
|
||||
session = Mock()
|
||||
session.scalar.return_value = message_file
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
|
||||
):
|
||||
mock_db.engine = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = session
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign:
|
||||
mock_sign.return_value = "signed-url"
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="file-1"))
|
||||
@@ -514,56 +538,42 @@ class TestMessageCycleManagerOptimization:
|
||||
assert len(message_cycle_manager._task_state.metadata.retriever_resources) == 1
|
||||
assert message_cycle_manager._task_state.metadata.retriever_resources[0].position == 1
|
||||
|
||||
def test_message_file_to_stream_response_uses_http_url_directly(self, message_cycle_manager):
|
||||
def test_message_file_to_stream_response_uses_http_url_directly(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Use original URL when message file URL is already HTTP."""
|
||||
message_cycle_manager._application_generate_entity.task_id = "task-http"
|
||||
message_file = SimpleNamespace(
|
||||
id="file-http",
|
||||
type="image",
|
||||
belongs_to="assistant",
|
||||
url="http://example.com/pic.png",
|
||||
message_id="msg-http",
|
||||
)
|
||||
|
||||
session = Mock()
|
||||
session.scalar.return_value = message_file
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
|
||||
):
|
||||
mock_db.engine = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = session
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(
|
||||
SimpleNamespace(message_file_id="file-http")
|
||||
cycle_db.add(
|
||||
_message_file(
|
||||
file_id="file-http",
|
||||
message_id="msg-http",
|
||||
belongs_to=MessageFileBelongsTo.ASSISTANT,
|
||||
url="http://example.com/pic.png",
|
||||
)
|
||||
)
|
||||
cycle_db.commit()
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="file-http"))
|
||||
|
||||
assert response is not None
|
||||
assert response.url == "http://example.com/pic.png"
|
||||
assert "msg-http" in message_cycle_manager._message_has_file
|
||||
|
||||
def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot(self, message_cycle_manager):
|
||||
def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot(
|
||||
self, message_cycle_manager, cycle_db: Session
|
||||
):
|
||||
"""Default tool file extension to .bin when URL has no extension part."""
|
||||
message_cycle_manager._application_generate_entity.task_id = "task-bin"
|
||||
message_file = SimpleNamespace(
|
||||
id="file-bin",
|
||||
type="file",
|
||||
belongs_to="assistant",
|
||||
url="tool-file-id",
|
||||
message_id="msg-bin",
|
||||
cycle_db.add(
|
||||
_message_file(
|
||||
file_id="file-bin",
|
||||
message_id="msg-bin",
|
||||
belongs_to=MessageFileBelongsTo.ASSISTANT,
|
||||
url="tool-file-id",
|
||||
file_type=FileType.CUSTOM,
|
||||
)
|
||||
)
|
||||
cycle_db.commit()
|
||||
|
||||
session = Mock()
|
||||
session.scalar.return_value = message_file
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
|
||||
):
|
||||
mock_db.engine = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = session
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign:
|
||||
mock_sign.return_value = "signed-bin-url"
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(
|
||||
@@ -574,19 +584,13 @@ class TestMessageCycleManagerOptimization:
|
||||
assert response.url == "signed-bin-url"
|
||||
mock_sign.assert_called_once_with(tool_file_id="tool-file-id", extension=".bin")
|
||||
|
||||
def test_message_file_to_stream_response_returns_none_when_file_missing(self, message_cycle_manager):
|
||||
def test_message_file_to_stream_response_returns_none_when_file_missing(
|
||||
self, message_cycle_manager, cycle_db: Session
|
||||
):
|
||||
"""Return None when message file lookup does not find a record."""
|
||||
session = Mock()
|
||||
session.scalar.return_value = None
|
||||
assert list(cycle_db.scalars(select(MessageFile)).all()) == []
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
|
||||
):
|
||||
mock_db.engine = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = session
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="missing"))
|
||||
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="missing"))
|
||||
|
||||
assert response is None
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Comprehensive unit tests for core/memory/token_buffer_memory.py"""
|
||||
"""Comprehensive SQLite-backed tests for token-buffer memory."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.memory import token_buffer_memory as memory_module
|
||||
from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from graphon.model_runtime.entities import (
|
||||
AssistantPromptMessage,
|
||||
ImagePromptMessageContent,
|
||||
@@ -13,13 +21,44 @@ from graphon.model_runtime.entities import (
|
||||
TextPromptMessageContent,
|
||||
UserPromptMessage,
|
||||
)
|
||||
from models.model import AppMode
|
||||
from models.base import TypeBase
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo
|
||||
from models.model import AppMode, Message, MessageFile
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Database:
|
||||
"""Typed SQLite binding plus executed SQL for query-count assertions."""
|
||||
|
||||
engine: Engine
|
||||
session: Session
|
||||
statements: list[tuple[str, object]]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
|
||||
TypeBase.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[Message.__table__, MessageFile.__table__, Workflow.__table__],
|
||||
)
|
||||
statements: list[tuple[str, object]] = []
|
||||
|
||||
def record_statement(_connection, _cursor, statement, parameters, _context, _executemany) -> None:
|
||||
statements.append((statement, parameters))
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
database = Database(engine=sqlite_engine, session=session, statements=statements)
|
||||
monkeypatch.setattr(memory_module, "db", database)
|
||||
yield database
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
|
||||
def _make_conversation(mode: AppMode = AppMode.CHAT) -> MagicMock:
|
||||
"""Return a minimal Conversation mock."""
|
||||
conv = MagicMock()
|
||||
@@ -47,6 +86,73 @@ def _make_message(answer: str = "hello", answer_tokens: int = 5) -> MagicMock:
|
||||
return msg
|
||||
|
||||
|
||||
def _persist_message(
|
||||
database: Database,
|
||||
conversation_id: str,
|
||||
*,
|
||||
query: str = "user query",
|
||||
answer: str = "hello",
|
||||
answer_tokens: int = 5,
|
||||
created_at: datetime | None = None,
|
||||
workflow_run_id: str | None = None,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
id=str(uuid4()),
|
||||
app_id="app-1",
|
||||
conversation_id=conversation_id,
|
||||
_inputs={},
|
||||
query=query,
|
||||
message={},
|
||||
message_unit_price=Decimal(0),
|
||||
answer=answer,
|
||||
answer_tokens=answer_tokens,
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
workflow_run_id=workflow_run_id,
|
||||
created_at=created_at or datetime.now(UTC).replace(tzinfo=None),
|
||||
)
|
||||
database.session.add(message)
|
||||
database.session.commit()
|
||||
return message
|
||||
|
||||
|
||||
def _persist_message_file(
|
||||
database: Database,
|
||||
message: Message,
|
||||
*,
|
||||
belongs_to: MessageFileBelongsTo | None,
|
||||
) -> MessageFile:
|
||||
message_file = MessageFile(
|
||||
message_id=message.id,
|
||||
type=FileType.IMAGE,
|
||||
transfer_method=FileTransferMethod.REMOTE_URL,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
belongs_to=belongs_to,
|
||||
url="https://example.com/image.png",
|
||||
)
|
||||
database.session.add(message_file)
|
||||
database.session.commit()
|
||||
return message_file
|
||||
|
||||
|
||||
def _persist_workflow(database: Database, *, workflow_id: str) -> Workflow:
|
||||
workflow = Workflow(
|
||||
id=workflow_id,
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
type=WorkflowType.CHAT,
|
||||
version="1",
|
||||
graph="{}",
|
||||
features="{}",
|
||||
created_by="account-1",
|
||||
)
|
||||
database.session.add(workflow)
|
||||
database.session.commit()
|
||||
return workflow
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests for __init__ and workflow_run_repo property
|
||||
# ===========================================================================
|
||||
@@ -61,25 +167,25 @@ class TestInit:
|
||||
assert mem.model_instance is mi
|
||||
assert mem._workflow_run_repo is None
|
||||
|
||||
def test_workflow_run_repo_is_created_lazily(self):
|
||||
def test_workflow_run_repo_is_created_lazily(self, database: Database):
|
||||
conv = _make_conversation()
|
||||
mi = _make_model_instance()
|
||||
mem = TokenBufferMemory(conversation=conv, model_instance=mi)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.sessionmaker") as mock_sm,
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=mock_repo,
|
||||
),
|
||||
):
|
||||
mock_db.engine = MagicMock()
|
||||
with patch(
|
||||
"core.memory.token_buffer_memory.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=mock_repo,
|
||||
) as repository_factory:
|
||||
repo = mem.workflow_run_repo
|
||||
assert repo is mock_repo
|
||||
assert mem._workflow_run_repo is mock_repo
|
||||
|
||||
session_factory = repository_factory.call_args.args[0]
|
||||
with session_factory() as session:
|
||||
assert isinstance(session, Session)
|
||||
assert session.get_bind() is database.engine
|
||||
|
||||
def test_workflow_run_repo_cached_after_first_access(self):
|
||||
conv = _make_conversation()
|
||||
mi = _make_model_instance()
|
||||
@@ -410,7 +516,7 @@ class TestBuildPromptMessageWithFiles:
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def test_workflow_mode_workflow_not_found_raises(self, mode):
|
||||
def test_workflow_mode_workflow_not_found_raises(self, mode, database: Database):
|
||||
"""Raises ValueError when Workflow lookup returns None."""
|
||||
conv = _make_conversation(mode)
|
||||
conv.app = MagicMock()
|
||||
@@ -422,22 +528,17 @@ class TestBuildPromptMessageWithFiles:
|
||||
mem._workflow_run_repo = MagicMock()
|
||||
mem._workflow_run_repo.get_workflow_run_by_id.return_value = mock_workflow_run
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
):
|
||||
mock_db.session.scalar.return_value = None # workflow not found
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
mem._build_prompt_message_with_files(
|
||||
message_files=[],
|
||||
text_content="text",
|
||||
message=_make_message(),
|
||||
app_record=MagicMock(),
|
||||
is_user_message=True,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
mem._build_prompt_message_with_files(
|
||||
message_files=[],
|
||||
text_content="text",
|
||||
message=_make_message(),
|
||||
app_record=MagicMock(),
|
||||
is_user_message=True,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def test_workflow_mode_success_no_files_user(self, mode):
|
||||
def test_workflow_mode_success_no_files_user(self, mode, database: Database):
|
||||
"""Happy path: workflow mode, no message files → plain UserPromptMessage."""
|
||||
conv = _make_conversation(mode)
|
||||
conv.app = MagicMock()
|
||||
@@ -445,22 +546,16 @@ class TestBuildPromptMessageWithFiles:
|
||||
mock_workflow_run = MagicMock()
|
||||
mock_workflow_run.workflow_id = str(uuid4())
|
||||
|
||||
mock_workflow = MagicMock()
|
||||
mock_workflow.features_dict = {}
|
||||
workflow = _persist_workflow(database, workflow_id=mock_workflow_run.workflow_id)
|
||||
|
||||
mem = TokenBufferMemory(conversation=conv, model_instance=_make_model_instance())
|
||||
mem._workflow_run_repo = MagicMock()
|
||||
mem._workflow_run_repo.get_workflow_run_by_id.return_value = mock_workflow_run
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
with patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
):
|
||||
mock_db.session.scalar.return_value = mock_workflow
|
||||
|
||||
result = mem._build_prompt_message_with_files(
|
||||
message_files=[],
|
||||
text_content="wf text",
|
||||
@@ -471,6 +566,7 @@ class TestBuildPromptMessageWithFiles:
|
||||
|
||||
assert isinstance(result, UserPromptMessage)
|
||||
assert result.content == "wf text"
|
||||
assert database.session.get(Workflow, workflow.id) is workflow
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Invalid mode
|
||||
@@ -498,417 +594,140 @@ class TestBuildPromptMessageWithFiles:
|
||||
|
||||
|
||||
class TestGetHistoryPromptMessages:
|
||||
"""Tests for get_history_prompt_messages."""
|
||||
"""Tests for persisted history retrieval, file batching, and pruning."""
|
||||
|
||||
def _make_memory(self, mode: AppMode = AppMode.CHAT) -> TokenBufferMemory:
|
||||
conv = _make_conversation(mode)
|
||||
conv.app = MagicMock()
|
||||
return TokenBufferMemory(conversation=conv, model_instance=_make_model_instance())
|
||||
|
||||
def test_returns_empty_when_no_messages(self):
|
||||
def test_returns_empty_when_no_messages(self, database: Database) -> None:
|
||||
assert self._make_memory().get_history_prompt_messages() == []
|
||||
|
||||
def test_skips_newest_message_without_answer(self, database: Database) -> None:
|
||||
mem = self._make_memory()
|
||||
with patch("core.memory.token_buffer_memory.db") as mock_db:
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
result = mem.get_history_prompt_messages()
|
||||
assert result == []
|
||||
message = _persist_message(database, mem.conversation.id, answer="", answer_tokens=0)
|
||||
|
||||
def test_skips_first_message_without_answer(self):
|
||||
"""The newest message (index 0 after extraction) without answer and tokens==0 is skipped."""
|
||||
assert mem.get_history_prompt_messages() == []
|
||||
assert database.session.get(Message, message.id) is message
|
||||
|
||||
def test_message_with_answer_returns_user_and_assistant_prompts(self, database: Database) -> None:
|
||||
mem = self._make_memory()
|
||||
_persist_message(database, mem.conversation.id, query="My query", answer="My answer", answer_tokens=10)
|
||||
|
||||
msg_no_answer = _make_message(answer="", answer_tokens=0)
|
||||
msg_no_answer.parent_message_id = None # ensures extract_thread_messages returns it
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg_no_answer],
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.return_value.all.side_effect = [
|
||||
[msg_no_answer], # first call: messages query
|
||||
[], # second call: user files query (never hit, but safe)
|
||||
]
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_message_with_answer_not_skipped(self):
|
||||
"""A message with a non-empty answer is NOT popped."""
|
||||
mem = self._make_memory()
|
||||
|
||||
msg = _make_message(answer="some answer", answer_tokens=10)
|
||||
msg.parent_message_id = None
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
# user files query → empty; assistant files query → empty
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert len(result) == 2 # one user + one assistant
|
||||
|
||||
def test_message_limit_default_is_500(self):
|
||||
"""When message_limit is None the stmt is limited to 500."""
|
||||
mem = self._make_memory()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.select") as mock_select,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
|
||||
):
|
||||
mock_stmt = MagicMock()
|
||||
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
|
||||
mock_stmt.limit.return_value = mock_stmt
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=None)
|
||||
mock_stmt.limit.assert_called_with(500)
|
||||
|
||||
def test_message_limit_clipped_to_500(self):
|
||||
"""A message_limit > 500 is clamped to 500."""
|
||||
mem = self._make_memory()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.select") as mock_select,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
|
||||
):
|
||||
mock_stmt = MagicMock()
|
||||
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
|
||||
mock_stmt.limit.return_value = mock_stmt
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=9999)
|
||||
mock_stmt.limit.assert_called_with(500)
|
||||
|
||||
def test_message_limit_positive_used(self):
|
||||
"""A positive message_limit < 500 is used as-is."""
|
||||
mem = self._make_memory()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.select") as mock_select,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
|
||||
):
|
||||
mock_stmt = MagicMock()
|
||||
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
|
||||
mock_stmt.limit.return_value = mock_stmt
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=10)
|
||||
mock_stmt.limit.assert_called_with(10)
|
||||
|
||||
def test_message_limit_zero_uses_default(self):
|
||||
"""message_limit=0 triggers the else branch → default 500."""
|
||||
mem = self._make_memory()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.select") as mock_select,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
|
||||
):
|
||||
mock_stmt = MagicMock()
|
||||
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
|
||||
mock_stmt.limit.return_value = mock_stmt
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=0)
|
||||
mock_stmt.limit.assert_called_with(500)
|
||||
|
||||
def test_user_files_cause_build_with_files_call(self):
|
||||
"""When user_files is non-empty _build_prompt_message_with_files is invoked."""
|
||||
mem = self._make_memory()
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
mock_user_file = MagicMock()
|
||||
mock_user_file.message_id = msg.id # must match so batched grouping keys it to this message
|
||||
mock_user_prompt = UserPromptMessage(content="from build")
|
||||
mock_assistant_prompt = AssistantPromptMessage(content="answer")
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
# messages query
|
||||
r.all.return_value = [msg]
|
||||
elif call_count["n"] == 1:
|
||||
# user files
|
||||
r.all.return_value = [mock_user_file]
|
||||
else:
|
||||
# assistant files
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch.object(
|
||||
mem,
|
||||
"_build_prompt_message_with_files",
|
||||
side_effect=[mock_user_prompt, mock_assistant_prompt],
|
||||
) as mock_build,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert mock_build.call_count >= 1
|
||||
# First call should be user message
|
||||
first_call_kwargs = mock_build.call_args_list[0][1]
|
||||
assert first_call_kwargs["is_user_message"] is True
|
||||
|
||||
def test_assistant_files_cause_build_with_files_call(self):
|
||||
"""When assistant_files is non-empty, build is called with is_user_message=False."""
|
||||
mem = self._make_memory()
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
mock_assistant_file = MagicMock()
|
||||
mock_assistant_file.message_id = msg.id # must match so batched grouping keys it to this message
|
||||
mock_user_prompt = UserPromptMessage(content="query")
|
||||
mock_assistant_prompt = AssistantPromptMessage(content="built")
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
elif call_count["n"] == 1:
|
||||
r.all.return_value = [] # no user files
|
||||
else:
|
||||
r.all.return_value = [mock_assistant_file]
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch.object(
|
||||
mem,
|
||||
"_build_prompt_message_with_files",
|
||||
return_value=mock_assistant_prompt,
|
||||
) as mock_build,
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
mock_build.assert_called_once()
|
||||
call_kwargs = mock_build.call_args[1]
|
||||
assert call_kwargs["is_user_message"] is False
|
||||
|
||||
def test_message_files_loaded_with_constant_query_count(self):
|
||||
"""Regression guard against N+1: message files must be batch-loaded.
|
||||
|
||||
Regardless of the number of messages in the thread, file loading must use a
|
||||
constant number of queries (1 messages query + 2 batched file queries),
|
||||
never 2 queries per message.
|
||||
"""
|
||||
mem = self._make_memory()
|
||||
|
||||
messages = [_make_message() for _ in range(5)]
|
||||
for m in messages:
|
||||
m.parent_message_id = None
|
||||
|
||||
scalars_calls = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
# First call returns the thread messages; the batched file queries return none.
|
||||
r.all.return_value = messages if scalars_calls["n"] == 0 else []
|
||||
scalars_calls["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=messages),
|
||||
patch("core.memory.token_buffer_memory.FileUploadConfigManager.convert", return_value=None),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
mem.get_history_prompt_messages()
|
||||
|
||||
# 1 (messages) + 2 (batched user/assistant files) = 3, independent of message count.
|
||||
# Before this fix it would have been 1 + 2 * 5 = 11 (an N+1 pattern).
|
||||
assert scalars_calls["n"] == 3
|
||||
|
||||
def test_token_pruning_removes_oldest_messages(self):
|
||||
"""If tokens exceed limit, oldest messages are removed until within limit."""
|
||||
conv = _make_conversation()
|
||||
conv.app = MagicMock()
|
||||
|
||||
# Model returns tokens that decrease only after removing pairs
|
||||
token_values = [3000, 1500] # first call over limit, second within
|
||||
mi = MagicMock()
|
||||
mi.get_llm_num_tokens.side_effect = token_values
|
||||
|
||||
mem = TokenBufferMemory(conversation=conv, model_instance=mi)
|
||||
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
else:
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages(max_token_limit=2000)
|
||||
|
||||
# After pruning, we should have fewer than the 2 initial messages
|
||||
assert len(result) <= 1
|
||||
|
||||
def test_token_pruning_stops_at_single_message(self):
|
||||
"""Pruning stops when only 1 message remains (to prevent empty list)."""
|
||||
conv = _make_conversation()
|
||||
conv.app = MagicMock()
|
||||
|
||||
# Always over limit
|
||||
mi = MagicMock()
|
||||
mi.get_llm_num_tokens.return_value = 99999
|
||||
|
||||
mem = TokenBufferMemory(conversation=conv, model_instance=mi)
|
||||
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
else:
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages(max_token_limit=1)
|
||||
|
||||
# At least 1 message should remain
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_no_pruning_when_within_limit(self):
|
||||
"""When tokens ≤ limit, no pruning occurs."""
|
||||
mem = self._make_memory()
|
||||
mem.model_instance.get_llm_num_tokens.return_value = 50 # well under default 2000
|
||||
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
else:
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages(max_token_limit=2000)
|
||||
|
||||
assert len(result) == 2 # user + assistant
|
||||
|
||||
def test_plain_user_and_assistant_messages_returned(self):
|
||||
"""Without files, plain UserPromptMessage and AssistantPromptMessage appear."""
|
||||
mem = self._make_memory()
|
||||
|
||||
msg = _make_message(answer="My answer")
|
||||
msg.query = "My query"
|
||||
msg.parent_message_id = None
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
else:
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages()
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert len(result) == 2
|
||||
user_msg, ai_msg = result
|
||||
assert isinstance(user_msg, UserPromptMessage)
|
||||
assert user_msg.content == "My query"
|
||||
assert isinstance(ai_msg, AssistantPromptMessage)
|
||||
assert ai_msg.content == "My answer"
|
||||
assert isinstance(result[0], UserPromptMessage)
|
||||
assert result[0].content == "My query"
|
||||
assert isinstance(result[1], AssistantPromptMessage)
|
||||
assert result[1].content == "My answer"
|
||||
|
||||
def test_history_is_conversation_scoped(self, database: Database) -> None:
|
||||
mem = self._make_memory()
|
||||
_persist_message(database, mem.conversation.id, answer="visible")
|
||||
_persist_message(database, "other-conversation", answer="hidden")
|
||||
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert [prompt.content for prompt in result] == ["user query", "visible"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message_limit", "expected_limit"),
|
||||
[(None, 500), (9999, 500), (10, 10), (0, 500)],
|
||||
)
|
||||
def test_message_limit_is_applied_to_executable_query(
|
||||
self,
|
||||
database: Database,
|
||||
message_limit: int | None,
|
||||
expected_limit: int,
|
||||
) -> None:
|
||||
mem = self._make_memory()
|
||||
before = len(database.statements)
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=message_limit)
|
||||
|
||||
statements = database.statements[before:]
|
||||
assert len(statements) == 1
|
||||
sql, parameters = statements[0]
|
||||
assert "LIMIT" in sql
|
||||
assert expected_limit in parameters
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("belongs_to", "is_user_message"),
|
||||
[
|
||||
(MessageFileBelongsTo.USER, True),
|
||||
(None, True),
|
||||
(MessageFileBelongsTo.ASSISTANT, False),
|
||||
],
|
||||
)
|
||||
def test_message_files_use_persisted_ownership(
|
||||
self,
|
||||
database: Database,
|
||||
belongs_to: MessageFileBelongsTo | None,
|
||||
is_user_message: bool,
|
||||
) -> None:
|
||||
mem = self._make_memory()
|
||||
message = _persist_message(database, mem.conversation.id)
|
||||
message_file = _persist_message_file(database, message, belongs_to=belongs_to)
|
||||
built_prompt = (
|
||||
UserPromptMessage(content="built user")
|
||||
if is_user_message
|
||||
else AssistantPromptMessage(content="built assistant")
|
||||
)
|
||||
|
||||
with patch.object(mem, "_build_prompt_message_with_files", return_value=built_prompt) as build_prompt:
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
build_prompt.assert_called_once()
|
||||
assert build_prompt.call_args.kwargs["message_files"] == [message_file]
|
||||
assert build_prompt.call_args.kwargs["is_user_message"] is is_user_message
|
||||
assert built_prompt in result
|
||||
|
||||
def test_message_files_are_batch_loaded_with_constant_query_count(self, database: Database) -> None:
|
||||
mem = self._make_memory()
|
||||
base_time = datetime.now(UTC).replace(tzinfo=None)
|
||||
messages = [
|
||||
_persist_message(
|
||||
database,
|
||||
mem.conversation.id,
|
||||
query=f"query-{index}",
|
||||
answer=f"answer-{index}",
|
||||
created_at=base_time + timedelta(seconds=index),
|
||||
)
|
||||
for index in range(5)
|
||||
]
|
||||
before = len(database.statements)
|
||||
|
||||
with patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=messages):
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
selects = [sql for sql, _ in database.statements[before:] if sql.lstrip().upper().startswith("SELECT")]
|
||||
assert len(selects) == 3
|
||||
assert len(result) == 10
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("token_values", "max_token_limit", "expected_length"),
|
||||
[
|
||||
([3000, 1500], 2000, 1),
|
||||
([99999, 99999], 1, 1),
|
||||
([50], 2000, 2),
|
||||
],
|
||||
)
|
||||
def test_token_pruning_uses_persisted_history(
|
||||
self,
|
||||
database: Database,
|
||||
token_values: list[int],
|
||||
max_token_limit: int,
|
||||
expected_length: int,
|
||||
) -> None:
|
||||
mem = self._make_memory()
|
||||
mem.model_instance.get_llm_num_tokens.side_effect = token_values
|
||||
_persist_message(database, mem.conversation.id)
|
||||
|
||||
result = mem.get_history_prompt_messages(max_token_limit=max_token_limit)
|
||||
|
||||
assert len(result) == expected_length
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
@@ -7,36 +7,212 @@ Covers:
|
||||
- TraceTask._get_user_id_from_metadata
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.account import Tenant
|
||||
from models.base import TypeBase
|
||||
from models.model import App, AppMode, IconType
|
||||
from models.provider import Provider, ProviderCredential, ProviderModel, ProviderModelCredential, ProviderType
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_db_and_session_patches(scalar_side_effect=None, scalar_return_value=None):
|
||||
"""Return (mock_db, cm, session) ready to patch 'core.ops.ops_trace_manager.db'
|
||||
and 'core.ops.ops_trace_manager.Session'.
|
||||
@pytest.fixture
|
||||
def orm_session(sqlite_engine: Engine) -> Iterator[Session]:
|
||||
models = (
|
||||
App,
|
||||
Tenant,
|
||||
Provider,
|
||||
ProviderCredential,
|
||||
ProviderModel,
|
||||
ProviderModelCredential,
|
||||
BuiltinToolProvider,
|
||||
ApiToolProvider,
|
||||
WorkflowToolProvider,
|
||||
MCPToolProvider,
|
||||
)
|
||||
tables = [model.metadata.tables[model.__tablename__] for model in models]
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
|
||||
|
||||
Provide either scalar_side_effect (list, for multiple calls) or
|
||||
scalar_return_value (single value).
|
||||
"""
|
||||
mock_db = MagicMock()
|
||||
mock_db.engine = MagicMock()
|
||||
with patch.object(type(db), "engine", new_callable=PropertyMock, return_value=sqlite_engine):
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
session = MagicMock()
|
||||
if scalar_side_effect is not None:
|
||||
session.scalar.side_effect = scalar_side_effect
|
||||
|
||||
def _persist_app(session: Session, *, tenant_id: str, name: str = "MyApp") -> App:
|
||||
app = App(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
mode=AppMode.WORKFLOW,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="workflow",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=False,
|
||||
)
|
||||
session.add(app)
|
||||
session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _persist_tenant(session: Session, *, name: str = "MyWorkspace") -> Tenant:
|
||||
tenant = Tenant(name=name)
|
||||
session.add(tenant)
|
||||
session.commit()
|
||||
return tenant
|
||||
|
||||
|
||||
def _persist_tool_provider(
|
||||
session: Session, provider_type: str
|
||||
) -> BuiltinToolProvider | ApiToolProvider | WorkflowToolProvider | MCPToolProvider:
|
||||
tenant_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
if provider_type in {"builtin", "plugin"}:
|
||||
provider = BuiltinToolProvider(
|
||||
name="CredentialA",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
provider="test/provider",
|
||||
)
|
||||
elif provider_type == "api":
|
||||
provider = ApiToolProvider(
|
||||
name="CredentialA",
|
||||
icon="icon.svg",
|
||||
schema="{}",
|
||||
schema_type_str=ApiProviderSchemaType.OPENAPI,
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
description="API provider",
|
||||
tools_str="[]",
|
||||
credentials_str="{}",
|
||||
)
|
||||
elif provider_type == "workflow":
|
||||
provider = WorkflowToolProvider(
|
||||
name="CredentialA",
|
||||
label="CredentialA",
|
||||
icon="icon.svg",
|
||||
app_id=str(uuid.uuid4()),
|
||||
version="1",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
description="Workflow provider",
|
||||
)
|
||||
elif provider_type == "mcp":
|
||||
provider = MCPToolProvider(
|
||||
name="CredentialA",
|
||||
server_identifier="credential-a",
|
||||
server_url="https://example.com/mcp",
|
||||
server_url_hash="credential-a-hash",
|
||||
icon="icon.svg",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
session.scalar.return_value = scalar_return_value
|
||||
raise ValueError(f"unsupported provider type: {provider_type}")
|
||||
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(return_value=session)
|
||||
cm.__exit__ = MagicMock(return_value=False)
|
||||
session.add(provider)
|
||||
session.commit()
|
||||
return provider
|
||||
|
||||
return mock_db, cm, session
|
||||
|
||||
def _persist_provider_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
credential_name: str = "ProvCredName",
|
||||
) -> ProviderCredential:
|
||||
credential = ProviderCredential(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
credential_name=credential_name,
|
||||
encrypted_config="{}",
|
||||
)
|
||||
session.add(credential)
|
||||
session.commit()
|
||||
return credential
|
||||
|
||||
|
||||
def _persist_model_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
credential_name: str = "ModelCredName",
|
||||
) -> ProviderModelCredential:
|
||||
credential = ProviderModelCredential(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
credential_name=credential_name,
|
||||
encrypted_config="{}",
|
||||
)
|
||||
session.add(credential)
|
||||
session.commit()
|
||||
return credential
|
||||
|
||||
|
||||
def _persist_provider(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
credential_id: str | None,
|
||||
) -> Provider:
|
||||
provider = Provider(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
provider_type=ProviderType.CUSTOM,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
session.add(provider)
|
||||
session.commit()
|
||||
return provider
|
||||
|
||||
|
||||
def _persist_provider_model(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
credential_id: str | None,
|
||||
) -> ProviderModel:
|
||||
model = ProviderModel(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
session.add(model)
|
||||
session.commit()
|
||||
return model
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _raise_on_table(engine: Engine, table_name: str) -> Iterator[None]:
|
||||
"""Raise only when SQL targets the named table, leaving other real lookups intact."""
|
||||
|
||||
def fail_target_query(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||
if f"FROM {table_name}" in statement:
|
||||
raise RuntimeError(f"forced failure for {table_name}")
|
||||
|
||||
event.listen(engine, "before_cursor_execute", fail_target_query)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", fail_target_query)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -47,62 +223,42 @@ def _make_db_and_session_patches(scalar_side_effect=None, scalar_return_value=No
|
||||
class TestLookupAppAndWorkspaceNames:
|
||||
"""Tests for _lookup_app_and_workspace_names(app_id, tenant_id)."""
|
||||
|
||||
def test_both_found(self):
|
||||
def test_both_found(self, orm_session: Session):
|
||||
"""Returns (app_name, workspace_name) when both records exist."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=["MyApp", "MyWorkspace"])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
|
||||
tenant = _persist_tenant(orm_session)
|
||||
app = _persist_app(orm_session, tenant_id=tenant.id)
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(app.id, tenant.id)
|
||||
|
||||
assert app_name == "MyApp"
|
||||
assert workspace_name == "MyWorkspace"
|
||||
|
||||
def test_app_only_found(self):
|
||||
def test_app_only_found(self, orm_session: Session):
|
||||
"""Returns (app_name, '') when tenant record is absent."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=["MyApp", None])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
|
||||
app = _persist_app(orm_session, tenant_id=str(uuid.uuid4()))
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(app.id, str(uuid.uuid4()))
|
||||
|
||||
assert app_name == "MyApp"
|
||||
assert workspace_name == ""
|
||||
|
||||
def test_tenant_only_found(self):
|
||||
def test_tenant_only_found(self, orm_session: Session):
|
||||
"""Returns ('', workspace_name) when app record is absent."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[None, "MyWorkspace"])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
|
||||
tenant = _persist_tenant(orm_session)
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(str(uuid.uuid4()), tenant.id)
|
||||
|
||||
assert app_name == ""
|
||||
assert workspace_name == "MyWorkspace"
|
||||
|
||||
def test_neither_found(self):
|
||||
def test_neither_found(self, orm_session: Session):
|
||||
"""Returns ('', '') when both DB lookups return None."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[None, None])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(str(uuid.uuid4()), str(uuid.uuid4()))
|
||||
|
||||
assert app_name == ""
|
||||
assert workspace_name == ""
|
||||
@@ -111,50 +267,30 @@ class TestLookupAppAndWorkspaceNames:
|
||||
"""Returns ('', '') immediately when both IDs are None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(None, None)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(None, None)
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert app_name == ""
|
||||
assert workspace_name == ""
|
||||
|
||||
def test_app_id_none_only_queries_tenant(self):
|
||||
def test_app_id_none_only_queries_tenant(self, orm_session: Session):
|
||||
"""When app_id is None, only the tenant query is issued."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="OnlyWorkspace")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(None, "tenant-456")
|
||||
tenant = _persist_tenant(orm_session, name="OnlyWorkspace")
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(None, tenant.id)
|
||||
|
||||
assert app_name == ""
|
||||
assert workspace_name == "OnlyWorkspace"
|
||||
assert session.scalar.call_count == 1
|
||||
|
||||
def test_tenant_id_none_only_queries_app(self):
|
||||
def test_tenant_id_none_only_queries_app(self, orm_session: Session):
|
||||
"""When tenant_id is None, only the app query is issued."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="OnlyApp")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", None)
|
||||
app = _persist_app(orm_session, tenant_id=str(uuid.uuid4()), name="OnlyApp")
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(app.id, None)
|
||||
|
||||
assert app_name == "OnlyApp"
|
||||
assert workspace_name == ""
|
||||
assert session.scalar.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -166,32 +302,20 @@ class TestLookupCredentialName:
|
||||
"""Tests for _lookup_credential_name(credential_id, provider_type)."""
|
||||
|
||||
@pytest.mark.parametrize("provider_type", ["builtin", "plugin", "api", "workflow", "mcp"])
|
||||
def test_known_provider_types_return_name(self, provider_type):
|
||||
def test_known_provider_types_return_name(self, provider_type: str, orm_session: Session):
|
||||
"""Each valid provider_type results in a DB query and returns the credential name."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="CredentialA")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
result = _lookup_credential_name("cred-123", provider_type)
|
||||
provider = _persist_tool_provider(orm_session, provider_type)
|
||||
result = _lookup_credential_name(provider.id, provider_type)
|
||||
|
||||
assert result == "CredentialA"
|
||||
session.scalar.assert_called_once()
|
||||
|
||||
def test_credential_not_found_returns_empty_string(self):
|
||||
def test_credential_not_found_returns_empty_string(self, orm_session: Session):
|
||||
"""Returns '' when DB yields None for the given credential_id."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_return_value=None)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
result = _lookup_credential_name("cred-999", "api")
|
||||
result = _lookup_credential_name(str(uuid.uuid4()), "api")
|
||||
|
||||
assert result == ""
|
||||
|
||||
@@ -199,48 +323,24 @@ class TestLookupCredentialName:
|
||||
"""Returns '' immediately for an unrecognised provider_type — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
result = _lookup_credential_name(str(uuid.uuid4()), "unknown_type")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
result = _lookup_credential_name("cred-123", "unknown_type")
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert result == ""
|
||||
|
||||
def test_none_credential_id_returns_empty_string_without_db(self):
|
||||
"""Returns '' immediately when credential_id is None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
result = _lookup_credential_name(None, "api")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
result = _lookup_credential_name(None, "api")
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert result == ""
|
||||
|
||||
def test_none_provider_type_returns_empty_string_without_db(self):
|
||||
"""Returns '' immediately when provider_type is None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
result = _lookup_credential_name(str(uuid.uuid4()), None)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
result = _lookup_credential_name("cred-123", None)
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert result == ""
|
||||
|
||||
def test_builtin_and_plugin_map_to_same_model(self):
|
||||
@@ -281,106 +381,78 @@ class TestLookupCredentialName:
|
||||
class TestLookupLlmCredentialInfo:
|
||||
"""Tests for _lookup_llm_credential_info(tenant_id, provider, model, model_type)."""
|
||||
|
||||
def _provider_record(self, credential_id: str | None = None) -> MagicMock:
|
||||
record = MagicMock()
|
||||
record.credential_id = credential_id
|
||||
return record
|
||||
|
||||
def _model_record(self, credential_id: str | None = None) -> MagicMock:
|
||||
record = MagicMock()
|
||||
record.credential_id = credential_id
|
||||
return record
|
||||
|
||||
def test_model_level_credential_found(self):
|
||||
def test_model_level_credential_found(self, orm_session: Session):
|
||||
"""Returns model-level credential_id and name when ProviderModel has a credential."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id=None)
|
||||
model_record = self._model_record(credential_id="model-cred-id")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
model_credential = _persist_model_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=None)
|
||||
_persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=model_credential.id)
|
||||
|
||||
# scalar calls: (1) Provider, (2) ProviderModel, (3) ProviderModelCredential.credential_name
|
||||
mock_db, cm, _session = _make_db_and_session_patches(
|
||||
scalar_side_effect=[provider_record, model_record, "ModelCredName"]
|
||||
decoy_tenant_id = str(uuid.uuid4())
|
||||
decoy_credential = _persist_model_credential(
|
||||
orm_session,
|
||||
tenant_id=decoy_tenant_id,
|
||||
credential_name="WrongTenantCredential",
|
||||
)
|
||||
_persist_provider(orm_session, tenant_id=decoy_tenant_id, credential_id=None)
|
||||
_persist_provider_model(orm_session, tenant_id=decoy_tenant_id, credential_id=decoy_credential.id)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
assert cred_id == "model-cred-id"
|
||||
assert cred_id == model_credential.id
|
||||
assert cred_name == "ModelCredName"
|
||||
|
||||
def test_provider_level_fallback_when_no_model_credential(self):
|
||||
def test_provider_level_fallback_when_no_model_credential(self, orm_session: Session):
|
||||
"""Falls back to provider-level credential when ProviderModel has no credential_id."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id="prov-cred-id")
|
||||
model_record = self._model_record(credential_id=None)
|
||||
tenant_id = str(uuid.uuid4())
|
||||
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
|
||||
_persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=None)
|
||||
|
||||
# scalar calls: (1) Provider, (2) ProviderModel (no cred), (3) ProviderCredential.credential_name
|
||||
mock_db, cm, _session = _make_db_and_session_patches(
|
||||
scalar_side_effect=[provider_record, model_record, "ProvCredName"]
|
||||
)
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
|
||||
assert cred_id == "prov-cred-id"
|
||||
assert cred_id == provider_credential.id
|
||||
assert cred_name == "ProvCredName"
|
||||
|
||||
def test_provider_level_fallback_when_no_model_record(self):
|
||||
def test_provider_level_fallback_when_no_model_record(self, orm_session: Session):
|
||||
"""Falls back to provider-level credential when no ProviderModel row exists."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id="prov-cred-id")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
|
||||
|
||||
# scalar calls: (1) Provider, (2) ProviderModel → None, (3) ProviderCredential.credential_name
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[provider_record, None, "ProvCredName"])
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
|
||||
assert cred_id == "prov-cred-id"
|
||||
assert cred_id == provider_credential.id
|
||||
assert cred_name == "ProvCredName"
|
||||
|
||||
def test_no_model_arg_uses_provider_level_only(self):
|
||||
def test_no_model_arg_uses_provider_level_only(self, orm_session: Session):
|
||||
"""When model is None, skips ProviderModel query and uses provider credential."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id="prov-cred-id")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
|
||||
|
||||
# scalar calls: (1) Provider, (2) ProviderCredential.credential_name — no ProviderModel
|
||||
mock_db, cm, session = _make_db_and_session_patches(scalar_side_effect=[provider_record, "ProvCredName"])
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", None)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", None)
|
||||
|
||||
assert cred_id == "prov-cred-id"
|
||||
assert cred_id == provider_credential.id
|
||||
assert cred_name == "ProvCredName"
|
||||
assert session.scalar.call_count == 2
|
||||
|
||||
def test_provider_not_found_returns_none_and_empty(self):
|
||||
def test_provider_not_found_returns_none_and_empty(self, orm_session: Session):
|
||||
"""Returns (None, '') when Provider record does not exist."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_return_value=None)
|
||||
other_tenant_id = str(uuid.uuid4())
|
||||
_persist_provider(orm_session, tenant_id=other_tenant_id, credential_id=None)
|
||||
tenant_id = str(uuid.uuid4())
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
@@ -389,16 +461,8 @@ class TestLookupLlmCredentialInfo:
|
||||
"""Returns (None, '') immediately when tenant_id is None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
cred_id, cred_name = _lookup_llm_credential_info(None, "openai", "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info(None, "openai", "gpt-4")
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
|
||||
@@ -406,69 +470,46 @@ class TestLookupLlmCredentialInfo:
|
||||
"""Returns (None, '') immediately when provider is None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
cred_id, cred_name = _lookup_llm_credential_info(str(uuid.uuid4()), None, "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", None, "gpt-4")
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
|
||||
def test_db_error_on_outer_query_returns_none_and_empty(self):
|
||||
def test_db_error_on_outer_query_returns_none_and_empty(self, orm_session: Session, sqlite_engine: Engine):
|
||||
"""Returns (None, '') and logs a warning when the outer DB query raises."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
mock_db, cm, session = _make_db_and_session_patches()
|
||||
session.scalar.side_effect = Exception("DB connection failed")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
with _raise_on_table(sqlite_engine, "providers"):
|
||||
cred_id, cred_name = _lookup_llm_credential_info(str(uuid.uuid4()), "openai", "gpt-4")
|
||||
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
|
||||
def test_credential_name_lookup_failure_returns_id_with_empty_name(self):
|
||||
def test_credential_name_lookup_failure_returns_id_with_empty_name(
|
||||
self, orm_session: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""When credential name sub-query fails, returns cred_id but '' for name."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id="prov-cred-id")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
|
||||
|
||||
# Provider found, no model record, then name lookup raises
|
||||
mock_db, cm, _session = _make_db_and_session_patches(
|
||||
scalar_side_effect=[provider_record, None, Exception("deleted")]
|
||||
)
|
||||
with _raise_on_table(sqlite_engine, "provider_credentials"):
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
|
||||
assert cred_id == "prov-cred-id"
|
||||
assert cred_id == provider_credential.id
|
||||
assert cred_name == ""
|
||||
|
||||
def test_no_credential_on_provider_or_model_returns_none_id(self):
|
||||
def test_no_credential_on_provider_or_model_returns_none_id(self, orm_session: Session):
|
||||
"""Returns (None, '') when neither provider nor model has a credential_id."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id=None)
|
||||
model_record = self._model_record(credential_id=None)
|
||||
tenant_id = str(uuid.uuid4())
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=None)
|
||||
_persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=None)
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[provider_record, model_record])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
|
||||
@@ -2,9 +2,21 @@ from types import SimpleNamespace
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import core.rag.extractor.excel_extractor as excel_module
|
||||
from core.rag.extractor.excel_extractor import ExcelExtractor
|
||||
from models.base import TypeBase
|
||||
from models.model import UploadFile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database_session_maker(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> sessionmaker[Session]:
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[UploadFile.__table__])
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(excel_module.session_factory, "create_session", session_maker)
|
||||
return session_maker
|
||||
|
||||
|
||||
class _FakeCell:
|
||||
@@ -58,82 +70,22 @@ class _FakeImage:
|
||||
return self._raw_data
|
||||
|
||||
|
||||
class _FieldExpression:
|
||||
def __eq__(self, other):
|
||||
return ("eq", other)
|
||||
|
||||
def in_(self, values):
|
||||
return ("in", tuple(values))
|
||||
|
||||
|
||||
class _SelectStub:
|
||||
def where(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
|
||||
class _FakeUploadFile:
|
||||
tenant_id = _FieldExpression()
|
||||
key = _FieldExpression()
|
||||
_i = 0
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
type(self)._i += 1
|
||||
self.id = f"u{self._i}"
|
||||
self.key = kwargs["key"]
|
||||
|
||||
|
||||
class _PersistentSession:
|
||||
def __init__(self, persisted):
|
||||
self._persisted = persisted
|
||||
self.added = []
|
||||
self.commit_count = 0
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return SimpleNamespace(all=lambda: list(self._persisted.values()))
|
||||
|
||||
def add_all(self, objects) -> None:
|
||||
self.added.extend(objects)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commit_count += 1
|
||||
for upload_file in self.added:
|
||||
self._persisted[upload_file.key] = upload_file
|
||||
self.added.clear()
|
||||
|
||||
|
||||
class _PersistentSessionFactory:
|
||||
def __init__(self):
|
||||
self.persisted = {}
|
||||
self.sessions = []
|
||||
|
||||
def create_session(self):
|
||||
session = _PersistentSession(self.persisted)
|
||||
self.sessions.append(session)
|
||||
return session
|
||||
|
||||
|
||||
def _patch_image_persistence(monkeypatch: pytest.MonkeyPatch):
|
||||
saves: list[tuple[str, bytes]] = []
|
||||
session_factory = _PersistentSessionFactory()
|
||||
|
||||
def save(key: str, data: bytes) -> None:
|
||||
saves.append((key, data))
|
||||
|
||||
_FakeUploadFile._i = 0
|
||||
monkeypatch.setattr(excel_module, "storage", SimpleNamespace(save=save))
|
||||
monkeypatch.setattr(excel_module, "session_factory", session_factory)
|
||||
monkeypatch.setattr(excel_module, "select", lambda *args, **kwargs: _SelectStub())
|
||||
monkeypatch.setattr(excel_module, "UploadFile", _FakeUploadFile)
|
||||
monkeypatch.setattr(excel_module.storage, "save", save)
|
||||
monkeypatch.setattr(excel_module.dify_config, "FILES_URL", "http://files.local", raising=False)
|
||||
monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False)
|
||||
|
||||
return saves, session_factory
|
||||
return saves
|
||||
|
||||
|
||||
def _get_upload_files(session_maker: sessionmaker[Session]) -> list[UploadFile]:
|
||||
with session_maker() as session:
|
||||
return list(session.scalars(select(UploadFile)).all())
|
||||
|
||||
|
||||
class TestExcelExtractor:
|
||||
@@ -160,7 +112,11 @@ class TestExcelExtractor:
|
||||
assert docs[1].page_content == '"Name":"";"Link":"123"'
|
||||
assert all(doc.metadata["source"] == "/tmp/sample.xlsx" for doc in docs)
|
||||
|
||||
def test_extract_xlsx_turns_embedded_images_into_markdown_links(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_extract_xlsx_turns_embedded_images_into_markdown_links(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nexcel-image"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -175,7 +131,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -184,23 +140,30 @@ class TestExcelExtractor:
|
||||
source_file_id="source-file-1",
|
||||
)
|
||||
docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
|
||||
assert workbook.closed is True
|
||||
assert len(docs) == 2
|
||||
assert len(upload_files) == 1
|
||||
assert docs[0].page_content == (
|
||||
'"Question":"Q1";"Answer":"A1";'
|
||||
'"Image":" '
|
||||
'"'
|
||||
f'"Image":" '
|
||||
f'"'
|
||||
)
|
||||
assert docs[1].page_content == '"Question":"Q2";"Answer":"A2";"Image":""'
|
||||
assert len(saves) == 1
|
||||
assert saves[0][0].startswith("image_files/tenant-1/source-file-1/")
|
||||
assert saves[0][0].endswith(".png")
|
||||
assert saves[0][1] == image_bytes
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1]
|
||||
assert upload_files[0].tenant_id == "tenant-1"
|
||||
assert upload_files[0].key == saves[0][0]
|
||||
assert upload_files[0].used is True
|
||||
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nimage-only-row"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -212,7 +175,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -221,17 +184,21 @@ class TestExcelExtractor:
|
||||
source_file_id="source-file-1",
|
||||
)
|
||||
docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
|
||||
assert workbook.closed is True
|
||||
assert len(docs) == 1
|
||||
assert len(upload_files) == 1
|
||||
assert docs[0].page_content == (
|
||||
'"Question":"";"Answer":"";"Image":""'
|
||||
f'"Question":"";"Answer":"";"Image":""'
|
||||
)
|
||||
assert len(saves) == 1
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1]
|
||||
|
||||
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nretry-safe-image"
|
||||
workbooks = [
|
||||
_FakeWorkbook(
|
||||
@@ -254,7 +221,7 @@ class TestExcelExtractor:
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbooks.pop(0))
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -264,16 +231,17 @@ class TestExcelExtractor:
|
||||
)
|
||||
first_docs = extractor.extract()
|
||||
second_docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
assert len(upload_files) == 1
|
||||
|
||||
expected_page_content = (
|
||||
'"Question":"Q1";"Answer":"A1";"Image":""'
|
||||
'"Question":"Q1";"Answer":"A1";'
|
||||
f'"Image":""'
|
||||
)
|
||||
|
||||
assert first_docs[0].page_content == expected_page_content
|
||||
assert second_docs[0].page_content == expected_page_content
|
||||
assert len(saves) == 1
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1, 0]
|
||||
|
||||
def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
|
||||
class FakeExcelFile:
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Unit tests for workflow-as-tool behavior.
|
||||
|
||||
StubSession/StubScalars emulate SQLAlchemy session/scalars with minimal methods
|
||||
(`scalar`, `scalars`, `expunge`, `commit`, `refresh`, context manager) to keep
|
||||
database access mocked and predictable in tests.
|
||||
"""
|
||||
"""Unit tests for workflow-as-tool behavior with real SQLite ORM boundaries."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, inspect
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
@@ -23,74 +23,142 @@ from core.tools.entities.tool_entities import (
|
||||
ToolProviderType,
|
||||
)
|
||||
from core.tools.errors import ToolInvokeError
|
||||
from core.tools.workflow_as_tool import tool as workflow_tool_module
|
||||
from core.tools.workflow_as_tool.tool import WorkflowTool
|
||||
from graphon.file import FILE_MODEL_IDENTITY, FileTransferMethod, FileType
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.base import TypeBase
|
||||
from models.enums import EndUserType
|
||||
from models.model import App, AppMode, EndUser
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
|
||||
TENANT_ID = "00000000-0000-0000-0000-000000000001"
|
||||
OTHER_TENANT_ID = "00000000-0000-0000-0000-000000000002"
|
||||
APP_ID = "00000000-0000-0000-0000-000000000003"
|
||||
ACCOUNT_ID = "00000000-0000-0000-0000-000000000004"
|
||||
END_USER_ID = "00000000-0000-0000-0000-000000000005"
|
||||
CREATOR_ID = "00000000-0000-0000-0000-000000000006"
|
||||
|
||||
|
||||
class StubScalars:
|
||||
"""Minimal stub for SQLAlchemy scalar results."""
|
||||
|
||||
_value: Any
|
||||
|
||||
def __init__(self, value: Any) -> None:
|
||||
self._value = value
|
||||
|
||||
def first(self) -> Any:
|
||||
return self._value
|
||||
@dataclass(frozen=True)
|
||||
class SqliteToolDb:
|
||||
engine: Engine
|
||||
session_maker: sessionmaker[Session]
|
||||
caller_session: Session
|
||||
|
||||
|
||||
class StubSession:
|
||||
"""Minimal stub for session_factory-created sessions."""
|
||||
@pytest.fixture
|
||||
def sqlite_tool_db(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
) -> Iterator[SqliteToolDb]:
|
||||
"""Bind service-owned sessions and Account tenant reloads to SQLite."""
|
||||
models = (App, Workflow, EndUser, Account, Tenant, TenantAccountJoin)
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[model.__table__ for model in models])
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(workflow_tool_module.session_factory, "create_session", session_maker)
|
||||
|
||||
scalar_results: list[Any]
|
||||
scalars_results: list[Any]
|
||||
expunge_calls: list[object]
|
||||
from models import account as account_module
|
||||
|
||||
def __init__(self, *, scalar_results: list[Any] | None = None, scalars_results: list[Any] | None = None) -> None:
|
||||
self.scalar_results = list(scalar_results or [])
|
||||
self.scalars_results = list(scalars_results or [])
|
||||
self.expunge_calls: list[object] = []
|
||||
|
||||
def scalar(self, _stmt: Any) -> Any:
|
||||
return self.scalar_results.pop(0)
|
||||
|
||||
def scalars(self, _stmt: Any) -> StubScalars:
|
||||
return StubScalars(self.scalars_results.pop(0))
|
||||
|
||||
def expunge(self, value: Any) -> None:
|
||||
self.expunge_calls.append(value)
|
||||
|
||||
def begin(self) -> "StubSession":
|
||||
return self
|
||||
|
||||
def commit(self) -> None:
|
||||
pass
|
||||
|
||||
def refresh(self, _value: Any) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "StubSession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
||||
return False
|
||||
monkeypatch.setattr(account_module, "db", SimpleNamespace(engine=sqlite_engine))
|
||||
with session_maker() as caller_session:
|
||||
yield SqliteToolDb(engine=sqlite_engine, session_maker=session_maker, caller_session=caller_session)
|
||||
|
||||
|
||||
def _build_tool() -> WorkflowTool:
|
||||
def _persist_tenant(db: SqliteToolDb, *, tenant_id: str = TENANT_ID) -> Tenant:
|
||||
tenant = Tenant(name="Tenant")
|
||||
tenant.id = tenant_id
|
||||
db.caller_session.add(tenant)
|
||||
db.caller_session.commit()
|
||||
return tenant
|
||||
|
||||
|
||||
def _persist_account(db: SqliteToolDb, *, tenant_id: str = TENANT_ID) -> Account:
|
||||
account = Account(name="Account", email="[email protected]")
|
||||
account.id = ACCOUNT_ID
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
current=True,
|
||||
role=TenantAccountRole.NORMAL,
|
||||
)
|
||||
db.caller_session.add_all([account, join])
|
||||
db.caller_session.commit()
|
||||
return account
|
||||
|
||||
|
||||
def _persist_end_user(
|
||||
db: SqliteToolDb,
|
||||
*,
|
||||
end_user_id: str = END_USER_ID,
|
||||
tenant_id: str = TENANT_ID,
|
||||
) -> EndUser:
|
||||
end_user = EndUser(
|
||||
id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
app_id=APP_ID,
|
||||
type=EndUserType.SERVICE_API,
|
||||
name="End user",
|
||||
session_id="end-user-session",
|
||||
)
|
||||
db.caller_session.add(end_user)
|
||||
db.caller_session.commit()
|
||||
return end_user
|
||||
|
||||
|
||||
def _persist_app(db: SqliteToolDb) -> App:
|
||||
app = App(
|
||||
id=APP_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
name="Workflow app",
|
||||
description="",
|
||||
mode=AppMode.WORKFLOW,
|
||||
icon_type=None,
|
||||
icon="",
|
||||
icon_background=None,
|
||||
app_model_config_id=None,
|
||||
workflow_id=None,
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
created_by=CREATOR_ID,
|
||||
)
|
||||
db.caller_session.add(app)
|
||||
db.caller_session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _persist_workflow(db: SqliteToolDb, *, version: str, workflow_id: str | None = None) -> Workflow:
|
||||
workflow = Workflow.new(
|
||||
tenant_id=TENANT_ID,
|
||||
app_id=APP_ID,
|
||||
type=WorkflowType.WORKFLOW.value,
|
||||
version=version,
|
||||
graph=json.dumps({"nodes": [], "edges": []}),
|
||||
features="{}",
|
||||
created_by=CREATOR_ID,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
workflow.id = workflow_id or str(uuid.uuid4())
|
||||
db.caller_session.add(workflow)
|
||||
db.caller_session.commit()
|
||||
return workflow
|
||||
|
||||
|
||||
def _build_tool(*, tenant_id: str = "test_tool", workflow_app_id: str = "app-1", version: str = "1") -> WorkflowTool:
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE)
|
||||
runtime = ToolRuntime(tenant_id=tenant_id, invoke_from=InvokeFrom.EXPLORE)
|
||||
return WorkflowTool(
|
||||
workflow_app_id="app-1",
|
||||
workflow_app_id=workflow_app_id,
|
||||
workflow_as_tool_id="wf-tool-1",
|
||||
version="1",
|
||||
version=version,
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
@@ -98,7 +166,10 @@ def _build_tool() -> WorkflowTool:
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure that WorkflowTool will throw a `ToolInvokeError` exception when
|
||||
`WorkflowAppGenerator.generate` returns a result with `error` key inside
|
||||
the `data` element.
|
||||
@@ -122,11 +193,14 @@ def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_fiel
|
||||
with pytest.raises(ToolInvokeError) as exc_info:
|
||||
# WorkflowTool always returns a generator, so we need to iterate to
|
||||
# actually `run` the tool.
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
assert exc_info.value.args == ("oops",)
|
||||
|
||||
|
||||
def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_does_not_use_pause_state_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure pause_state_config is passed as None."""
|
||||
tool = _build_tool()
|
||||
|
||||
@@ -140,14 +214,17 @@ def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.Monke
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert "pause_state_config" in call_kwargs
|
||||
assert call_kwargs["pause_state_config"] is None
|
||||
|
||||
|
||||
def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_passes_parent_trace_context_from_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure nested workflow runtime metadata is forwarded as parent trace context."""
|
||||
tool = _build_tool()
|
||||
tool.set_parent_trace_context(
|
||||
@@ -165,7 +242,7 @@ def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pyt
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert call_kwargs["args"]["parent_trace_context"].model_dump() == {
|
||||
@@ -174,7 +251,10 @@ def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pyt
|
||||
}
|
||||
|
||||
|
||||
def test_workflow_tool_passes_parent_trace_session_id(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_passes_parent_trace_session_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure nested workflows inherit the parent observability session ID."""
|
||||
tool = _build_tool()
|
||||
tool.entity.parameters = [
|
||||
@@ -197,14 +277,17 @@ def test_workflow_tool_passes_parent_trace_session_id(monkeypatch: pytest.Monkey
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {"trace_session_id": "user-input-session"}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {"trace_session_id": "user-input-session"}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert call_kwargs["args"]["inputs"]["trace_session_id"] == "user-input-session"
|
||||
assert call_kwargs["args"]["trace_session_id"] == "session-1"
|
||||
|
||||
|
||||
def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure private trace context does not overwrite same-named workflow inputs."""
|
||||
tool = _build_tool()
|
||||
tool.entity.parameters = [
|
||||
@@ -238,7 +321,7 @@ def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypat
|
||||
|
||||
list(
|
||||
tool.invoke(
|
||||
MagicMock(),
|
||||
sqlite_tool_db.caller_session,
|
||||
"test_user",
|
||||
{
|
||||
"outer_workflow_run_id": "user-workflow-input",
|
||||
@@ -256,7 +339,10 @@ def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypat
|
||||
}
|
||||
|
||||
|
||||
def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_can_clear_parent_trace_context(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure reused WorkflowTool instances do not keep stale parent trace context."""
|
||||
tool = _build_tool()
|
||||
tool.set_parent_trace_context(
|
||||
@@ -275,13 +361,16 @@ def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.Monkey
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert "parent_trace_context" not in call_kwargs["args"]
|
||||
|
||||
|
||||
def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_can_clear_trace_session_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure reused WorkflowTool instances do not keep stale trace session IDs."""
|
||||
tool = _build_tool()
|
||||
tool.set_trace_session_id("session-1")
|
||||
@@ -297,7 +386,7 @@ def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatc
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert "trace_session_id" not in call_kwargs["args"]
|
||||
@@ -315,6 +404,7 @@ def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatc
|
||||
def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
runtime_parameters: dict[str, Any],
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure incomplete runtime metadata does not leak parent trace context into generator args."""
|
||||
tool = _build_tool()
|
||||
@@ -330,13 +420,16 @@ def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete(
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert "parent_trace_context" not in call_kwargs["args"]
|
||||
|
||||
|
||||
def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_should_generate_variable_messages_for_outputs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Test that WorkflowTool should generate variable messages when there are outputs"""
|
||||
tool = _build_tool()
|
||||
|
||||
@@ -359,7 +452,7 @@ def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
# Execute tool invocation
|
||||
messages = list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
messages = list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
# Verify variable messages
|
||||
variable_messages = [msg for msg in messages if msg.type == ToolInvokeMessage.MessageType.VARIABLE]
|
||||
@@ -382,7 +475,10 @@ def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch
|
||||
assert json_messages[0].message.json_object == mock_outputs
|
||||
|
||||
|
||||
def test_workflow_tool_should_handle_empty_outputs(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_should_handle_empty_outputs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Test that WorkflowTool should handle empty outputs correctly"""
|
||||
tool = _build_tool()
|
||||
|
||||
@@ -402,7 +498,7 @@ def test_workflow_tool_should_handle_empty_outputs(monkeypatch: pytest.MonkeyPat
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
# Execute tool invocation
|
||||
messages = list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
messages = list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
# Verify generated messages
|
||||
# Should contain: 0 variable messages + 1 text message + 1 JSON message = 2 messages
|
||||
@@ -458,41 +554,32 @@ def test_create_file_message_should_include_file_marker():
|
||||
assert message.meta == {"file": file_obj}
|
||||
|
||||
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(sqlite_tool_db: SqliteToolDb):
|
||||
"""Ensure worker context can resolve EndUser when Account is missing."""
|
||||
|
||||
tenant = SimpleNamespace(id="tenant_id")
|
||||
end_user = SimpleNamespace(id="end_user_id", tenant_id="tenant_id")
|
||||
|
||||
# Monkeypatch session factory to return our stub session
|
||||
stub_session = StubSession(scalar_results=[tenant, None, end_user])
|
||||
monkeypatch.setattr(
|
||||
"core.tools.workflow_as_tool.tool.session_factory.create_session",
|
||||
lambda: stub_session,
|
||||
_persist_tenant(sqlite_tool_db)
|
||||
end_user = _persist_end_user(sqlite_tool_db)
|
||||
other_tenant_end_user = _persist_end_user(
|
||||
sqlite_tool_db,
|
||||
end_user_id="00000000-0000-0000-0000-000000000007",
|
||||
tenant_id=OTHER_TENANT_ID,
|
||||
)
|
||||
|
||||
tool = _build_tool()
|
||||
tool = _build_tool(tenant_id=TENANT_ID)
|
||||
tool.runtime.invoke_from = InvokeFrom.SERVICE_API
|
||||
tool.runtime.tenant_id = "tenant_id"
|
||||
|
||||
resolved_user = tool._resolve_user_from_database(user_id=end_user.id)
|
||||
|
||||
assert resolved_user is end_user
|
||||
assert stub_session.expunge_calls == [end_user]
|
||||
assert isinstance(resolved_user, EndUser)
|
||||
assert resolved_user.id == end_user.id
|
||||
assert resolved_user.tenant_id == TENANT_ID
|
||||
assert inspect(resolved_user).detached is True
|
||||
assert tool._resolve_user_from_database(user_id=other_tenant_end_user.id) is None
|
||||
|
||||
|
||||
def test_resolve_user_from_database_returns_none_when_no_tenant(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_resolve_user_from_database_returns_none_when_no_tenant(sqlite_tool_db: SqliteToolDb):
|
||||
"""Return None if tenant cannot be found in worker context."""
|
||||
|
||||
# Monkeypatch session factory to return our stub session with no tenant
|
||||
monkeypatch.setattr(
|
||||
"core.tools.workflow_as_tool.tool.session_factory.create_session",
|
||||
lambda: StubSession(scalar_results=[None]),
|
||||
)
|
||||
|
||||
tool = _build_tool()
|
||||
tool = _build_tool(tenant_id=OTHER_TENANT_ID)
|
||||
tool.runtime.invoke_from = InvokeFrom.SERVICE_API
|
||||
tool.runtime.tenant_id = "missing_tenant"
|
||||
|
||||
resolved_user = tool._resolve_user_from_database(user_id="any")
|
||||
|
||||
@@ -544,7 +631,10 @@ def test_extract_usage_from_nested():
|
||||
assert nested == {"total_tokens": 3}
|
||||
|
||||
|
||||
def test_invoke_raises_when_user_not_found(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_invoke_raises_when_user_not_found(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Raise ToolInvokeError when user resolution fails."""
|
||||
tool = _build_tool()
|
||||
monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None)
|
||||
@@ -552,58 +642,45 @@ def test_invoke_raises_when_user_not_found(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: None)
|
||||
|
||||
with pytest.raises(ToolInvokeError, match="User not found"):
|
||||
list(tool.invoke(MagicMock(), "missing", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "missing", {}))
|
||||
|
||||
|
||||
def test_resolve_user_from_database_returns_account(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_resolve_user_from_database_returns_account(sqlite_tool_db: SqliteToolDb):
|
||||
"""Resolve Account and set tenant in worker context."""
|
||||
tenant = SimpleNamespace(id="tenant_id")
|
||||
account = SimpleNamespace(id="account_id", current_tenant=None)
|
||||
set_current_tenant = Mock(side_effect=lambda tenant, *, session: setattr(account, "current_tenant", tenant))
|
||||
account.set_current_tenant_with_session = set_current_tenant
|
||||
session = StubSession(scalar_results=[tenant, account])
|
||||
tenant = _persist_tenant(sqlite_tool_db)
|
||||
account = _persist_account(sqlite_tool_db)
|
||||
tool = _build_tool(tenant_id=TENANT_ID)
|
||||
|
||||
monkeypatch.setattr("core.tools.workflow_as_tool.tool.session_factory.create_session", lambda: session)
|
||||
tool = _build_tool()
|
||||
tool.runtime.tenant_id = "tenant_id"
|
||||
|
||||
resolved = tool._resolve_user_from_database(user_id="account_id")
|
||||
assert resolved is account
|
||||
assert account.current_tenant is tenant
|
||||
set_current_tenant.assert_called_once_with(tenant, session=session)
|
||||
assert session.expunge_calls == [account]
|
||||
resolved = tool._resolve_user_from_database(user_id=account.id)
|
||||
assert isinstance(resolved, Account)
|
||||
assert resolved.id == account.id
|
||||
assert resolved.current_tenant_id == tenant.id
|
||||
assert inspect(resolved).detached is True
|
||||
|
||||
|
||||
def test_get_workflow_and_get_app_db_branches(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_get_workflow_and_get_app_db_branches(sqlite_tool_db: SqliteToolDb):
|
||||
"""Cover workflow/app retrieval branches and error cases."""
|
||||
tool = _build_tool()
|
||||
latest_workflow = SimpleNamespace(id="wf-latest")
|
||||
specific_workflow = SimpleNamespace(id="wf-v1")
|
||||
app = SimpleNamespace(id="app-1")
|
||||
sessions = iter(
|
||||
[
|
||||
StubSession(scalar_results=[], scalars_results=[latest_workflow]),
|
||||
StubSession(scalar_results=[specific_workflow], scalars_results=[]),
|
||||
StubSession(scalar_results=[app], scalars_results=[]),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.tools.workflow_as_tool.tool.session_factory.create_session",
|
||||
lambda: next(sessions),
|
||||
)
|
||||
app = _persist_app(sqlite_tool_db)
|
||||
specific_workflow = _persist_workflow(sqlite_tool_db, version="1")
|
||||
latest_workflow = _persist_workflow(sqlite_tool_db, version="2")
|
||||
_persist_workflow(sqlite_tool_db, version=Workflow.VERSION_DRAFT)
|
||||
tool = _build_tool(tenant_id=TENANT_ID, workflow_app_id=APP_ID)
|
||||
|
||||
assert tool._get_workflow("app-1", "") is latest_workflow
|
||||
assert tool._get_workflow("app-1", "1") is specific_workflow
|
||||
assert tool._get_app("app-1") is app
|
||||
latest = tool._get_workflow(APP_ID, "")
|
||||
specific = tool._get_workflow(APP_ID, "1")
|
||||
resolved_app = tool._get_app(APP_ID)
|
||||
|
||||
assert latest.id == latest_workflow.id
|
||||
assert specific.id == specific_workflow.id
|
||||
assert resolved_app.id == app.id
|
||||
assert inspect(latest).detached is True
|
||||
assert inspect(specific).detached is True
|
||||
assert inspect(resolved_app).detached is True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.tools.workflow_as_tool.tool.session_factory.create_session",
|
||||
lambda: StubSession(scalar_results=[None, None], scalars_results=[None]),
|
||||
)
|
||||
with pytest.raises(ValueError, match="workflow not found"):
|
||||
tool._get_workflow("app-1", "1")
|
||||
tool._get_workflow(APP_ID, "missing")
|
||||
with pytest.raises(ValueError, match="app not found"):
|
||||
tool._get_app("app-1")
|
||||
tool._get_app("00000000-0000-0000-0000-000000000099")
|
||||
|
||||
|
||||
def _setup_transform_args_tool(monkeypatch: pytest.MonkeyPatch) -> WorkflowTool:
|
||||
@@ -722,7 +799,10 @@ def test_transform_args_normalizes_optional_files_parameter(
|
||||
assert files == []
|
||||
|
||||
|
||||
def test_workflow_tool_invocation_normalizes_optional_files_parameter(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_invocation_normalizes_optional_files_parameter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure casted empty FILES values do not reach workflow input validation as [None]."""
|
||||
tool = _build_tool()
|
||||
images_param = ToolParameter.get_simple_instance(
|
||||
@@ -741,7 +821,7 @@ def test_workflow_tool_invocation_normalizes_optional_files_parameter(monkeypatc
|
||||
generate_mock = MagicMock(return_value={"data": {}})
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {"images": None}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {"images": None}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert call_kwargs["args"]["inputs"]["images"] == []
|
||||
|
||||
@@ -2,7 +2,16 @@ from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import (
|
||||
AIModelEntity,
|
||||
FetchFrom,
|
||||
ModelFeature,
|
||||
ModelPropertyKey,
|
||||
ModelType,
|
||||
ParameterRule,
|
||||
ParameterType,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_model_reuses_single_model_assembly():
|
||||
@@ -47,3 +56,98 @@ def test_fetch_model_reuses_single_model_assembly():
|
||||
model_type=ModelType.LLM,
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
|
||||
def _make_model_schema_with_defaults() -> AIModelEntity:
|
||||
"""Return a minimal AIModelEntity whose parameter_rules carry defaults."""
|
||||
return AIModelEntity(
|
||||
model="qwen-max",
|
||||
label=I18nObject(en_US="Qwen Max"),
|
||||
model_type=ModelType.LLM,
|
||||
features=[ModelFeature.AGENT_THOUGHT, ModelFeature.MULTI_TOOL_CALL],
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={
|
||||
ModelPropertyKey.MODE: "chat",
|
||||
ModelPropertyKey.CONTEXT_SIZE: 32768,
|
||||
},
|
||||
parameter_rules=[
|
||||
ParameterRule(
|
||||
name="temperature",
|
||||
use_template="temperature",
|
||||
label=I18nObject(en_US="Temperature"),
|
||||
type=ParameterType.FLOAT,
|
||||
required=False,
|
||||
default=0.7,
|
||||
min=0.0,
|
||||
max=2.0,
|
||||
precision=2,
|
||||
),
|
||||
ParameterRule(
|
||||
name="max_tokens",
|
||||
use_template="max_tokens",
|
||||
label=I18nObject(en_US="Max Tokens"),
|
||||
type=ParameterType.INT,
|
||||
required=False,
|
||||
default=2048,
|
||||
min=1,
|
||||
max=32768,
|
||||
),
|
||||
ParameterRule(
|
||||
name="top_p",
|
||||
use_template="top_p",
|
||||
label=I18nObject(en_US="Top P"),
|
||||
type=ParameterType.FLOAT,
|
||||
required=False,
|
||||
default=1.0,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_extract_default_completion_params_collects_rule_defaults():
|
||||
"""_extract_default_completion_params should gather every rule.default."""
|
||||
schema = _make_model_schema_with_defaults()
|
||||
params = AgentRuntimeSupport._extract_default_completion_params(schema)
|
||||
assert params == {"temperature": 0.7, "max_tokens": 2048, "top_p": 1.0}
|
||||
|
||||
|
||||
def test_extract_default_completion_params_skips_rules_without_default():
|
||||
"""Rules whose default is None must not appear in the result."""
|
||||
schema = AIModelEntity(
|
||||
model="test-model",
|
||||
label=I18nObject(en_US="Test"),
|
||||
model_type=ModelType.LLM,
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={ModelPropertyKey.MODE: "chat"},
|
||||
parameter_rules=[
|
||||
ParameterRule(
|
||||
name="seed",
|
||||
label=I18nObject(en_US="Seed"),
|
||||
type=ParameterType.INT,
|
||||
required=False,
|
||||
default=None,
|
||||
),
|
||||
ParameterRule(
|
||||
name="temperature",
|
||||
label=I18nObject(en_US="Temperature"),
|
||||
type=ParameterType.FLOAT,
|
||||
required=False,
|
||||
default=0.5,
|
||||
),
|
||||
],
|
||||
)
|
||||
params = AgentRuntimeSupport._extract_default_completion_params(schema)
|
||||
assert params == {"temperature": 0.5}
|
||||
|
||||
|
||||
def test_extract_default_completion_params_empty_when_no_defaults():
|
||||
"""An empty parameter_rules list yields an empty dict."""
|
||||
schema = AIModelEntity(
|
||||
model="test-model",
|
||||
label=I18nObject(en_US="Test"),
|
||||
model_type=ModelType.LLM,
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={ModelPropertyKey.MODE: "chat"},
|
||||
parameter_rules=[],
|
||||
)
|
||||
assert AgentRuntimeSupport._extract_default_completion_params(schema) == {}
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id
|
||||
from libs.helper import OptionalTimestampField, alphanumeric, email, escape_like_pattern, extract_tenant_id
|
||||
from models.account import Account
|
||||
from models.model import EndUser
|
||||
|
||||
@@ -153,3 +153,47 @@ class TestEmailValidator:
|
||||
def test_invalid_email_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid email"):
|
||||
email("not-an-email")
|
||||
|
||||
|
||||
class TestAlphanumericValidator:
|
||||
"""Tests for the alphanumeric() validator — regression for #39666."""
|
||||
|
||||
def test_valid_alphanumeric_accepted(self):
|
||||
assert alphanumeric("tool_name") == "tool_name"
|
||||
assert alphanumeric("Tool123") == "Tool123"
|
||||
assert alphanumeric("_underscore_start") == "_underscore_start"
|
||||
assert alphanumeric("a") == "a"
|
||||
|
||||
def test_trailing_newline_rejected(self):
|
||||
# re.match with $ accepts a trailing \n in Python; re.fullmatch does not.
|
||||
# This was the pre-fix behaviour: alphanumeric("tool\n") returned "tool\n".
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\n")
|
||||
|
||||
def test_trailing_carriage_return_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\r")
|
||||
|
||||
def test_trailing_crlf_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\r\n")
|
||||
|
||||
def test_leading_newline_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("\ntool_name")
|
||||
|
||||
def test_embedded_whitespace_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool name")
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("")
|
||||
|
||||
def test_special_characters_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool-name")
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool.name")
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool/name")
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
preset = "strict"
|
||||
project-includes = ["."]
|
||||
search-path = ["../.."]
|
||||
python-platform = "linux"
|
||||
python-version = "3.12.0"
|
||||
infer-with-first-use = true
|
||||
min-severity = "warn"
|
||||
|
||||
# Existing strict-mode debt. Remove a file when bringing it under strict checking.
|
||||
project-excludes = [
|
||||
"clients/agent_backend/test_cleanup_composition_compositor_integration.py",
|
||||
"clients/agent_backend/test_client.py",
|
||||
"clients/agent_backend/test_event_adapter.py",
|
||||
"clients/agent_backend/test_fake_client.py",
|
||||
"clients/agent_backend/test_request_builder.py",
|
||||
"clients/agent_backend/test_session_cleanup.py",
|
||||
"commands/test_archive_workflow_runs.py",
|
||||
"commands/test_clean_expired_messages.py",
|
||||
"commands/test_data_migration_commands.py",
|
||||
"commands/test_data_migration_wizard.py",
|
||||
"commands/test_fix_app_site_missing.py",
|
||||
"commands/test_generate_swagger_markdown_docs.py",
|
||||
"commands/test_generate_swagger_specs.py",
|
||||
"commands/test_legacy_model_type_migration.py",
|
||||
"commands/test_lint_response_contracts.py",
|
||||
"commands/test_reset_encrypt_key_pair.py",
|
||||
"commands/test_upgrade_db.py",
|
||||
"configs/test_dify_config.py",
|
||||
"configs/test_env_consistency.py",
|
||||
"configs/test_nacos_http_client.py",
|
||||
"conftest.py",
|
||||
"controllers/common/test_agent_app_parameters.py",
|
||||
"controllers/common/test_app_access.py",
|
||||
"controllers/common/test_errors.py",
|
||||
"controllers/common/test_fields.py",
|
||||
"controllers/common/test_file_response.py",
|
||||
"controllers/common/test_helpers.py",
|
||||
"controllers/common/test_schema.py",
|
||||
"controllers/common/test_session.py",
|
||||
"controllers/console/agent/test_agent_controllers.py",
|
||||
"controllers/console/app/test_agent_app_sandbox.py",
|
||||
"controllers/console/app/test_agent_config_inspector.py",
|
||||
"controllers/console/app/test_agent_drive_inspector.py",
|
||||
"controllers/console/app/test_agent_manage_guard.py",
|
||||
"controllers/console/app/test_agent_skills.py",
|
||||
"controllers/console/app/test_annotation_api.py",
|
||||
"controllers/console/app/test_annotation_security.py",
|
||||
"controllers/console/app/test_app_apis.py",
|
||||
"controllers/console/app/test_app_import_api.py",
|
||||
"controllers/console/app/test_app_response_models.py",
|
||||
"controllers/console/app/test_audio.py",
|
||||
"controllers/console/app/test_conversation_api.py",
|
||||
"controllers/console/app/test_conversation_variables_api.py",
|
||||
"controllers/console/app/test_create_app_payload.py",
|
||||
"controllers/console/app/test_description_validation.py",
|
||||
"controllers/console/app/test_generator_api.py",
|
||||
"controllers/console/app/test_generator_api_missing.py",
|
||||
"controllers/console/app/test_mcp_server_response.py",
|
||||
"controllers/console/app/test_message_api.py",
|
||||
"controllers/console/app/test_model_config_api.py",
|
||||
"controllers/console/app/test_ops_trace_api.py",
|
||||
"controllers/console/app/test_statistic_api.py",
|
||||
"controllers/console/app/test_workflow.py",
|
||||
"controllers/console/app/test_workflow_app_log_api.py",
|
||||
"controllers/console/app/test_workflow_comment_api.py",
|
||||
"controllers/console/app/test_workflow_convert_api.py",
|
||||
"controllers/console/app/test_workflow_node_output_inspector.py",
|
||||
"controllers/console/app/test_workflow_pause_details_api.py",
|
||||
"controllers/console/app/test_workflow_run_api.py",
|
||||
"controllers/console/app/test_workflow_trigger_api.py",
|
||||
"controllers/console/app/test_wraps.py",
|
||||
"controllers/console/app/workflow_draft_variables_test.py",
|
||||
"controllers/console/auth/test_account_activation.py",
|
||||
"controllers/console/auth/test_authentication_security.py",
|
||||
"controllers/console/auth/test_data_source_bearer_auth.py",
|
||||
"controllers/console/auth/test_email_register.py",
|
||||
"controllers/console/auth/test_email_register_language.py",
|
||||
"controllers/console/auth/test_email_verification.py",
|
||||
"controllers/console/auth/test_forgot_password.py",
|
||||
"controllers/console/auth/test_login_logout.py",
|
||||
"controllers/console/auth/test_oauth.py",
|
||||
"controllers/console/auth/test_oauth_timezone.py",
|
||||
"controllers/console/auth/test_password_reset.py",
|
||||
"controllers/console/auth/test_token_refresh.py",
|
||||
"controllers/console/billing/test_billing.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_datasource_auth.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py",
|
||||
"controllers/console/datasets/test_data_source.py",
|
||||
"controllers/console/datasets/test_datasets.py",
|
||||
"controllers/console/datasets/test_datasets_document.py",
|
||||
"controllers/console/datasets/test_datasets_document_download.py",
|
||||
"controllers/console/datasets/test_datasets_segments.py",
|
||||
"controllers/console/datasets/test_external.py",
|
||||
"controllers/console/datasets/test_hit_testing.py",
|
||||
"controllers/console/datasets/test_hit_testing_base.py",
|
||||
"controllers/console/datasets/test_metadata.py",
|
||||
"controllers/console/datasets/test_website.py",
|
||||
"controllers/console/datasets/test_wraps.py",
|
||||
"controllers/console/explore/test_audio.py",
|
||||
"controllers/console/explore/test_banner.py",
|
||||
"controllers/console/explore/test_completion.py",
|
||||
"controllers/console/explore/test_installed_app.py",
|
||||
"controllers/console/explore/test_message.py",
|
||||
"controllers/console/explore/test_parameter.py",
|
||||
"controllers/console/explore/test_recommended_app.py",
|
||||
"controllers/console/explore/test_saved_message.py",
|
||||
"controllers/console/explore/test_trial.py",
|
||||
"controllers/console/explore/test_workflow.py",
|
||||
"controllers/console/explore/test_wraps.py",
|
||||
"controllers/console/snippets/test_snippet_workflow.py",
|
||||
"controllers/console/snippets/test_snippet_workflow_draft_variable.py",
|
||||
"controllers/console/tag/test_tags.py",
|
||||
"controllers/console/test_document_detail_api_data_source_info.py",
|
||||
"controllers/console/test_extension.py",
|
||||
"controllers/console/test_fastopenapi_ping.py",
|
||||
"controllers/console/test_fastopenapi_setup.py",
|
||||
"controllers/console/test_fastopenapi_version.py",
|
||||
"controllers/console/test_feature.py",
|
||||
"controllers/console/test_files.py",
|
||||
"controllers/console/test_files_security.py",
|
||||
"controllers/console/test_human_input_form.py",
|
||||
"controllers/console/test_knowledge_fs_proxy.py",
|
||||
"controllers/console/test_remote_files.py",
|
||||
"controllers/console/test_spec.py",
|
||||
"controllers/console/test_version.py",
|
||||
"controllers/console/test_workflow_run_archive.py",
|
||||
"controllers/console/test_workspace_account.py",
|
||||
"controllers/console/test_workspace_members.py",
|
||||
"controllers/console/test_wraps.py",
|
||||
"controllers/console/workspace/test_accounts.py",
|
||||
"controllers/console/workspace/test_agent_providers.py",
|
||||
"controllers/console/workspace/test_endpoint.py",
|
||||
"controllers/console/workspace/test_load_balancing_config.py",
|
||||
"controllers/console/workspace/test_members.py",
|
||||
"controllers/console/workspace/test_model_providers.py",
|
||||
"controllers/console/workspace/test_models.py",
|
||||
"controllers/console/workspace/test_plugin.py",
|
||||
"controllers/console/workspace/test_rbac.py",
|
||||
"controllers/console/workspace/test_snippets.py",
|
||||
"controllers/console/workspace/test_tool_providers.py",
|
||||
"controllers/console/workspace/test_workspace.py",
|
||||
"controllers/files/test_image_preview.py",
|
||||
"controllers/files/test_tool_files.py",
|
||||
"controllers/files/test_upload.py",
|
||||
"controllers/inner_api/app/test_dsl.py",
|
||||
"controllers/inner_api/plugin/test_agent_config.py",
|
||||
"controllers/inner_api/plugin/test_agent_drive.py",
|
||||
"controllers/inner_api/plugin/test_plugin.py",
|
||||
"controllers/inner_api/plugin/test_plugin_wraps.py",
|
||||
"controllers/inner_api/test_auth_wraps.py",
|
||||
"controllers/inner_api/test_knowledge_retrieval.py",
|
||||
"controllers/inner_api/test_mail.py",
|
||||
"controllers/inner_api/test_runtime_credentials.py",
|
||||
"controllers/inner_api/workspace/test_workspace.py",
|
||||
"controllers/mcp/test_mcp.py",
|
||||
"controllers/openapi/auth/test_composition.py",
|
||||
"controllers/openapi/auth/test_conditions.py",
|
||||
"controllers/openapi/auth/test_data.py",
|
||||
"controllers/openapi/auth/test_flow.py",
|
||||
"controllers/openapi/auth/test_pipeline.py",
|
||||
"controllers/openapi/auth/test_prepare.py",
|
||||
"controllers/openapi/auth/test_verify.py",
|
||||
"controllers/openapi/conftest.py",
|
||||
"controllers/openapi/test_account.py",
|
||||
"controllers/openapi/test_app_describe_builder.py",
|
||||
"controllers/openapi/test_app_list_query.py",
|
||||
"controllers/openapi/test_app_payloads.py",
|
||||
"controllers/openapi/test_app_run_dispatch.py",
|
||||
"controllers/openapi/test_app_run_rate_limit.py",
|
||||
"controllers/openapi/test_app_run_streaming.py",
|
||||
"controllers/openapi/test_apps_permitted_external_query.py",
|
||||
"controllers/openapi/test_audit_app_run.py",
|
||||
"controllers/openapi/test_contract.py",
|
||||
"controllers/openapi/test_cors.py",
|
||||
"controllers/openapi/test_device_approve_deny.py",
|
||||
"controllers/openapi/test_device_code.py",
|
||||
"controllers/openapi/test_device_lookup.py",
|
||||
"controllers/openapi/test_device_sso.py",
|
||||
"controllers/openapi/test_device_token.py",
|
||||
"controllers/openapi/test_error_contract.py",
|
||||
"controllers/openapi/test_health.py",
|
||||
"controllers/openapi/test_human_input_form.py",
|
||||
"controllers/openapi/test_input_schema.py",
|
||||
"controllers/openapi/test_meta_version.py",
|
||||
"controllers/openapi/test_models.py",
|
||||
"controllers/openapi/test_oauth_sso_claims.py",
|
||||
"controllers/openapi/test_oauth_sso_csrf.py",
|
||||
"controllers/openapi/test_oauth_sso_host_header.py",
|
||||
"controllers/openapi/test_pagination_envelope.py",
|
||||
"controllers/openapi/test_supported_app_type.py",
|
||||
"controllers/openapi/test_version_gate.py",
|
||||
"controllers/openapi/test_workflow_events_openapi.py",
|
||||
"controllers/openapi/test_workspaces.py",
|
||||
"controllers/openapi/test_workspaces_members.py",
|
||||
"controllers/service_api/app/test_annotation.py",
|
||||
"controllers/service_api/app/test_app.py",
|
||||
"controllers/service_api/app/test_audio.py",
|
||||
"controllers/service_api/app/test_chat_request_payload.py",
|
||||
"controllers/service_api/app/test_completion.py",
|
||||
"controllers/service_api/app/test_conversation.py",
|
||||
"controllers/service_api/app/test_file.py",
|
||||
"controllers/service_api/app/test_file_preview.py",
|
||||
"controllers/service_api/app/test_hitl_service_api.py",
|
||||
"controllers/service_api/app/test_human_input_form.py",
|
||||
"controllers/service_api/app/test_message.py",
|
||||
"controllers/service_api/app/test_workflow.py",
|
||||
"controllers/service_api/app/test_workflow_events.py",
|
||||
"controllers/service_api/conftest.py",
|
||||
"controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py",
|
||||
"controllers/service_api/dataset/test_dataset_segment.py",
|
||||
"controllers/service_api/dataset/test_document.py",
|
||||
"controllers/service_api/dataset/test_hit_testing.py",
|
||||
"controllers/service_api/dataset/test_metadata.py",
|
||||
"controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py",
|
||||
"controllers/service_api/dataset/test_rag_pipeline_route_registration.py",
|
||||
"controllers/service_api/test_index.py",
|
||||
"controllers/service_api/test_trace_session_id_parsing.py",
|
||||
"controllers/service_api/test_wraps.py",
|
||||
"controllers/test_compare_versions.py",
|
||||
"controllers/test_conversation_rename_payload.py",
|
||||
"controllers/test_swagger.py",
|
||||
"controllers/trigger/test_trigger.py",
|
||||
"controllers/trigger/test_webhook.py",
|
||||
"controllers/web/conftest.py",
|
||||
"controllers/web/test_app.py",
|
||||
"controllers/web/test_completion.py",
|
||||
"controllers/web/test_human_input_file_upload.py",
|
||||
"controllers/web/test_human_input_form.py",
|
||||
"controllers/web/test_message_endpoints.py",
|
||||
"controllers/web/test_message_list.py",
|
||||
"controllers/web/test_pydantic_models.py",
|
||||
"controllers/web/test_web_forgot_password.py",
|
||||
"controllers/web/test_web_login.py",
|
||||
"controllers/web/test_web_passport.py",
|
||||
"controllers/web/test_workflow.py",
|
||||
"controllers/web/test_wraps.py",
|
||||
"core/agent/conftest.py",
|
||||
"core/agent/output_parser/test_cot_output_parser.py",
|
||||
"core/agent/strategy/test_base.py",
|
||||
"core/agent/strategy/test_plugin.py",
|
||||
"core/agent/test_base_agent_runner.py",
|
||||
"core/agent/test_cot_agent_runner.py",
|
||||
"core/agent/test_cot_chat_agent_runner.py",
|
||||
"core/agent/test_cot_completion_agent_runner.py",
|
||||
"core/agent/test_fc_agent_runner.py",
|
||||
"core/agent/test_plugin_entities.py",
|
||||
"core/app/app_config/common/test_parameters_mapping.py",
|
||||
"core/app/app_config/common/test_sensitive_word_avoidance_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_agent_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_dataset_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_model_config_converter.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_model_config_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_variables_manager.py",
|
||||
"core/app/app_config/features/file_upload/test_manager.py",
|
||||
"core/app/app_config/features/test_additional_feature_managers.py",
|
||||
"core/app/app_config/test_base_app_config_manager.py",
|
||||
"core/app/app_config/test_entities.py",
|
||||
"core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py",
|
||||
"core/app/apps/advanced_chat/test_app_config_manager.py",
|
||||
"core/app/apps/advanced_chat/test_app_generator.py",
|
||||
"core/app/apps/advanced_chat/test_app_runner_conversation_variables.py",
|
||||
"core/app/apps/advanced_chat/test_app_runner_input_moderation.py",
|
||||
"core/app/apps/advanced_chat/test_generate_response_converter.py",
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline.py",
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline_core.py",
|
||||
"core/app/apps/agent_app/test_app_config_manager.py",
|
||||
"core/app/apps/agent_app/test_app_generator.py",
|
||||
"core/app/apps/agent_app/test_app_runner.py",
|
||||
"core/app/apps/agent_app/test_input_guards.py",
|
||||
"core/app/apps/agent_app/test_resolve_agent.py",
|
||||
"core/app/apps/agent_app/test_runtime_request_builder.py",
|
||||
"core/app/apps/agent_app/test_session_store.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_config_manager.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_generator.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_runner.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_generate_response_converter.py",
|
||||
"core/app/apps/chat/test_app_config_manager.py",
|
||||
"core/app/apps/chat/test_app_generator_and_runner.py",
|
||||
"core/app/apps/chat/test_base_app_runner_multimodal.py",
|
||||
"core/app/apps/chat/test_generate_response_converter.py",
|
||||
"core/app/apps/common/test_graph_runtime_state_support.py",
|
||||
"core/app/apps/common/test_workflow_response_converter.py",
|
||||
"core/app/apps/common/test_workflow_response_converter_human_input.py",
|
||||
"core/app/apps/common/test_workflow_response_converter_resumption.py",
|
||||
"core/app/apps/common/test_workflow_response_converter_truncation.py",
|
||||
"core/app/apps/completion/test_app_runner.py",
|
||||
"core/app/apps/completion/test_completion_app_config_manager.py",
|
||||
"core/app/apps/completion/test_completion_completion_app_generator.py",
|
||||
"core/app/apps/completion/test_completion_generate_response_converter.py",
|
||||
"core/app/apps/pipeline/test_pipeline_config_manager.py",
|
||||
"core/app/apps/pipeline/test_pipeline_generate_response_converter.py",
|
||||
"core/app/apps/pipeline/test_pipeline_generator.py",
|
||||
"core/app/apps/pipeline/test_pipeline_queue_manager.py",
|
||||
"core/app/apps/pipeline/test_pipeline_runner.py",
|
||||
"core/app/apps/test_advanced_chat_app_generator.py",
|
||||
"core/app/apps/test_base_app_generate_response_converter.py",
|
||||
"core/app/apps/test_base_app_generator.py",
|
||||
"core/app/apps/test_base_app_queue_manager.py",
|
||||
"core/app/apps/test_base_app_runner.py",
|
||||
"core/app/apps/test_exc.py",
|
||||
"core/app/apps/test_message_based_app_generator.py",
|
||||
"core/app/apps/test_message_based_app_queue_manager.py",
|
||||
"core/app/apps/test_message_generator.py",
|
||||
"core/app/apps/test_pause_resume.py",
|
||||
"core/app/apps/test_streaming_utils.py",
|
||||
"core/app/apps/test_trace_session_id_generate_extras.py",
|
||||
"core/app/apps/test_workflow_app_generator.py",
|
||||
"core/app/apps/test_workflow_app_runner_core.py",
|
||||
"core/app/apps/test_workflow_app_runner_notifications.py",
|
||||
"core/app/apps/test_workflow_app_runner_single_node.py",
|
||||
"core/app/apps/test_workflow_pause_events.py",
|
||||
"core/app/apps/workflow/test_active_workflow_tasks.py",
|
||||
"core/app/apps/workflow/test_app_config_manager.py",
|
||||
"core/app/apps/workflow/test_app_generator_extra.py",
|
||||
"core/app/apps/workflow/test_app_queue_manager.py",
|
||||
"core/app/apps/workflow/test_command_channels.py",
|
||||
"core/app/apps/workflow/test_errors.py",
|
||||
"core/app/apps/workflow/test_generate_response_converter.py",
|
||||
"core/app/apps/workflow/test_generate_task_pipeline.py",
|
||||
"core/app/apps/workflow/test_generate_task_pipeline_core.py",
|
||||
"core/app/entities/test_app_invoke_entities.py",
|
||||
"core/app/entities/test_queue_entities.py",
|
||||
"core/app/entities/test_rag_pipeline_invoke_entities.py",
|
||||
"core/app/entities/test_task_entities.py",
|
||||
"core/app/features/rate_limiting/conftest.py",
|
||||
"core/app/features/rate_limiting/test_rate_limit.py",
|
||||
"core/app/features/test_annotation_reply.py",
|
||||
"core/app/features/test_hosting_moderation.py",
|
||||
"core/app/layers/test_conversation_variable_persist_layer.py",
|
||||
"core/app/layers/test_pause_state_persist_layer.py",
|
||||
"core/app/layers/test_suspend_layer.py",
|
||||
"core/app/layers/test_timeslice_layer.py",
|
||||
"core/app/layers/test_trigger_post_layer.py",
|
||||
"core/app/task_pipeline/test_based_generate_task_pipeline.py",
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py",
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py",
|
||||
"core/app/task_pipeline/test_exc.py",
|
||||
"core/app/task_pipeline/test_message_cycle_manager_optimization.py",
|
||||
"core/app/test_easy_ui_model_config_manager.py",
|
||||
"core/app/test_invoke_from.py",
|
||||
"core/app/test_llm_quota.py",
|
||||
"core/app/workflow/layers/test_persistence.py",
|
||||
"core/app/workflow/layers/test_persistence_inspector_publish.py",
|
||||
"core/app/workflow/test_file_runtime.py",
|
||||
"core/app/workflow/test_node_factory.py",
|
||||
"core/app/workflow/test_observability_layer_extra.py",
|
||||
"core/app/workflow/test_persistence_layer.py",
|
||||
"core/base/test_app_generator_tts_publisher.py",
|
||||
"core/callback_handler/test_agent_tool_callback_handler.py",
|
||||
"core/callback_handler/test_index_tool_callback_handler.py",
|
||||
"core/callback_handler/test_workflow_tool_callback_handler.py",
|
||||
"core/datasource/__base/test_datasource_plugin.py",
|
||||
"core/datasource/__base/test_datasource_provider.py",
|
||||
"core/datasource/__base/test_datasource_runtime.py",
|
||||
"core/datasource/entities/test_api_entities.py",
|
||||
"core/datasource/entities/test_common_entities.py",
|
||||
"core/datasource/entities/test_datasource_entities.py",
|
||||
"core/datasource/local_file/test_local_file_plugin.py",
|
||||
"core/datasource/local_file/test_local_file_provider.py",
|
||||
"core/datasource/online_document/test_online_document_plugin.py",
|
||||
"core/datasource/online_document/test_online_document_provider.py",
|
||||
"core/datasource/online_drive/test_online_drive_plugin.py",
|
||||
"core/datasource/online_drive/test_online_drive_provider.py",
|
||||
"core/datasource/test_datasource_file_manager.py",
|
||||
"core/datasource/test_datasource_manager.py",
|
||||
"core/datasource/test_errors.py",
|
||||
"core/datasource/test_file_upload.py",
|
||||
"core/datasource/test_notion_provider.py",
|
||||
"core/datasource/test_website_crawl.py",
|
||||
"core/datasource/utils/test_message_transformer.py",
|
||||
"core/datasource/website_crawl/test_website_crawl_plugin.py",
|
||||
"core/datasource/website_crawl/test_website_crawl_provider.py",
|
||||
"core/entities/test_entities_mcp_provider.py",
|
||||
"core/entities/test_entities_provider_configuration.py",
|
||||
"core/extension/test_api_based_extension_requestor.py",
|
||||
"core/extension/test_extensible.py",
|
||||
"core/extension/test_extension.py",
|
||||
"core/external_data_tool/api/test_api.py",
|
||||
"core/external_data_tool/test_base.py",
|
||||
"core/external_data_tool/test_external_data_fetch.py",
|
||||
"core/external_data_tool/test_factory.py",
|
||||
"core/file/test_models.py",
|
||||
"core/file/test_remote_fetcher.py",
|
||||
"core/helper/code_executor/javascript/test_javascript_transformer.py",
|
||||
"core/helper/code_executor/jinja2/test_jinja2_sandbox.py",
|
||||
"core/helper/code_executor/python3/test_python3_transformer.py",
|
||||
"core/helper/code_executor/test_code_executor.py",
|
||||
"core/helper/code_executor/test_code_node_provider.py",
|
||||
"core/helper/code_executor/test_template_transformer.py",
|
||||
"core/helper/test_creators.py",
|
||||
"core/helper/test_credential_utils.py",
|
||||
"core/helper/test_csv_sanitizer.py",
|
||||
"core/helper/test_encrypter.py",
|
||||
"core/helper/test_ssrf_proxy.py",
|
||||
"core/helper/test_trace_id_helper.py",
|
||||
"core/llm_generator/output_parser/test_rule_config_generator.py",
|
||||
"core/llm_generator/output_parser/test_structured_output.py",
|
||||
"core/llm_generator/test_llm_generator.py",
|
||||
"core/llm_generator/test_llm_generator_missing.py",
|
||||
"core/logging/test_context.py",
|
||||
"core/logging/test_filters.py",
|
||||
"core/logging/test_structured_formatter.py",
|
||||
"core/logging/test_trace_helpers.py",
|
||||
"core/mcp/auth/test_auth_flow.py",
|
||||
"core/mcp/client/test_session.py",
|
||||
"core/mcp/client/test_sse.py",
|
||||
"core/mcp/client/test_streamable_http.py",
|
||||
"core/mcp/server/test_streamable_http.py",
|
||||
"core/mcp/session/test_base_session.py",
|
||||
"core/mcp/session/test_client_session.py",
|
||||
"core/mcp/test_auth_client_inheritance.py",
|
||||
"core/mcp/test_entities.py",
|
||||
"core/mcp/test_error.py",
|
||||
"core/mcp/test_mcp_client.py",
|
||||
"core/mcp/test_types.py",
|
||||
"core/mcp/test_utils.py",
|
||||
"core/memory/test_token_buffer_memory.py",
|
||||
"core/model_runtime/test_model_provider_factory.py",
|
||||
"core/moderation/api/test_api.py",
|
||||
"core/moderation/test_content_moderation.py",
|
||||
"core/moderation/test_input_moderation.py",
|
||||
"core/moderation/test_output_moderation.py",
|
||||
"core/moderation/test_sensitive_word_filter.py",
|
||||
"core/ops/test_base_trace_instance.py",
|
||||
"core/ops/test_config_entity.py",
|
||||
"core/ops/test_lookup_helpers.py",
|
||||
"core/ops/test_ops_trace_manager.py",
|
||||
"core/ops/test_trace_queue_manager.py",
|
||||
"core/ops/test_trace_session_metadata.py",
|
||||
"core/ops/test_utils.py",
|
||||
"core/plugin/impl/test_agent_client.py",
|
||||
"core/plugin/impl/test_asset_manager.py",
|
||||
"core/plugin/impl/test_base_client_impl.py",
|
||||
"core/plugin/impl/test_datasource_manager.py",
|
||||
"core/plugin/impl/test_debugging_client.py",
|
||||
"core/plugin/impl/test_endpoint_client_impl.py",
|
||||
"core/plugin/impl/test_exc_impl.py",
|
||||
"core/plugin/impl/test_model_client.py",
|
||||
"core/plugin/impl/test_model_runtime_factory.py",
|
||||
"core/plugin/impl/test_oauth_handler.py",
|
||||
"core/plugin/impl/test_tool_manager.py",
|
||||
"core/plugin/impl/test_trigger_client.py",
|
||||
"core/plugin/test_backwards_invocation_app.py",
|
||||
"core/plugin/test_backwards_invocation_model.py",
|
||||
"core/plugin/test_endpoint_client.py",
|
||||
"core/plugin/test_model_runtime_adapter.py",
|
||||
"core/plugin/test_plugin_entities.py",
|
||||
"core/plugin/test_plugin_manager.py",
|
||||
"core/plugin/test_plugin_runtime.py",
|
||||
"core/plugin/utils/test_chunk_merger.py",
|
||||
"core/plugin/utils/test_http_parser.py",
|
||||
"core/prompt/test_advanced_prompt_transform.py",
|
||||
"core/prompt/test_agent_history_prompt_transform.py",
|
||||
"core/prompt/test_extract_thread_messages.py",
|
||||
"core/prompt/test_prompt_message.py",
|
||||
"core/prompt/test_prompt_transform.py",
|
||||
"core/prompt/test_simple_prompt_transform.py",
|
||||
"core/rag/cleaner/test_clean_processor.py",
|
||||
"core/rag/data_post_processor/test_data_post_processor.py",
|
||||
"core/rag/datasource/keyword/jieba/test_jieba.py",
|
||||
"core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py",
|
||||
"core/rag/datasource/keyword/jieba/test_stopwords.py",
|
||||
"core/rag/datasource/keyword/test_keyword_base.py",
|
||||
"core/rag/datasource/keyword/test_keyword_factory.py",
|
||||
"core/rag/datasource/test_datasource_retrieval.py",
|
||||
"core/rag/datasource/vdb/test_field.py",
|
||||
"core/rag/datasource/vdb/test_vector_base.py",
|
||||
"core/rag/datasource/vdb/test_vector_factory.py",
|
||||
"core/rag/docstore/test_dataset_docstore.py",
|
||||
"core/rag/embedding/test_cached_embedding.py",
|
||||
"core/rag/embedding/test_embedding_base.py",
|
||||
"core/rag/embedding/test_embedding_service.py",
|
||||
"core/rag/extractor/blob/test_blob.py",
|
||||
"core/rag/extractor/firecrawl/test_firecrawl.py",
|
||||
"core/rag/extractor/test_csv_extractor.py",
|
||||
"core/rag/extractor/test_excel_extractor.py",
|
||||
"core/rag/extractor/test_extract_processor.py",
|
||||
"core/rag/extractor/test_extractor_base.py",
|
||||
"core/rag/extractor/test_helpers.py",
|
||||
"core/rag/extractor/test_html_extractor.py",
|
||||
"core/rag/extractor/test_jina_reader_extractor.py",
|
||||
"core/rag/extractor/test_markdown_extractor.py",
|
||||
"core/rag/extractor/test_notion_extractor.py",
|
||||
"core/rag/extractor/test_pdf_extractor.py",
|
||||
"core/rag/extractor/test_text_extractor.py",
|
||||
"core/rag/extractor/test_word_extractor.py",
|
||||
"core/rag/extractor/unstructured/test_unstructured_extractors.py",
|
||||
"core/rag/extractor/watercrawl/test_watercrawl.py",
|
||||
"core/rag/indexing/processor/test_paragraph_index_processor.py",
|
||||
"core/rag/indexing/processor/test_qa_index_processor.py",
|
||||
"core/rag/indexing/test_index_processor.py",
|
||||
"core/rag/indexing/test_index_processor_base.py",
|
||||
"core/rag/indexing/test_indexing_runner.py",
|
||||
"core/rag/pipeline/test_queue.py",
|
||||
"core/rag/rerank/test_reranker.py",
|
||||
"core/rag/retrieval/test_dataset_retrieval.py",
|
||||
"core/rag/retrieval/test_dataset_retrieval_methods.py",
|
||||
"core/rag/retrieval/test_multi_dataset_function_call_router.py",
|
||||
"core/rag/retrieval/test_multi_dataset_react_route.py",
|
||||
"core/rag/splitter/test_text_splitter.py",
|
||||
"core/repositories/test_celery_workflow_execution_repository.py",
|
||||
"core/repositories/test_celery_workflow_node_execution_repository.py",
|
||||
"core/repositories/test_factory.py",
|
||||
"core/repositories/test_human_input_form_repository_impl.py",
|
||||
"core/repositories/test_human_input_repository.py",
|
||||
"core/repositories/test_sqlalchemy_workflow_execution_repository.py",
|
||||
"core/repositories/test_sqlalchemy_workflow_node_execution_repository.py",
|
||||
"core/repositories/test_workflow_node_execution_conflict_handling.py",
|
||||
"core/repositories/test_workflow_node_execution_truncation.py",
|
||||
"core/schemas/test_registry.py",
|
||||
"core/schemas/test_resolver.py",
|
||||
"core/schemas/test_schema_manager.py",
|
||||
"core/telemetry/test_facade.py",
|
||||
"core/telemetry/test_gateway_integration.py",
|
||||
"core/test_file.py",
|
||||
"core/test_model_manager.py",
|
||||
"core/test_provider_configuration.py",
|
||||
"core/test_provider_manager.py",
|
||||
"core/test_trigger_debug_event_selectors.py",
|
||||
"core/tools/entities/test_api_entities.py",
|
||||
"core/tools/test_base_tool.py",
|
||||
"core/tools/test_builtin_tool_base.py",
|
||||
"core/tools/test_builtin_tool_provider.py",
|
||||
"core/tools/test_builtin_tools_extra.py",
|
||||
"core/tools/test_custom_tool.py",
|
||||
"core/tools/test_custom_tool_provider.py",
|
||||
"core/tools/test_dataset_retriever_tool.py",
|
||||
"core/tools/test_mcp_tool.py",
|
||||
"core/tools/test_mcp_tool_provider.py",
|
||||
"core/tools/test_plugin_tool.py",
|
||||
"core/tools/test_plugin_tool_provider.py",
|
||||
"core/tools/test_tool_engine.py",
|
||||
"core/tools/test_tool_entities.py",
|
||||
"core/tools/test_tool_file_manager.py",
|
||||
"core/tools/test_tool_label_manager.py",
|
||||
"core/tools/test_tool_manager.py",
|
||||
"core/tools/test_tool_parameter_type.py",
|
||||
"core/tools/test_tool_provider_controller.py",
|
||||
"core/tools/utils/test_configuration.py",
|
||||
"core/tools/utils/test_encryption.py",
|
||||
"core/tools/utils/test_message_transformer.py",
|
||||
"core/tools/utils/test_misc_utils_extra.py",
|
||||
"core/tools/utils/test_model_invocation_utils.py",
|
||||
"core/tools/utils/test_parser.py",
|
||||
"core/tools/utils/test_system_oauth_encryption.py",
|
||||
"core/tools/utils/test_tool_engine_serialization.py",
|
||||
"core/tools/utils/test_web_reader_tool.py",
|
||||
"core/tools/utils/test_workflow_configuration_sync.py",
|
||||
"core/tools/workflow_as_tool/test_provider.py",
|
||||
"core/tools/workflow_as_tool/test_tool.py",
|
||||
"core/trigger/conftest.py",
|
||||
"core/trigger/debug/test_debug_event_bus.py",
|
||||
"core/trigger/debug/test_debug_event_selectors.py",
|
||||
"core/trigger/test_provider.py",
|
||||
"core/trigger/test_trigger_manager.py",
|
||||
"core/trigger/utils/test_utils_encryption.py",
|
||||
"core/trigger/utils/test_utils_endpoint.py",
|
||||
"core/trigger/utils/test_utils_locks.py",
|
||||
"core/variables/test_segment.py",
|
||||
"core/variables/test_segment_type.py",
|
||||
"core/variables/test_segment_type_validation.py",
|
||||
"core/variables/test_variables.py",
|
||||
"core/workflow/context/test_execution_context.py",
|
||||
"core/workflow/context/test_flask_app_context.py",
|
||||
"core/workflow/entities/test_private_workflow_pause.py",
|
||||
"core/workflow/generator/test_prompts.py",
|
||||
"core/workflow/generator/test_runner.py",
|
||||
"core/workflow/generator/test_runner_missing.py",
|
||||
"core/workflow/generator/test_tool_catalogue.py",
|
||||
"core/workflow/graph_engine/layers/conftest.py",
|
||||
"core/workflow/graph_engine/layers/test_llm_quota.py",
|
||||
"core/workflow/graph_engine/layers/test_observability.py",
|
||||
"core/workflow/graph_engine/test_mock_config.py",
|
||||
"core/workflow/graph_engine/test_mock_factory.py",
|
||||
"core/workflow/graph_engine/test_mock_nodes.py",
|
||||
"core/workflow/graph_engine/test_parallel_human_input_join_resume.py",
|
||||
"core/workflow/graph_engine/test_table_runner.py",
|
||||
"core/workflow/graph_engine/test_tool_in_chatflow.py",
|
||||
"core/workflow/nodes/agent/test_message_transformer.py",
|
||||
"core/workflow/nodes/agent/test_runtime_support.py",
|
||||
"core/workflow/nodes/agent_v2/test_agent_node.py",
|
||||
"core/workflow/nodes/agent_v2/test_binding_resolver.py",
|
||||
"core/workflow/nodes/agent_v2/test_dify_tools_builder.py",
|
||||
"core/workflow/nodes/agent_v2/test_file_tenant_validator.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_adapter.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_failure_orchestrator.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_file_rebacker.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_type_checker.py",
|
||||
"core/workflow/nodes/agent_v2/test_runtime_request_builder.py",
|
||||
"core/workflow/nodes/agent_v2/test_session_cleanup_layer.py",
|
||||
"core/workflow/nodes/agent_v2/test_session_store.py",
|
||||
"core/workflow/nodes/agent_v2/test_validators.py",
|
||||
"core/workflow/nodes/answer/test_answer.py",
|
||||
"core/workflow/nodes/base/test_base_node.py",
|
||||
"core/workflow/nodes/base/test_get_node_type_classes_mapping.py",
|
||||
"core/workflow/nodes/code/code_node_spec.py",
|
||||
"core/workflow/nodes/datasource/test_datasource_node.py",
|
||||
"core/workflow/nodes/http_request/test_http_request_executor.py",
|
||||
"core/workflow/nodes/http_request/test_http_request_node.py",
|
||||
"core/workflow/nodes/human_input/test_dify_owned_contracts.py",
|
||||
"core/workflow/nodes/human_input/test_email_delivery_config.py",
|
||||
"core/workflow/nodes/human_input/test_entities.py",
|
||||
"core/workflow/nodes/human_input/test_human_input_form_filled_event.py",
|
||||
"core/workflow/nodes/iteration/test_iteration_child_engine_errors.py",
|
||||
"core/workflow/nodes/knowledge_index/test_knowledge_index_node.py",
|
||||
"core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py",
|
||||
"core/workflow/nodes/list_operator/node_spec.py",
|
||||
"core/workflow/nodes/llm/test_llm_utils.py",
|
||||
"core/workflow/nodes/llm/test_node.py",
|
||||
"core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py",
|
||||
"core/workflow/nodes/template_transform/template_transform_node_spec.py",
|
||||
"core/workflow/nodes/template_transform/test_template_transform_node.py",
|
||||
"core/workflow/nodes/test_base_node.py",
|
||||
"core/workflow/nodes/test_document_extractor_node.py",
|
||||
"core/workflow/nodes/test_if_else.py",
|
||||
"core/workflow/nodes/test_list_operator.py",
|
||||
"core/workflow/nodes/test_start_node_json_object.py",
|
||||
"core/workflow/nodes/tool/test_tool_node.py",
|
||||
"core/workflow/nodes/tool/test_tool_node_runtime.py",
|
||||
"core/workflow/nodes/webhook/test_entities.py",
|
||||
"core/workflow/nodes/webhook/test_exceptions.py",
|
||||
"core/workflow/nodes/webhook/test_webhook_file_conversion.py",
|
||||
"core/workflow/nodes/webhook/test_webhook_node.py",
|
||||
"core/workflow/test_enrich_pause_reasons.py",
|
||||
"core/workflow/test_form_input_serialization_compat.py",
|
||||
"core/workflow/test_graph_topology.py",
|
||||
"core/workflow/test_human_input_adapter.py",
|
||||
"core/workflow/test_human_input_callback.py",
|
||||
"core/workflow/test_human_input_forms.py",
|
||||
"core/workflow/test_node_factory.py",
|
||||
"core/workflow/test_node_mapping_bootstrap.py",
|
||||
"core/workflow/test_node_runtime.py",
|
||||
"core/workflow/test_system_variable.py",
|
||||
"core/workflow/test_variable_pool.py",
|
||||
"core/workflow/test_workflow_entry.py",
|
||||
"core/workflow/test_workflow_entry_helpers.py",
|
||||
"core/workflow/test_workflow_entry_redis_channel.py",
|
||||
"dev/test_generate_knowledge_fs_contract.py",
|
||||
"enterprise/telemetry/test_contracts.py",
|
||||
"enterprise/telemetry/test_draft_trace.py",
|
||||
"enterprise/telemetry/test_enterprise_trace.py",
|
||||
"enterprise/telemetry/test_event_handlers.py",
|
||||
"enterprise/telemetry/test_exporter.py",
|
||||
"enterprise/telemetry/test_gateway.py",
|
||||
"enterprise/telemetry/test_metric_handler.py",
|
||||
"enums/test_quota_type.py",
|
||||
"events/event_handlers/test_clean_when_document_deleted.py",
|
||||
"events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py",
|
||||
"events/test_app_event_signals.py",
|
||||
"events/test_events_package_compat.py",
|
||||
"events/test_update_provider_when_message_created.py",
|
||||
"extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py",
|
||||
"extensions/logstore/test_sql_escape.py",
|
||||
"extensions/otel/conftest.py",
|
||||
"extensions/otel/decorators/handlers/test_generate_handler.py",
|
||||
"extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py",
|
||||
"extensions/otel/decorators/test_base.py",
|
||||
"extensions/otel/decorators/test_handler.py",
|
||||
"extensions/otel/test_celery_sqlcommenter.py",
|
||||
"extensions/otel/test_context.py",
|
||||
"extensions/otel/test_retrieval_tracing.py",
|
||||
"extensions/otel/test_runtime.py",
|
||||
"extensions/storage/test_supabase_storage.py",
|
||||
"extensions/test_celery_ssl.py",
|
||||
"extensions/test_ext_blueprints_openapi.py",
|
||||
"extensions/test_ext_login.py",
|
||||
"extensions/test_ext_request_logging.py",
|
||||
"extensions/test_ext_socketio.py",
|
||||
"extensions/test_pubsub_channel.py",
|
||||
"extensions/test_redis.py",
|
||||
"extensions/test_set_secretkey.py",
|
||||
"extensions/test_workflow_warm_shutdown.py",
|
||||
"factories/test_build_from_mapping.py",
|
||||
"factories/test_file_factory.py",
|
||||
"factories/test_file_validation.py",
|
||||
"factories/test_variable_factory.py",
|
||||
"fields/test_dataset_fields.py",
|
||||
"fields/test_file_fields.py",
|
||||
"fields/test_message_fields.py",
|
||||
"libs/_human_input/support.py",
|
||||
"libs/_human_input/test_form_service.py",
|
||||
"libs/_human_input/test_models.py",
|
||||
"libs/broadcast_channel/redis/test_channel_unit_tests.py",
|
||||
"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py",
|
||||
"libs/test_api_token_cache.py",
|
||||
"libs/test_archive_storage.py",
|
||||
"libs/test_cron_compatibility.py",
|
||||
"libs/test_custom_inputs.py",
|
||||
"libs/test_datetime_utils.py",
|
||||
"libs/test_email.py",
|
||||
"libs/test_email_i18n.py",
|
||||
"libs/test_encryption.py",
|
||||
"libs/test_external_api.py",
|
||||
"libs/test_file_utils.py",
|
||||
"libs/test_flask_utils.py",
|
||||
"libs/test_helper.py",
|
||||
"libs/test_json_in_md_parser.py",
|
||||
"libs/test_jwt_imports.py",
|
||||
"libs/test_login.py",
|
||||
"libs/test_oauth_base.py",
|
||||
"libs/test_oauth_bearer.py",
|
||||
"libs/test_oauth_bearer_layer0_cache.py",
|
||||
"libs/test_oauth_bearer_rate_limit_ordering.py",
|
||||
"libs/test_oauth_bearer_require_scope.py",
|
||||
"libs/test_oauth_clients.py",
|
||||
"libs/test_orjson.py",
|
||||
"libs/test_pagination.py",
|
||||
"libs/test_pandas.py",
|
||||
"libs/test_passport.py",
|
||||
"libs/test_password.py",
|
||||
"libs/test_rate_limit_bearer.py",
|
||||
"libs/test_rate_limiter.py",
|
||||
"libs/test_rsa.py",
|
||||
"libs/test_schedule_utils_enhanced.py",
|
||||
"libs/test_sendgrid_client.py",
|
||||
"libs/test_smtp_client.py",
|
||||
"libs/test_time_parser.py",
|
||||
"libs/test_token.py",
|
||||
"libs/test_token_manager.py",
|
||||
"libs/test_uuid_utils.py",
|
||||
"libs/test_workspace_member_helper.py",
|
||||
"libs/test_workspace_permission.py",
|
||||
"libs/test_yarl.py",
|
||||
"migrations/test_agent_drive_skill_metadata_refactor.py",
|
||||
"migrations/test_uuidv7_pg18_migration.py",
|
||||
"models/test_account_models.py",
|
||||
"models/test_agent.py",
|
||||
"models/test_app_models.py",
|
||||
"models/test_base.py",
|
||||
"models/test_conversation_variable.py",
|
||||
"models/test_dataset_models.py",
|
||||
"models/test_end_user_type.py",
|
||||
"models/test_enums_creator_user_role.py",
|
||||
"models/test_model.py",
|
||||
"models/test_plugin_entities.py",
|
||||
"models/test_provider_models.py",
|
||||
"models/test_tool_models.py",
|
||||
"models/test_types.py",
|
||||
"models/test_workflow.py",
|
||||
"models/test_workflow_models.py",
|
||||
"models/test_workflow_node_execution_offload.py",
|
||||
"oss/__mock/aliyun_oss.py",
|
||||
"oss/__mock/baidu_obs.py",
|
||||
"oss/__mock/base.py",
|
||||
"oss/__mock/local.py",
|
||||
"oss/__mock/tencent_cos.py",
|
||||
"oss/__mock/volcengine_tos.py",
|
||||
"oss/aliyun_oss/aliyun_oss/test_aliyun_oss.py",
|
||||
"oss/baidu_obs/test_baidu_obs.py",
|
||||
"oss/opendal/test_opendal.py",
|
||||
"oss/tencent_cos/test_tencent_cos.py",
|
||||
"oss/volcengine_tos/test_volcengine_tos.py",
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py",
|
||||
"services/agent/test_agent_composer_entities.py",
|
||||
"services/agent/test_agent_dsl_service.py",
|
||||
"services/agent/test_agent_observability_service.py",
|
||||
"services/agent/test_agent_services.py",
|
||||
"services/agent/test_composer_candidates.py",
|
||||
"services/agent/test_composer_mention_validation.py",
|
||||
"services/agent/test_prompt_mentions.py",
|
||||
"services/agent/test_skill_package_service.py",
|
||||
"services/agent/test_skill_standardize_service.py",
|
||||
"services/agent/test_skill_tool_inference_service.py",
|
||||
"services/agent/test_workflow_publish_service.py",
|
||||
"services/auth/test_api_key_auth_base.py",
|
||||
"services/auth/test_api_key_auth_factory.py",
|
||||
"services/auth/test_api_key_auth_service.py",
|
||||
"services/auth/test_auth_type.py",
|
||||
"services/auth/test_firecrawl_auth.py",
|
||||
"services/auth/test_jina_auth.py",
|
||||
"services/auth/test_jina_auth_standalone_module.py",
|
||||
"services/auth/test_watercrawl_auth.py",
|
||||
"services/controller_api.py",
|
||||
"services/data_migration/test_dependency_discovery_service.py",
|
||||
"services/data_migration/test_entities.py",
|
||||
"services/data_migration/test_export_service.py",
|
||||
"services/data_migration/test_import_service.py",
|
||||
"services/data_migration/test_package_service.py",
|
||||
"services/data_migration/test_report_service.py",
|
||||
"services/dataset_service_test_helpers.py",
|
||||
"services/document_service_validation.py",
|
||||
"services/enterprise/test_account_deletion_sync.py",
|
||||
"services/enterprise/test_app_permitted_service.py",
|
||||
"services/enterprise/test_enterprise_service.py",
|
||||
"services/enterprise/test_plugin_manager_service.py",
|
||||
"services/enterprise/test_rbac_service.py",
|
||||
"services/enterprise/test_traceparent_propagation.py",
|
||||
"services/hit_service.py",
|
||||
"services/openapi/test_mint_policy.py",
|
||||
"services/plugin/conftest.py",
|
||||
"services/plugin/test_dependencies_analysis.py",
|
||||
"services/plugin/test_endpoint_service.py",
|
||||
"services/plugin/test_oauth_service.py",
|
||||
"services/plugin/test_plugin_migration.py",
|
||||
"services/plugin/test_plugin_parameter_service.py",
|
||||
"services/plugin/test_plugin_service.py",
|
||||
"services/plugin/test_plugin_service_installation.py",
|
||||
"services/rag_pipeline/pipeline_template/test_built_in_retrieval.py",
|
||||
"services/rag_pipeline/pipeline_template/test_pipeline_template_base.py",
|
||||
"services/rag_pipeline/test_pipeline_generate_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_dsl_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_task_proxy.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_transform_service.py",
|
||||
"services/recommend_app/test_buildin_retrieval.py",
|
||||
"services/recommend_app/test_category_order.py",
|
||||
"services/recommend_app/test_recommend_app_factory.py",
|
||||
"services/recommend_app/test_recommend_app_type.py",
|
||||
"services/recommend_app/test_remote_retrieval.py",
|
||||
"services/retention/test_messages_clean_policy.py",
|
||||
"services/retention/workflow_run/test_archive_download_preparation.py",
|
||||
"services/retention/workflow_run/test_archive_download_task_cache.py",
|
||||
"services/retention/workflow_run/test_archive_log_service.py",
|
||||
"services/retention/workflow_run/test_bundle_archive_maintenance.py",
|
||||
"services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py",
|
||||
"services/retention/workflow_run/test_restore_archived_workflow_run.py",
|
||||
"services/test_account_service.py",
|
||||
"services/test_agent_app_feature_service.py",
|
||||
"services/test_agent_app_sandbox_service.py",
|
||||
"services/test_agent_config_service.py",
|
||||
"services/test_agent_drive_service.py",
|
||||
"services/test_agent_file_request_service.py",
|
||||
"services/test_annotation_service.py",
|
||||
"services/test_api_token_service.py",
|
||||
"services/test_app_dsl_service.py",
|
||||
"services/test_app_generate_service.py",
|
||||
"services/test_app_generate_service_streaming_integration.py",
|
||||
"services/test_app_model_config_service.py",
|
||||
"services/test_app_service.py",
|
||||
"services/test_app_task_service.py",
|
||||
"services/test_archive_workflow_run_logs.py",
|
||||
"services/test_async_workflow_service.py",
|
||||
"services/test_audio_service.py",
|
||||
"services/test_billing_service.py",
|
||||
"services/test_clear_free_plan_expired_workflow_run_logs.py",
|
||||
"services/test_clear_free_plan_tenant_expired_logs.py",
|
||||
"services/test_code_based_extension_service.py",
|
||||
"services/test_conversation_service.py",
|
||||
"services/test_credential_permission_service.py",
|
||||
"services/test_credit_pool_service.py",
|
||||
"services/test_dataset_service_dataset.py",
|
||||
"services/test_dataset_service_document.py",
|
||||
"services/test_dataset_service_lock_not_owned.py",
|
||||
"services/test_dataset_service_segment.py",
|
||||
"services/test_datasource_provider_service.py",
|
||||
"services/test_document_indexing_task_proxy.py",
|
||||
"services/test_duplicate_document_indexing_task_proxy.py",
|
||||
"services/test_export_app_messages.py",
|
||||
"services/test_external_dataset_service.py",
|
||||
"services/test_feature_service_app_dsl_version.py",
|
||||
"services/test_feature_service_enable_app_deploy.py",
|
||||
"services/test_feature_service_human_input_email_delivery.py",
|
||||
"services/test_feature_service_learn_app.py",
|
||||
"services/test_feature_service_licensed_seats.py",
|
||||
"services/test_feature_service_trial_models.py",
|
||||
"services/test_feature_service_vector_space.py",
|
||||
"services/test_feature_service_webapp_public_access.py",
|
||||
"services/test_feedback_service.py",
|
||||
"services/test_file_service.py",
|
||||
"services/test_human_input_delivery_test_service.py",
|
||||
"services/test_human_input_file_upload_service.py",
|
||||
"services/test_human_input_service.py",
|
||||
"services/test_knowledge_retrieval_inner_service.py",
|
||||
"services/test_knowledge_service.py",
|
||||
"services/test_message_service.py",
|
||||
"services/test_messages_clean_service.py",
|
||||
"services/test_metadata_nullable_bug.py",
|
||||
"services/test_metadata_service_session_boundary.py",
|
||||
"services/test_model_load_balancing_service.py",
|
||||
"services/test_model_provider_service.py",
|
||||
"services/test_model_provider_service_sanitization.py",
|
||||
"services/test_oauth_device_flow.py",
|
||||
"services/test_oauth_server_service.py",
|
||||
"services/test_operation_service.py",
|
||||
"services/test_rag_pipeline_task_proxy.py",
|
||||
"services/test_schedule_service.py",
|
||||
"services/test_snippet_dsl_service.py",
|
||||
"services/test_snippet_generate_service.py",
|
||||
"services/test_snippet_service.py",
|
||||
"services/test_step_by_step_tour_service.py",
|
||||
"services/test_summary_index_service.py",
|
||||
"services/test_telemetry_service.py",
|
||||
"services/test_trigger_provider_service.py",
|
||||
"services/test_variable_truncator.py",
|
||||
"services/test_vector_service.py",
|
||||
"services/test_webhook_service.py",
|
||||
"services/test_webhook_service_additional.py",
|
||||
"services/test_website_service.py",
|
||||
"services/test_workflow_app_service_metadata.py",
|
||||
"services/test_workflow_collaboration_service.py",
|
||||
"services/test_workflow_comment_service.py",
|
||||
"services/test_workflow_generator_service.py",
|
||||
"services/test_workflow_node_execution_trace_service.py",
|
||||
"services/test_workflow_run_service.py",
|
||||
"services/test_workflow_run_service_pause.py",
|
||||
"services/test_workflow_service.py",
|
||||
"services/tools/test_api_tools_manage_service.py",
|
||||
"services/tools/test_builtin_tools_manage_service.py",
|
||||
"services/tools/test_mcp_tools_transform.py",
|
||||
"services/tools/test_tool_labels_service.py",
|
||||
"services/tools/test_tools_manage_service.py",
|
||||
"services/tools/test_tools_transform_service.py",
|
||||
"services/workflow/test_draft_var_loader_simple.py",
|
||||
"services/workflow/test_inspector_events.py",
|
||||
"services/workflow/test_node_output_inspector_service.py",
|
||||
"services/workflow/test_queue_dispatcher.py",
|
||||
"services/workflow/test_scheduler.py",
|
||||
"services/workflow/test_workflow_converter_additional.py",
|
||||
"services/workflow/test_workflow_draft_variable_service.py",
|
||||
"services/workflow/test_workflow_event_snapshot_service.py",
|
||||
"services/workflow/test_workflow_event_snapshot_service_additional.py",
|
||||
"services/workflow/test_workflow_human_input_delivery.py",
|
||||
"services/workflow/test_workflow_restore.py",
|
||||
"tasks/test_agent_backend_session_cleanup_task.py",
|
||||
"tasks/test_async_workflow_tasks.py",
|
||||
"tasks/test_batch_clean_document_task.py",
|
||||
"tasks/test_clean_dataset_task.py",
|
||||
"tasks/test_clean_document_task.py",
|
||||
"tasks/test_community_telemetry_task.py",
|
||||
"tasks/test_dataset_indexing_task.py",
|
||||
"tasks/test_document_indexing_sync_task.py",
|
||||
"tasks/test_document_indexing_update_task.py",
|
||||
"tasks/test_duplicate_document_indexing_task.py",
|
||||
"tasks/test_enable_segment_index_tasks.py",
|
||||
"tasks/test_enterprise_telemetry_task.py",
|
||||
"tasks/test_human_input_timeout_tasks.py",
|
||||
"tasks/test_initialize_created_app_rbac_access_task.py",
|
||||
"tasks/test_install_default_plugins_task.py",
|
||||
"tasks/test_mail_human_input_delivery_task.py",
|
||||
"tasks/test_mail_send_task.py",
|
||||
"tasks/test_ops_trace_task.py",
|
||||
"tasks/test_process_tenant_plugin_autoupgrade_check_task.py",
|
||||
"tasks/test_refresh_billing_vector_space_task.py",
|
||||
"tasks/test_remove_app_and_related_data_task.py",
|
||||
"tasks/test_resume_agent_app_task.py",
|
||||
"tasks/test_summary_queue_isolation.py",
|
||||
"tasks/test_trigger_processing_tasks.py",
|
||||
"tasks/test_workflow_execute_task.py",
|
||||
"test_app_factory.py",
|
||||
"test_makefile_backend_tests.py",
|
||||
"test_pytest_dify.py",
|
||||
"tools/test_api_tool.py",
|
||||
"tools/test_mcp_tool.py",
|
||||
"utils/encryption/test_system_encryption.py",
|
||||
"utils/http_parser/test_oauth_convert_request_to_raw_data.py",
|
||||
"utils/position_helper/test_position_helper.py",
|
||||
"utils/structured_output_parser/test_structured_output_parser.py",
|
||||
"utils/test_text_processing.py",
|
||||
"utils/yaml/test_yaml_utils.py",
|
||||
]
|
||||
|
||||
[errors]
|
||||
missing-override-decorator = "error"
|
||||
redundant-cast = true
|
||||
unannotated-return = true
|
||||
unnecessary-type-conversion = true
|
||||
unused-ignore = true
|
||||
@@ -1,89 +1,105 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import (
|
||||
TenantPluginAutoUpgradeCategory,
|
||||
TenantPluginAutoUpgradeMode,
|
||||
TenantPluginAutoUpgradeStrategy,
|
||||
TenantPluginAutoUpgradeStrategySetting,
|
||||
)
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
MODULE = "services.plugin.plugin_auto_upgrade_service"
|
||||
PLUGIN_CATEGORY = TenantPluginAutoUpgradeCategory.TOOL
|
||||
STRATEGY_MODELS = (TenantPluginAutoUpgradeStrategy,)
|
||||
|
||||
|
||||
def _patched_session():
|
||||
"""Return a mock SQLAlchemy session for service calls."""
|
||||
session = MagicMock()
|
||||
return session
|
||||
def _strategy(
|
||||
tenant_id: str,
|
||||
*,
|
||||
category: TenantPluginAutoUpgradeCategory = PLUGIN_CATEGORY,
|
||||
setting: TenantPluginAutoUpgradeStrategySetting = TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
mode: TenantPluginAutoUpgradeMode = TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude: list[str] | None = None,
|
||||
include: list[str] | None = None,
|
||||
upgrade_time: int = 0,
|
||||
) -> TenantPluginAutoUpgradeStrategy:
|
||||
return TenantPluginAutoUpgradeStrategy(
|
||||
tenant_id=tenant_id,
|
||||
category=category,
|
||||
strategy_setting=setting,
|
||||
upgrade_time_of_day=upgrade_time,
|
||||
upgrade_mode=mode,
|
||||
exclude_plugins=exclude or [],
|
||||
include_plugins=include or [],
|
||||
)
|
||||
|
||||
|
||||
class TestGetStrategy:
|
||||
def test_returns_strategy_when_found(self):
|
||||
session = _patched_session()
|
||||
strategy = MagicMock()
|
||||
session.scalar.return_value = strategy
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_returns_strategy_when_found(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
strategy = _strategy(tenant_id)
|
||||
sqlite_session.add(strategy)
|
||||
sqlite_session.commit()
|
||||
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session)
|
||||
result = PluginAutoUpgradeService.get_strategy(tenant_id, PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
assert result is strategy
|
||||
|
||||
def test_returns_none_when_not_found(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is None
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_returns_none_when_not_found(self, sqlite_session: Session) -> None:
|
||||
assert PluginAutoUpgradeService.get_strategy(str(uuid4()), PLUGIN_CATEGORY, session=sqlite_session) is None
|
||||
|
||||
|
||||
class TestChangeStrategy:
|
||||
def test_creates_new_strategy(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.return_value = MagicMock()
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
"t1",
|
||||
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
3,
|
||||
TenantPluginAutoUpgradeMode.ALL,
|
||||
[],
|
||||
[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
session.add.assert_called_once()
|
||||
|
||||
def test_updates_existing_strategy(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
session.scalar.return_value = existing
|
||||
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_new_strategy(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
"t1",
|
||||
tenant_id,
|
||||
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
3,
|
||||
TenantPluginAutoUpgradeMode.ALL,
|
||||
[],
|
||||
[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy))
|
||||
assert result is True
|
||||
assert strategy is not None
|
||||
assert strategy.tenant_id == tenant_id
|
||||
assert strategy.upgrade_time_of_day == 3
|
||||
assert strategy.upgrade_mode == TenantPluginAutoUpgradeMode.ALL
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_updates_existing_strategy(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id)
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
tenant_id,
|
||||
TenantPluginAutoUpgradeStrategySetting.LATEST,
|
||||
5,
|
||||
TenantPluginAutoUpgradeMode.PARTIAL,
|
||||
["p1"],
|
||||
["p2"],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
assert result is True
|
||||
assert existing.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
|
||||
assert existing.upgrade_time_of_day == 5
|
||||
@@ -93,157 +109,115 @@ class TestChangeStrategy:
|
||||
|
||||
|
||||
class TestExcludePlugin:
|
||||
def test_creates_default_strategy_when_none_exists(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_default_strategy_when_none_exists(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.select"),
|
||||
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy"),
|
||||
):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin(
|
||||
"t1",
|
||||
"plugin-1",
|
||||
PLUGIN_CATEGORY,
|
||||
session=session,
|
||||
)
|
||||
result = PluginAutoUpgradeService.exclude_plugin(tenant_id, "plugin-1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy))
|
||||
assert result is True
|
||||
session.add.assert_called_once()
|
||||
assert strategy is not None
|
||||
assert strategy.exclude_plugins == ["plugin-1"]
|
||||
|
||||
def test_appends_to_exclude_list_in_exclude_mode(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
existing.exclude_plugins = ["p-existing"]
|
||||
session.scalar.return_value = existing
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_appends_to_exclude_list_in_exclude_mode(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, exclude=["p-existing"])
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p-new", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.exclude_plugins == ["p-existing", "p-new"]
|
||||
|
||||
def test_removes_from_include_list_in_partial_mode(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.PARTIAL
|
||||
existing.include_plugins = ["p1", "p2"]
|
||||
session.scalar.return_value = existing
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_removes_from_include_list_in_partial_mode(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.PARTIAL, include=["p1", "p2"])
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.include_plugins == ["p2"]
|
||||
|
||||
def test_switches_to_exclude_mode_from_all(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.ALL
|
||||
session.scalar.return_value = existing
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_switches_to_exclude_mode_from_all(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.ALL)
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
def test_no_duplicate_in_exclude_list(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
existing.exclude_plugins = ["p1"]
|
||||
session.scalar.return_value = existing
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_no_duplicate_in_exclude_list(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, exclude=["p1"])
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
|
||||
class TestBackfillStrategyCategories:
|
||||
def test_creates_default_missing_categories_without_fetching_daemon(self):
|
||||
session = _patched_session()
|
||||
tool_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeCategory.TOOL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude_plugins=[],
|
||||
include_plugins=[],
|
||||
)
|
||||
session.scalars.return_value.all.return_value = [tool_strategy]
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_default_missing_categories_without_fetching_daemon(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
tool_strategy = _strategy(tenant_id)
|
||||
sqlite_session.add(tool_strategy)
|
||||
sqlite_session.commit()
|
||||
installer = MagicMock()
|
||||
|
||||
with patch(f"{MODULE}.PluginInstaller", return_value=installer):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session)
|
||||
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
|
||||
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
|
||||
|
||||
strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all())
|
||||
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 1
|
||||
assert result.normalized is False
|
||||
installer.list_plugins.assert_not_called()
|
||||
assert len(strategies) == len(TenantPluginAutoUpgradeCategory)
|
||||
assert tool_strategy.upgrade_time_of_day == expected_time
|
||||
created_strategies = [call.args[0] for call in session.add.call_args_list]
|
||||
model_strategy = next(
|
||||
strategy for strategy in created_strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
|
||||
strategy for strategy in strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
|
||||
)
|
||||
assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
|
||||
assert model_strategy.upgrade_time_of_day == expected_time
|
||||
|
||||
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
|
||||
|
||||
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self) -> None:
|
||||
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day(str(uuid4()))
|
||||
assert default_time % (15 * 60) == 0
|
||||
assert 0 <= default_time < 24 * 60 * 60
|
||||
|
||||
def test_creates_missing_categories_and_splits_known_plugins(self, caplog: pytest.LogCaptureFixture):
|
||||
session = _patched_session()
|
||||
tool_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeCategory.TOOL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include_plugins=["model-plugin", "tool-plugin"],
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_missing_categories_and_splits_known_plugins(
|
||||
self, sqlite_session: Session, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
tool_strategy = _strategy(
|
||||
tenant_id,
|
||||
exclude=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include=["model-plugin", "tool-plugin"],
|
||||
)
|
||||
model_strategy = SimpleNamespace(
|
||||
model_strategy = _strategy(
|
||||
tenant_id,
|
||||
category=TenantPluginAutoUpgradeCategory.MODEL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include_plugins=["model-plugin", "tool-plugin"],
|
||||
exclude=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include=["model-plugin", "tool-plugin"],
|
||||
)
|
||||
session.scalars.return_value.all.return_value = [tool_strategy, model_strategy]
|
||||
|
||||
sqlite_session.add_all([tool_strategy, model_strategy])
|
||||
sqlite_session.commit()
|
||||
installed_plugins = [
|
||||
SimpleNamespace(
|
||||
plugin_id="tool-plugin",
|
||||
@@ -261,18 +235,17 @@ class TestBackfillStrategyCategories:
|
||||
patch(f"{MODULE}.PluginInstaller", return_value=installer),
|
||||
caplog.at_level(logging.WARNING, logger=MODULE),
|
||||
):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session)
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
|
||||
|
||||
strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all())
|
||||
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 2
|
||||
assert result.normalized is True
|
||||
assert session.add.call_count == len(TenantPluginAutoUpgradeCategory) - 2
|
||||
assert len(strategies) == len(TenantPluginAutoUpgradeCategory)
|
||||
assert tool_strategy.exclude_plugins == ["tool-plugin"]
|
||||
assert tool_strategy.include_plugins == ["tool-plugin"]
|
||||
assert model_strategy.exclude_plugins == ["model-plugin"]
|
||||
assert model_strategy.include_plugins == ["model-plugin"]
|
||||
assert (
|
||||
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
|
||||
"tenant_id=t1, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
|
||||
f"tenant_id={tenant_id}, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
|
||||
)
|
||||
|
||||
@@ -7,28 +7,19 @@ import pytest
|
||||
import zstandard
|
||||
from pydantic import TypeAdapter
|
||||
from redis import RedisError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCacheType
|
||||
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
|
||||
from models.provider import Provider, ProviderCredential, ProviderType, TenantPreferredModelProvider
|
||||
|
||||
MODULE = "core.plugin.plugin_service"
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.execute = Mock()
|
||||
self.scalars = Mock(return_value=SimpleNamespace(all=Mock(return_value=[])))
|
||||
|
||||
def __enter__(self) -> "_FakeSession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
return None
|
||||
|
||||
def begin(self) -> "_FakeSession":
|
||||
return self
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
USER_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
def _build_provider_entity(provider: str = "openai") -> ProviderEntity:
|
||||
@@ -246,6 +237,26 @@ class TestPluginModelProviderCache:
|
||||
call([cache_key]),
|
||||
]
|
||||
|
||||
def test_fetch_plugin_model_providers_bypasses_redis_when_cache_disabled(self) -> None:
|
||||
"""With the cache disabled the daemon is the only source, and Redis is never touched."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client, patch(f"{MODULE}.dify_config") as config:
|
||||
config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED = False
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
first = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
second = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert [provider.provider for provider in first] == ["langgenius/openai/openai"]
|
||||
assert [provider.provider for provider in second] == ["langgenius/openai/openai"]
|
||||
assert client.fetch_model_providers.call_count == 2
|
||||
redis_client.get.assert_not_called()
|
||||
redis_client.mget.assert_not_called()
|
||||
redis_client.setex.assert_not_called()
|
||||
redis_client.lock.assert_not_called()
|
||||
|
||||
def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None:
|
||||
"""Redis read failures do not block provider discovery for the tenant."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
@@ -1146,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
|
||||
@@ -1168,8 +1232,26 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.uninstall("tenant-1", "installation-1")
|
||||
result = PluginService.uninstall(TENANT_ID, "installation-1")
|
||||
|
||||
assert result is True
|
||||
installer.uninstall.assert_called_once_with("tenant-1", "installation-1")
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
installer.uninstall.assert_called_once_with(TENANT_ID, "installation-1")
|
||||
invalidate_cache.assert_called_once_with(TENANT_ID)
|
||||
credentials_cache.assert_called_once_with(
|
||||
tenant_id=TENANT_ID,
|
||||
identity_id=provider_id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
credentials_cache.return_value.delete.assert_called_once_with()
|
||||
|
||||
sqlite_session.expunge_all()
|
||||
assert sqlite_session.get(ProviderCredential, credential_id) is None
|
||||
persisted_provider = sqlite_session.get(Provider, provider_id)
|
||||
assert persisted_provider is not None
|
||||
assert persisted_provider.credential_id is None
|
||||
assert sqlite_session.get(TenantPreferredModelProvider, preferred_provider_id) is None
|
||||
assert sqlite_session.get(ProviderCredential, other_credential_id) is not None
|
||||
persisted_other_provider = sqlite_session.get(Provider, other_provider_id)
|
||||
assert persisted_other_provider is not None
|
||||
assert persisted_other_provider.credential_id == other_credential_id
|
||||
assert sqlite_session.get(TenantPreferredModelProvider, other_preferred_provider_id) is not None
|
||||
|
||||
@@ -8,6 +8,7 @@ verification, marketplace upgrade flows, and uninstall with credential cleanup.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -19,7 +20,6 @@ from sqlalchemy.orm import Session
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginVerification
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from models import ProviderType
|
||||
from models.engine import db
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
@@ -27,20 +27,16 @@ from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import (
|
||||
PluginInstallationPermissionModel,
|
||||
PluginInstallationScope,
|
||||
SystemFeatureModel,
|
||||
)
|
||||
|
||||
|
||||
def _make_features(
|
||||
def _make_permission(
|
||||
restrict_to_marketplace: bool = False,
|
||||
scope: PluginInstallationScope = PluginInstallationScope.ALL,
|
||||
) -> SystemFeatureModel:
|
||||
return SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
plugin_installation_permission=PluginInstallationPermissionModel(
|
||||
restrict_to_marketplace_only=restrict_to_marketplace,
|
||||
plugin_installation_scope=scope,
|
||||
),
|
||||
) -> PluginInstallationPermissionModel:
|
||||
return PluginInstallationPermissionModel(
|
||||
restrict_to_marketplace_only=restrict_to_marketplace,
|
||||
plugin_installation_scope=scope,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,22 +115,31 @@ class TestFetchLatestPluginVersion:
|
||||
class TestCheckMarketplaceOnlyPermission:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_raises_when_restricted(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=True)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=True)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_marketplace_only_permission()
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_passes_when_not_restricted(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=False)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=False)
|
||||
|
||||
PluginService._check_marketplace_only_permission() # should not raise
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_raises_when_scope_denies_all(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError, match="not allowed"):
|
||||
PluginService._check_marketplace_only_permission()
|
||||
|
||||
|
||||
class TestCheckPluginInstallationScope:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_allows_langgenius(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_ONLY
|
||||
)
|
||||
verification = MagicMock()
|
||||
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
|
||||
|
||||
@@ -142,14 +147,16 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_rejects_third_party(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_ONLY
|
||||
)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_allows_partner(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
)
|
||||
verification = MagicMock()
|
||||
@@ -159,7 +166,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_rejects_none(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
)
|
||||
|
||||
@@ -168,7 +175,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_none_scope_always_raises(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.NONE)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
|
||||
verification = MagicMock()
|
||||
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
|
||||
|
||||
@@ -177,10 +184,19 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_all_scope_passes_any(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.ALL)
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.ALL)
|
||||
|
||||
PluginService._check_plugin_installation_scope(None) # should not raise
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_unknown_scope_always_raises(self, mock_fs):
|
||||
permission = _make_permission()
|
||||
permission.plugin_installation_scope = cast(PluginInstallationScope, "unknown-scope")
|
||||
mock_fs.get_plugin_installation_permission.return_value = permission
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError, match="policy is invalid"):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
|
||||
class TestGetPluginIconUrl:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
@@ -248,7 +264,7 @@ class TestUpgradePluginWithMarketplace:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_skips_download_when_already_installed(self, mock_config, mock_installer_cls, mock_fs, mock_marketplace):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
installer.upgrade_plugin.return_value = MagicMock()
|
||||
@@ -264,7 +280,7 @@ class TestUpgradePluginWithMarketplace:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_installed(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
mock_download.return_value = b"pkg-bytes"
|
||||
@@ -283,7 +299,7 @@ class TestUpgradePluginWithGithub:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls: MagicMock, mock_fs: MagicMock):
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.upgrade_plugin.return_value = MagicMock()
|
||||
|
||||
@@ -298,7 +314,7 @@ class TestUploadPkg:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_runs_permission_and_scope_checks(self, mock_installer_cls: MagicMock, mock_fs: MagicMock):
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
upload_resp = MagicMock()
|
||||
upload_resp.verification = None
|
||||
mock_installer_cls.return_value.upload_pkg.return_value = upload_resp
|
||||
@@ -322,7 +338,7 @@ class TestInstallFromMarketplacePkg:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_cached(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
mock_download.return_value = b"pkg"
|
||||
@@ -344,7 +360,7 @@ class TestInstallFromMarketplacePkg:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls: MagicMock, mock_fs: MagicMock):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
decode_resp = MagicMock()
|
||||
|
||||
+213
-270
@@ -10,14 +10,19 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, create_autospec, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import Column, Integer, MetaData, String, Table
|
||||
from sqlalchemy import Column, Engine, Integer, MetaData, String, Table, delete, event, func, select
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from libs.archive_storage import ArchiveStorageNotConfiguredError
|
||||
from models.enums import CreatorUserRole
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
from models.workflow import (
|
||||
WorkflowAppLog,
|
||||
@@ -28,6 +33,7 @@ from models.workflow import (
|
||||
WorkflowPauseReason,
|
||||
WorkflowRun,
|
||||
)
|
||||
from services.retention.workflow_run import restore_archived_workflow_run as restore_module
|
||||
from services.retention.workflow_run.restore_archived_workflow_run import (
|
||||
SCHEMA_MAPPERS,
|
||||
TABLE_MODELS,
|
||||
@@ -36,24 +42,49 @@ from services.retention.workflow_run.restore_archived_workflow_run import (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Database:
|
||||
"""Explicit SQLite engine, caller session, and real service-owned session factory."""
|
||||
|
||||
engine: Engine
|
||||
session: Session
|
||||
session_maker: sessionmaker[Session]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
|
||||
WorkflowRun.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[WorkflowRun.__table__, WorkflowAppLog.__table__, WorkflowArchiveLog.__table__],
|
||||
)
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with session_maker() as session:
|
||||
database = Database(engine=sqlite_engine, session=session, session_maker=session_maker)
|
||||
monkeypatch.setattr(restore_module, "db", database)
|
||||
# Production constructs PostgreSQL's equivalent statement; SQLite's
|
||||
# dialect keeps the conflict behavior executable in these tests.
|
||||
monkeypatch.setattr(restore_module, "pg_insert", sqlite_insert)
|
||||
yield database
|
||||
|
||||
|
||||
class WorkflowRunRestoreTestDataFactory:
|
||||
"""
|
||||
Factory for creating test data and mock objects.
|
||||
Factory for creating persisted-model-compatible test data.
|
||||
|
||||
Provides reusable methods to create consistent mock objects for testing
|
||||
workflow run restore operations.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_run_mock(
|
||||
def create_workflow_run(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
) -> WorkflowRun:
|
||||
"""
|
||||
Create a mock WorkflowRun object.
|
||||
Create a concrete WorkflowRun object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -63,27 +94,44 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock WorkflowRun object with specified attributes
|
||||
WorkflowRun object with specified attributes
|
||||
"""
|
||||
run = create_autospec(WorkflowRun, instance=True)
|
||||
run.id = run_id
|
||||
run.tenant_id = tenant_id
|
||||
run.app_id = app_id
|
||||
run.created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
|
||||
for key, value in kwargs.items():
|
||||
setattr(run, key, value)
|
||||
attrs = {
|
||||
"id": run_id,
|
||||
"tenant_id": tenant_id,
|
||||
"app_id": app_id,
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"graph": None,
|
||||
"inputs": None,
|
||||
"status": "succeeded",
|
||||
"outputs": "{}",
|
||||
"error": None,
|
||||
"elapsed_time": 0,
|
||||
"total_tokens": 0,
|
||||
"total_steps": 0,
|
||||
"created_by_role": CreatorUserRole.ACCOUNT,
|
||||
"created_by": "user-123",
|
||||
"created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
|
||||
"finished_at": None,
|
||||
"exceptions_count": 0,
|
||||
}
|
||||
attrs.update(kwargs)
|
||||
run = WorkflowRun(**attrs)
|
||||
return run
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_archive_log_mock(
|
||||
def create_workflow_archive_log(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
) -> WorkflowArchiveLog:
|
||||
"""
|
||||
Create a mock WorkflowArchiveLog object.
|
||||
Create a concrete WorkflowArchiveLog object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -93,16 +141,32 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock WorkflowArchiveLog object with specified attributes
|
||||
WorkflowArchiveLog object with specified attributes
|
||||
"""
|
||||
archive_log = create_autospec(WorkflowArchiveLog, instance=True)
|
||||
archive_log.workflow_run_id = run_id
|
||||
archive_log.tenant_id = tenant_id
|
||||
archive_log.app_id = app_id
|
||||
archive_log.run_created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
|
||||
for key, value in kwargs.items():
|
||||
setattr(archive_log, key, value)
|
||||
return archive_log
|
||||
attrs = {
|
||||
"tenant_id": tenant_id,
|
||||
"app_id": app_id,
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": run_id,
|
||||
"created_by_role": CreatorUserRole.ACCOUNT,
|
||||
"created_by": "user-123",
|
||||
"log_id": None,
|
||||
"log_created_at": None,
|
||||
"log_created_from": None,
|
||||
"run_version": "1",
|
||||
"run_status": "succeeded",
|
||||
"run_triggered_from": "app-run",
|
||||
"run_error": None,
|
||||
"run_elapsed_time": 0,
|
||||
"run_total_tokens": 0,
|
||||
"run_total_steps": 0,
|
||||
"run_created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
|
||||
"run_finished_at": None,
|
||||
"run_exceptions_count": 0,
|
||||
"trigger_metadata": None,
|
||||
}
|
||||
attrs.update(kwargs)
|
||||
return WorkflowArchiveLog(**attrs)
|
||||
|
||||
@staticmethod
|
||||
def create_archive_zip_mock(
|
||||
@@ -137,7 +201,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -151,7 +215,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "app",
|
||||
"created_from": "service-api",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -161,7 +225,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "app",
|
||||
"created_from": "service-api",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -225,14 +289,10 @@ class TestGetWorkflowRunRepo:
|
||||
"""Tests for WorkflowRunRestore._get_workflow_run_repo method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.DifyAPIRepositoryFactory")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.db")
|
||||
def test_first_call_creates_repo(self, mock_db, mock_sessionmaker, mock_factory):
|
||||
def test_first_call_creates_repo(self, mock_factory, database: Database):
|
||||
"""First call should create and cache repository."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
mock_repo = Mock()
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
@@ -240,8 +300,9 @@ class TestGetWorkflowRunRepo:
|
||||
|
||||
assert result is mock_repo
|
||||
assert restore.workflow_run_repo is mock_repo
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(mock_session)
|
||||
session_maker = mock_factory.create_api_workflow_run_repository.call_args.args[0]
|
||||
assert isinstance(session_maker, sessionmaker)
|
||||
assert session_maker.kw["bind"] is database.engine
|
||||
|
||||
def test_cached_repo_returned(self):
|
||||
"""Subsequent calls should return cached repository."""
|
||||
@@ -492,47 +553,27 @@ class TestGetModelColumnInfo:
|
||||
class TestRestoreTableRecords:
|
||||
"""Tests for WorkflowRunRestore._restore_table_records method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.TABLE_MODELS")
|
||||
def test_unknown_table_returns_zero(self, mock_table_models, caplog: pytest.LogCaptureFixture):
|
||||
def test_unknown_table_returns_zero(self, database: Database, caplog: pytest.LogCaptureFixture):
|
||||
"""Should return 0 for unknown table."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_table_models.get.return_value = None
|
||||
|
||||
mock_session = Mock()
|
||||
records = [{"id": "test"}]
|
||||
caplog.set_level(logging.WARNING, logger="services.retention.workflow_run.restore_archived_workflow_run")
|
||||
|
||||
result = restore._restore_table_records(mock_session, "unknown_table", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(database.session, "unknown_table", records, schema_version="1.0")
|
||||
|
||||
assert result == 0
|
||||
assert "Unknown table: unknown_table" in caplog.messages
|
||||
|
||||
def test_empty_records_returns_zero(self):
|
||||
def test_empty_records_returns_zero(self, database: Database):
|
||||
"""Should return 0 for empty records list."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_session = Mock()
|
||||
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", [], schema_version="1.0")
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", [], schema_version="1.0")
|
||||
assert result == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
|
||||
def test_successful_restore(self, mock_cast, mock_pg_insert):
|
||||
def test_successful_restore(self, database: Database):
|
||||
"""Should successfully restore records."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
# Mock session and execution
|
||||
mock_session = Mock()
|
||||
mock_result = Mock()
|
||||
mock_result.rowcount = 2
|
||||
mock_session.execute.return_value = mock_result
|
||||
mock_cast.return_value = mock_result
|
||||
|
||||
# Mock insert statement
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_pg_insert.return_value = mock_stmt
|
||||
|
||||
records = [
|
||||
{
|
||||
"id": "test1",
|
||||
@@ -540,7 +581,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -552,7 +593,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -560,38 +601,20 @@ class TestRestoreTableRecords:
|
||||
},
|
||||
]
|
||||
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
|
||||
assert result == 2
|
||||
mock_session.execute.assert_called_once()
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 2
|
||||
assert restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0") == 0
|
||||
|
||||
def test_missing_required_columns_raises_error(self):
|
||||
def test_missing_required_columns_raises_error(self, database: Database):
|
||||
"""Should raise ValueError for missing required columns."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
# Use a dedicated mock model to isolate required-column validation behavior.
|
||||
mock_model = Mock()
|
||||
records = [{"id": "test"}]
|
||||
|
||||
# Mock a required column
|
||||
required_column = Mock()
|
||||
required_column.key = "required_field"
|
||||
required_column.nullable = False
|
||||
required_column.default = None
|
||||
required_column.server_default = None
|
||||
required_column.autoincrement = False
|
||||
required_column.type = Mock()
|
||||
|
||||
# Mock the __table__ attribute properly
|
||||
mock_table = Mock()
|
||||
mock_table.columns = [required_column]
|
||||
mock_model.__table__ = mock_table
|
||||
|
||||
records = [{"name": "test"}] # Missing required 'required_field'
|
||||
|
||||
with patch.dict(TABLE_MODELS, {"test_table": mock_model}):
|
||||
with pytest.raises(ValueError, match="Missing required columns for test_table"):
|
||||
restore._restore_table_records(mock_session, "test_table", records, schema_version="1.0")
|
||||
with pytest.raises(ValueError, match="Missing required columns for workflow_runs"):
|
||||
restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -603,38 +626,38 @@ class TestRestoreFromRun:
|
||||
"""Tests for WorkflowRunRestore._restore_from_run method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_archive_storage_not_configured(self, mock_get_storage):
|
||||
def test_archive_storage_not_configured(self, mock_get_storage, database: Database):
|
||||
"""Should handle ArchiveStorageNotConfiguredError."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("Storage not configured")
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is False
|
||||
assert "Storage not configured" in result.error
|
||||
assert result.elapsed_time > 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_archive_bundle_not_found(self, mock_get_storage):
|
||||
def test_archive_bundle_not_found(self, mock_get_storage, database: Database):
|
||||
"""Should handle FileNotFoundError when archive bundle is missing."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_storage = Mock()
|
||||
mock_storage.get_object.side_effect = FileNotFoundError("Bundle not found")
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is False
|
||||
assert "Archive bundle not found" in result.error
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_dry_run_mode(self, mock_get_storage):
|
||||
def test_dry_run_mode(self, mock_get_storage, database: Database):
|
||||
"""Should handle dry run mode correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -644,23 +667,16 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
# Create a proper mock session with context manager support
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts["workflow_runs"] == 1
|
||||
assert result.restored_counts["workflow_app_logs"] == 2
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
|
||||
def test_successful_restore(self, mock_cast, mock_pg_insert, mock_get_storage):
|
||||
def test_successful_restore(self, mock_get_storage, database: Database):
|
||||
"""Should successfully restore from archive."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -670,53 +686,57 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
# Mock session with context manager support
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
def session_maker():
|
||||
return mock_session
|
||||
|
||||
# Mock database execution to return integer counts
|
||||
mock_result_workflow_runs = Mock()
|
||||
mock_result_workflow_runs.rowcount = 1
|
||||
mock_result_app_logs = Mock()
|
||||
mock_result_app_logs.rowcount = 2
|
||||
|
||||
# Configure session.execute to return different results based on the table
|
||||
def mock_execute(stmt):
|
||||
if "workflow_runs" in str(stmt):
|
||||
return mock_result_workflow_runs
|
||||
else:
|
||||
return mock_result_app_logs
|
||||
|
||||
mock_session.execute.side_effect = mock_execute
|
||||
mock_cast.return_value = mock_result_workflow_runs
|
||||
|
||||
# Mock insert statement
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_pg_insert.return_value = mock_stmt
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
|
||||
# Mock repository methods
|
||||
with patch.object(restore, "_get_workflow_run_repo") as mock_get_repo:
|
||||
mock_repo = Mock()
|
||||
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
|
||||
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
|
||||
)
|
||||
mock_get_repo.return_value = mock_repo
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts["workflow_runs"] == 1
|
||||
assert result.restored_counts["workflow_app_logs"] >= 1 # Just check it's restored
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_repo.delete_archive_log_by_run_id.assert_called_once_with(mock_session, run.id)
|
||||
assert result.restored_counts["workflow_app_logs"] == 2
|
||||
database.session.expire_all()
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
|
||||
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 2
|
||||
assert database.session.scalar(select(func.count(WorkflowArchiveLog.id))) == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_invalid_archive_bundle(self, mock_get_storage):
|
||||
def test_insert_failure_rolls_back_all_tables(self, mock_get_storage, database: Database):
|
||||
"""A later table failure must roll back earlier restored rows."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_storage = Mock()
|
||||
mock_storage.get_object.return_value = WorkflowRunRestoreTestDataFactory.create_archive_zip_mock()
|
||||
mock_get_storage.return_value = mock_storage
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
def fail_app_log_insert(_connection, _cursor, statement, _parameters, _context, _executemany):
|
||||
if statement.startswith("INSERT INTO workflow_app_logs"):
|
||||
raise RuntimeError("forced app-log insert failure")
|
||||
|
||||
event.listen(database.engine, "before_cursor_execute", fail_app_log_insert)
|
||||
try:
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
finally:
|
||||
event.remove(database.engine, "before_cursor_execute", fail_app_log_insert)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "forced app-log insert failure"
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 0
|
||||
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_invalid_archive_bundle(self, mock_get_storage, database: Database):
|
||||
"""Should handle invalid archive bundle."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -725,22 +745,17 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = b"invalid zip data"
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
# Create proper mock session
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is False
|
||||
# The error message comes from zipfile.BadZipFile which says "File is not a zip file"
|
||||
assert "File is not a zip file" in result.error
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_workflow_archive_log_input(self, mock_get_storage):
|
||||
def test_workflow_archive_log_input(self, mock_get_storage, database: Database):
|
||||
"""Should handle WorkflowArchiveLog input correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -750,14 +765,11 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
|
||||
# Create proper mock session
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
result = restore._restore_from_run(archive_log, session_maker=lambda: mock_session)
|
||||
result = restore._restore_from_run(archive_log, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.run_id == archive_log.workflow_run_id
|
||||
@@ -772,39 +784,29 @@ class TestRestoreFromRun:
|
||||
class TestRestoreBatch:
|
||||
"""Tests for WorkflowRunRestore.restore_batch method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_empty_tenant_ids_returns_empty(self, mock_sessionmaker):
|
||||
def test_empty_tenant_ids_returns_empty(self, database: Database):
|
||||
"""Should return empty list when tenant_ids is empty list."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
result = restore.restore_batch(
|
||||
tenant_ids=[],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
result = restore.restore_batch(
|
||||
tenant_ids=[],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_successful_batch_restore(self, mock_executor):
|
||||
def test_successful_batch_restore(self, mock_executor, database: Database):
|
||||
"""Should successfully restore batch of workflow runs."""
|
||||
restore = WorkflowRunRestore(workers=2)
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
# Mock repository and archive logs
|
||||
mock_repo = Mock()
|
||||
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-2")
|
||||
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-2")
|
||||
database.session.add_all([archive_log1, archive_log2])
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log1, archive_log2]
|
||||
|
||||
# Mock restore results
|
||||
@@ -821,38 +823,25 @@ class TestRestoreBatch:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", side_effect=[result1, result2]):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].run_id == "run-1"
|
||||
assert results[1].run_id == "run-2"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_dry_run_batch_restore(self, mock_executor):
|
||||
def test_dry_run_batch_restore(self, mock_executor, database: Database):
|
||||
"""Should handle dry run mode for batch restore."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log]
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
|
||||
@@ -867,18 +856,11 @@ class TestRestoreBatch:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].success is True
|
||||
@@ -907,16 +889,14 @@ class TestRestoreByRunId:
|
||||
assert "not found" in result.error
|
||||
assert result.run_id == "nonexistent-run"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_successful_restore_by_id(self, mock_sessionmaker):
|
||||
def test_successful_restore_by_id(self, database: Database):
|
||||
"""Should successfully restore by run ID."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={})
|
||||
@@ -924,24 +904,19 @@ class TestRestoreByRunId:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_dry_run_restore_by_id(self, mock_sessionmaker):
|
||||
def test_dry_run_restore_by_id(self, database: Database):
|
||||
"""Should handle dry run mode for restore by ID."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
|
||||
@@ -949,10 +924,7 @@ class TestRestoreByRunId:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
@@ -1038,8 +1010,7 @@ class TestIntegration:
|
||||
"""Integration tests combining multiple components."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_full_restore_flow(self, mock_executor, mock_get_storage):
|
||||
def test_full_restore_flow(self, mock_get_storage, database: Database):
|
||||
"""Test complete restore flow with all components."""
|
||||
restore = WorkflowRunRestore(workers=1)
|
||||
|
||||
@@ -1059,7 +1030,7 @@ class TestIntegration:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -1072,48 +1043,20 @@ class TestIntegration:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.rowcount = 1
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
# Mock repository
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
# Mock ThreadPoolExecutor (not actually used in restore_by_run_id but needed for patch)
|
||||
mock_executor_instance = Mock()
|
||||
mock_executor_instance.__enter__ = Mock(return_value=mock_executor_instance)
|
||||
mock_executor_instance.__exit__ = Mock(return_value=None)
|
||||
mock_executor_instance.map = Mock(return_value=[])
|
||||
mock_executor.return_value = mock_executor_instance
|
||||
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
|
||||
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
|
||||
)
|
||||
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert") as mock_insert:
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_insert.return_value = mock_stmt
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.cast") as mock_cast:
|
||||
mock_cast.return_value = mock_result
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
result = restore.restore_by_run_id("run-123")
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
|
||||
result = restore.restore_by_run_id("run-123")
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts.get("workflow_runs") == 1
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
|
||||
|
||||
@@ -7,14 +7,16 @@ update_features persists those flags as a new app_model_config version without
|
||||
touching model / prompt / agent_mode.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode, AppModelConfig
|
||||
from services.agent_app_feature_service import AgentAppFeatureConfigService
|
||||
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
APP_ID = "22222222-2222-2222-2222-222222222222"
|
||||
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
class TestValidateFeatures:
|
||||
@@ -71,45 +73,47 @@ class TestValidateFeatures:
|
||||
AgentAppFeatureConfigService.validate_features(TENANT_ID, {"suggested_questions": "nope"})
|
||||
|
||||
|
||||
class _FakeWriteSession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[Any] = []
|
||||
self.flushed = 0
|
||||
self.committed = 0
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed += 1
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed += 1
|
||||
|
||||
|
||||
class TestUpdateFeatures:
|
||||
def test_persists_new_app_model_config_version(self):
|
||||
session = _FakeWriteSession()
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id=TENANT_ID, id="app-1", app_model_config_id=None, updated_by=None, updated_at=None
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, App, AppModelConfig)], indirect=True)
|
||||
def test_persists_new_app_model_config_version(self, sqlite_session: Session):
|
||||
app_model = App(
|
||||
id=APP_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
name="Agent App",
|
||||
description="",
|
||||
mode=AppMode.AGENT,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
account = SimpleNamespace(id="acct-1")
|
||||
account = Account(name="Test User", email="[email protected]")
|
||||
account.id = ACCOUNT_ID
|
||||
sqlite_session.add_all([account, app_model])
|
||||
sqlite_session.commit()
|
||||
|
||||
new_config = AgentAppFeatureConfigService.update_features(
|
||||
app_model=app_model, # type: ignore[arg-type]
|
||||
account=account, # type: ignore[arg-type]
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
config={"opening_statement": "Hi!", "suggested_questions_after_answer": {"enabled": True}},
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
)
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
# New row carries the features but no Soul-owned model/prompt/agent_mode.
|
||||
assert new_config.app_id == "app-1"
|
||||
assert new_config.app_id == APP_ID
|
||||
assert new_config.opening_statement == "Hi!"
|
||||
assert new_config.model is None
|
||||
assert new_config.agent_mode is None
|
||||
# App is repointed at the new version and the write is committed.
|
||||
assert app_model.app_model_config_id == new_config.id
|
||||
assert app_model.updated_by == "acct-1"
|
||||
assert new_config in session.added
|
||||
assert session.flushed == 1
|
||||
assert session.committed == 1
|
||||
assert app_model.updated_by == ACCOUNT_ID
|
||||
sqlite_session.expunge_all()
|
||||
persisted_config = sqlite_session.get(AppModelConfig, new_config.id)
|
||||
persisted_app = sqlite_session.get(App, APP_ID)
|
||||
assert persisted_config is not None
|
||||
assert persisted_config.opening_statement == "Hi!"
|
||||
assert persisted_config.model is None
|
||||
assert persisted_config.agent_mode is None
|
||||
assert persisted_app is not None
|
||||
assert persisted_app.app_model_config_id == new_config.id
|
||||
assert persisted_app.updated_by == ACCOUNT_ID
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from services import feature_service as feature_service_module
|
||||
from services.feature_service import FeatureService, PluginInstallationScope, SystemFeatureModel
|
||||
|
||||
|
||||
def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False)
|
||||
|
||||
permission = FeatureService.get_plugin_installation_permission()
|
||||
|
||||
assert permission.plugin_installation_scope is PluginInstallationScope.ALL
|
||||
assert permission.restrict_to_marketplace_only is False
|
||||
|
||||
|
||||
def test_get_plugin_installation_permission_parses_enterprise_policy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
feature_service_module.EnterpriseService,
|
||||
"get_info",
|
||||
staticmethod(
|
||||
lambda: {
|
||||
"PluginInstallationPermission": {
|
||||
"pluginInstallationScope": "official_only",
|
||||
"restrictToMarketplaceOnly": True,
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
permission = FeatureService.get_plugin_installation_permission()
|
||||
|
||||
assert permission.plugin_installation_scope is PluginInstallationScope.OFFICIAL_ONLY
|
||||
assert permission.restrict_to_marketplace_only is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_permission",
|
||||
[
|
||||
{
|
||||
"pluginInstallationScope": "unknown-scope",
|
||||
"restrictToMarketplaceOnly": False,
|
||||
},
|
||||
{
|
||||
"pluginInstallationScope": "all",
|
||||
"restrictToMarketplaceOnly": "false",
|
||||
},
|
||||
],
|
||||
ids=["unknown_scope", "non_boolean_marketplace_restriction"],
|
||||
)
|
||||
def test_invalid_enterprise_policy_denies_all_plugin_installations(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
invalid_permission: dict[str, object],
|
||||
) -> None:
|
||||
with caplog.at_level(logging.ERROR, logger="services.feature_service"):
|
||||
permission = FeatureService._resolve_plugin_installation_permission(
|
||||
{"PluginInstallationPermission": invalid_permission}
|
||||
)
|
||||
|
||||
assert permission.plugin_installation_scope is PluginInstallationScope.NONE
|
||||
assert permission.restrict_to_marketplace_only is True
|
||||
assert "denying all plugin installations" in caplog.text
|
||||
|
||||
|
||||
def test_system_features_exposes_only_validated_plugin_installation_policy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
feature_service_module.EnterpriseService,
|
||||
"get_info",
|
||||
staticmethod(
|
||||
lambda: {
|
||||
"PluginInstallationPermission": {
|
||||
"pluginInstallationScope": "unknown-scope",
|
||||
"restrictToMarketplaceOnly": False,
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE)
|
||||
|
||||
FeatureService._fulfill_params_from_enterprise(features)
|
||||
|
||||
assert features.plugin_installation_permission.plugin_installation_scope is PluginInstallationScope.NONE
|
||||
assert features.plugin_installation_permission.restrict_to_marketplace_only is True
|
||||
@@ -1,6 +1,8 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -9,6 +11,8 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.base import TypeBase
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import Account, EndUser, UploadFile
|
||||
from services.errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError
|
||||
@@ -17,31 +21,54 @@ from services.file_service import FileService
|
||||
|
||||
class TestFileService:
|
||||
@pytest.fixture
|
||||
def mock_db_session(self):
|
||||
session = MagicMock(spec=Session)
|
||||
# Mock context manager behavior
|
||||
session.__enter__.return_value = session
|
||||
return session
|
||||
def sqlite_session_maker(self, sqlite_engine: Engine) -> sessionmaker[Session]:
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[UploadFile.__tablename__]])
|
||||
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_maker(self, mock_db_session):
|
||||
maker = MagicMock(spec=sessionmaker)
|
||||
maker.return_value = mock_db_session
|
||||
return maker
|
||||
def db_session(self, sqlite_session_maker: sessionmaker[Session]) -> Iterator[Session]:
|
||||
with sqlite_session_maker() as session:
|
||||
yield session
|
||||
|
||||
@pytest.fixture
|
||||
def file_service(self, mock_session_maker):
|
||||
return FileService(session_factory=mock_session_maker)
|
||||
def file_service(self, sqlite_session_maker: sessionmaker[Session]) -> FileService:
|
||||
return FileService(session_factory=sqlite_session_maker)
|
||||
|
||||
def test_init_with_engine(self):
|
||||
engine = MagicMock(spec=Engine)
|
||||
service = FileService(session_factory=engine)
|
||||
@staticmethod
|
||||
def _persist_upload_file(
|
||||
session: Session,
|
||||
*,
|
||||
file_id: str = "file_id",
|
||||
tenant_id: str = "tenant_id",
|
||||
extension: str = "txt",
|
||||
mime_type: str = "text/plain",
|
||||
key: str = "key",
|
||||
) -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id,
|
||||
storage_type=StorageType.LOCAL,
|
||||
key=key,
|
||||
name=f"test.{extension}",
|
||||
size=10,
|
||||
extension=extension,
|
||||
mime_type=mime_type,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="user_id",
|
||||
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
used=False,
|
||||
)
|
||||
upload_file.id = file_id
|
||||
session.add(upload_file)
|
||||
session.commit()
|
||||
return upload_file
|
||||
|
||||
def test_init_with_engine(self, sqlite_engine: Engine):
|
||||
service = FileService(session_factory=sqlite_engine)
|
||||
assert isinstance(service._session_maker, sessionmaker)
|
||||
|
||||
def test_init_with_sessionmaker(self):
|
||||
maker = MagicMock(spec=sessionmaker)
|
||||
service = FileService(session_factory=maker)
|
||||
assert service._session_maker == maker
|
||||
def test_init_with_sessionmaker(self, sqlite_session_maker: sessionmaker[Session]):
|
||||
service = FileService(session_factory=sqlite_session_maker)
|
||||
assert service._session_maker == sqlite_session_maker
|
||||
|
||||
def test_init_invalid_factory(self):
|
||||
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
|
||||
@@ -52,11 +79,11 @@ class TestFileService:
|
||||
@patch("services.file_service.extract_tenant_id")
|
||||
@patch("services.file_service.file_helpers.get_signed_file_url")
|
||||
def test_upload_file_success(
|
||||
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, mock_db_session
|
||||
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, db_session: Session
|
||||
):
|
||||
# Setup
|
||||
mock_tenant_id.return_value = "tenant_id"
|
||||
mock_now.return_value = "2024-01-01"
|
||||
mock_now.return_value = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
mock_get_url.return_value = "http://signed-url"
|
||||
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -81,8 +108,9 @@ class TestFileService:
|
||||
assert result.source_url == "http://signed-url"
|
||||
|
||||
mock_storage.save.assert_called_once()
|
||||
mock_db_session.add.assert_called_once_with(result)
|
||||
mock_db_session.commit.assert_called_once()
|
||||
persisted = db_session.get(UploadFile, result.id)
|
||||
assert persisted is not None
|
||||
assert persisted.hash == result.hash
|
||||
|
||||
def test_upload_file_uses_explicit_resource_tenant(self, file_service: FileService):
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -109,7 +137,7 @@ class TestFileService:
|
||||
with pytest.raises(ValueError, match="Filename contains invalid characters"):
|
||||
file_service.upload_file(filename="invalid/file.txt", content=b"", mimetype="text/plain", user=MagicMock())
|
||||
|
||||
def test_upload_file_long_filename(self, file_service: FileService, mock_db_session):
|
||||
def test_upload_file_long_filename(self, file_service: FileService, db_session: Session):
|
||||
# Setup
|
||||
long_name = "a" * 210 + ".txt"
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -124,6 +152,7 @@ class TestFileService:
|
||||
result = file_service.upload_file(filename=long_name, content=b"test", mimetype="text/plain", user=user)
|
||||
assert len(result.name) <= 205 # 200 + . + extension
|
||||
assert result.name.endswith(".txt")
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_upload_file_blocked_extension(self, file_service):
|
||||
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe"):
|
||||
@@ -145,7 +174,7 @@ class TestFileService:
|
||||
with pytest.raises(FileTooLargeError):
|
||||
file_service.upload_file(filename="test.jpg", content=content, mimetype="image/jpeg", user=MagicMock())
|
||||
|
||||
def test_upload_file_end_user(self, file_service: FileService, mock_db_session):
|
||||
def test_upload_file_end_user(self, file_service: FileService, db_session: Session):
|
||||
user = MagicMock(spec=EndUser)
|
||||
user.id = "end_user_id"
|
||||
|
||||
@@ -157,6 +186,7 @@ class TestFileService:
|
||||
mock_tenant.return_value = "tenant"
|
||||
result = file_service.upload_file(filename="test.txt", content=b"test", mimetype="text/plain", user=user)
|
||||
assert result.created_by_role == CreatorUserRole.END_USER
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_is_file_size_within_limit(self):
|
||||
with (
|
||||
@@ -181,12 +211,8 @@ class TestFileService:
|
||||
assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True
|
||||
assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False
|
||||
|
||||
def test_get_file_base64_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "test_key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_base64_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, key="test_key")
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load_once.return_value = b"test content"
|
||||
@@ -198,16 +224,17 @@ class TestFileService:
|
||||
assert result == base64.b64encode(b"test content").decode()
|
||||
mock_storage.load_once.assert_called_once_with("test_key")
|
||||
|
||||
def test_get_file_base64_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_file_base64_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_base64("non_existent")
|
||||
|
||||
def test_get_file_presigned_url_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.key = "upload_files/tenant_id/icon.png"
|
||||
upload_file.mime_type = "image/png"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_presigned_url_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(
|
||||
db_session,
|
||||
extension="png",
|
||||
mime_type="image/png",
|
||||
key="upload_files/tenant_id/icon.png",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(dify_config, "FILES_ACCESS_TIMEOUT", 300),
|
||||
@@ -224,13 +251,11 @@ class TestFileService:
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
def test_get_file_presigned_url_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
|
||||
def test_get_file_presigned_url_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id")
|
||||
|
||||
def test_upload_text_success(self, file_service: FileService, mock_db_session):
|
||||
def test_upload_text_success(self, file_service: FileService, db_session: Session):
|
||||
# Setup
|
||||
text = "sample text"
|
||||
text_name = "test.txt"
|
||||
@@ -249,21 +274,17 @@ class TestFileService:
|
||||
assert result.used is True
|
||||
assert result.extension == "txt"
|
||||
mock_storage.save.assert_called_once()
|
||||
mock_db_session.add.assert_called_once()
|
||||
mock_db_session.commit.assert_called_once()
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_upload_text_long_name(self, file_service: FileService, mock_db_session):
|
||||
def test_upload_text_long_name(self, file_service: FileService, db_session: Session):
|
||||
long_name = "a" * 210
|
||||
with patch("services.file_service.storage"):
|
||||
result = file_service.upload_text("text", long_name, "user", "tenant")
|
||||
assert len(result.name) == 200
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_get_file_preview_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "pdf"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_preview_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="pdf", mime_type="application/pdf")
|
||||
|
||||
with patch("services.file_service.ExtractProcessor.load_from_upload_file") as mock_extract:
|
||||
mock_extract.return_value = "Extracted text content"
|
||||
@@ -274,27 +295,17 @@ class TestFileService:
|
||||
# Assert
|
||||
assert result == "Extracted text content"
|
||||
|
||||
def test_get_file_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_file_preview_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_preview("non_existent", "tenant_id")
|
||||
|
||||
def test_get_file_preview_unsupported_type(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "exe"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="exe", mime_type="application/octet-stream")
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_file_preview("file_id", "tenant_id")
|
||||
|
||||
def test_get_image_preview_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "jpg"
|
||||
upload_file.mime_type = "image/jpeg"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_image_preview_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="jpg", mime_type="image/jpeg")
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_image_signature") as mock_verify,
|
||||
@@ -316,28 +327,21 @@ class TestFileService:
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_image_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_image_preview_not_found(self, file_service: FileService):
|
||||
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
|
||||
mock_verify.return_value = True
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_image_preview_unsupported_type(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "txt"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_image_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
|
||||
mock_verify.return_value = True
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_file_generator_by_file_id_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_generator_by_file_id_success(self, file_service: FileService, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session)
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_file_signature") as mock_verify,
|
||||
@@ -348,7 +352,8 @@ class TestFileService:
|
||||
|
||||
gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
assert list(gen) == [b"chunk"]
|
||||
assert file == upload_file
|
||||
assert file.id == upload_file.id
|
||||
assert file.key == upload_file.key
|
||||
|
||||
def test_get_file_generator_by_file_id_invalid_sig(self, file_service):
|
||||
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
|
||||
@@ -356,20 +361,14 @@ class TestFileService:
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_file_generator_by_file_id_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_file_generator_by_file_id_not_found(self, file_service: FileService):
|
||||
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
|
||||
mock_verify.return_value = True
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_public_image_preview_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "png"
|
||||
upload_file.mime_type = "image/png"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_public_image_preview_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="png", mime_type="image/png")
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = b"image content"
|
||||
@@ -377,66 +376,56 @@ class TestFileService:
|
||||
assert gen == b"image content"
|
||||
assert mime == "image/png"
|
||||
|
||||
def test_get_public_image_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_public_image_preview_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_public_image_preview("file_id")
|
||||
|
||||
def test_get_public_image_preview_unsupported_type(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "txt"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_public_image_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_public_image_preview("file_id")
|
||||
|
||||
def test_get_file_content_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_get_file_content_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = b"hello world"
|
||||
result = file_service.get_file_content("file_id")
|
||||
assert result == "hello world"
|
||||
|
||||
def test_get_file_content_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_get_file_content_not_found(self, file_service: FileService):
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_content("file_id")
|
||||
|
||||
def test_delete_file_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "key"
|
||||
# For session.scalar(select(...))
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
def test_delete_file_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
file_service.delete_file("file_id")
|
||||
mock_storage.delete.assert_called_once_with("key")
|
||||
mock_db_session.delete.assert_called_once_with(upload_file)
|
||||
db_session.expire_all()
|
||||
assert db_session.get(UploadFile, "file_id") is None
|
||||
|
||||
def test_delete_file_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
def test_delete_file_not_found(self, file_service: FileService):
|
||||
file_service.delete_file("file_id")
|
||||
# Should return without doing anything
|
||||
|
||||
def test_get_upload_files_by_ids_empty(self):
|
||||
session = MagicMock()
|
||||
result = FileService.get_upload_files_by_ids("tenant_id", [], session=session)
|
||||
def test_get_upload_files_by_ids_empty(self, db_session: Session):
|
||||
result = FileService.get_upload_files_by_ids("tenant_id", [], session=db_session)
|
||||
assert result == {}
|
||||
|
||||
def test_get_upload_files_by_ids(self):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
upload_file.tenant_id = "tenant_id"
|
||||
session = MagicMock()
|
||||
session.scalars().all.return_value = [upload_file]
|
||||
def test_get_upload_files_by_ids(self, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session, file_id="550e8400-e29b-41d4-a716-446655440000")
|
||||
self._persist_upload_file(
|
||||
db_session,
|
||||
file_id="550e8400-e29b-41d4-a716-446655440001",
|
||||
tenant_id="other-tenant",
|
||||
)
|
||||
|
||||
result = FileService.get_upload_files_by_ids(
|
||||
"tenant_id", ["550e8400-e29b-41d4-a716-446655440000"], session=session
|
||||
"tenant_id",
|
||||
["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001"],
|
||||
session=db_session,
|
||||
)
|
||||
assert result["550e8400-e29b-41d4-a716-446655440000"] == upload_file
|
||||
|
||||
@@ -453,10 +442,8 @@ class TestFileService:
|
||||
used.add("a (1).txt")
|
||||
assert FileService._dedupe_zip_entry_name("a.txt", used) == "a (2).txt"
|
||||
|
||||
def test_build_upload_files_zip_tempfile(self):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.name = "test.txt"
|
||||
upload_file.key = "key"
|
||||
def test_build_upload_files_zip_tempfile(self, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session)
|
||||
|
||||
with (
|
||||
patch("services.file_service.storage") as mock_storage,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -42,14 +41,13 @@ from services.human_input_service import (
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session_factory(sqlite_engine: Engine) -> Iterator[tuple[sessionmaker[Session], Session]]:
|
||||
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with factory() as session:
|
||||
yield factory, session
|
||||
def unbound_session_factory() -> sessionmaker[Session]:
|
||||
"""Supply the required constructor dependency without enabling database access."""
|
||||
return sessionmaker()
|
||||
|
||||
|
||||
def _persist_app(sqlite_session: Session, mode: AppMode) -> App:
|
||||
app = App(
|
||||
def _make_app(mode: AppMode) -> App:
|
||||
return App(
|
||||
id="app-id",
|
||||
tenant_id="tenant-id",
|
||||
name="Test App",
|
||||
@@ -60,9 +58,6 @@ def _persist_app(sqlite_session: Session, mode: AppMode) -> App:
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
sqlite_session.add(app)
|
||||
sqlite_session.commit()
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -97,14 +92,11 @@ def sample_form_record():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -116,7 +108,8 @@ def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
_persist_app(sqlite_session, AppMode.WORKFLOW)
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.WORKFLOW))
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -128,10 +121,9 @@ def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
|
||||
|
||||
def test_ensure_form_active_respects_global_timeout(
|
||||
monkeypatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
monkeypatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
expired_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
created_at=naive_utc_now() - timedelta(hours=2),
|
||||
@@ -143,14 +135,11 @@ def test_ensure_form_active_respects_global_timeout(
|
||||
service.ensure_form_active(Form(expired_record))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -162,7 +151,8 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
_persist_app(sqlite_session, AppMode.ADVANCED_CHAT)
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.ADVANCED_CHAT))
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -173,14 +163,11 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -192,7 +179,8 @@ def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
_persist_app(sqlite_session, AppMode.COMPLETION)
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.COMPLETION))
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -202,14 +190,13 @@ def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_for_console_uses_repository(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
console_record = dataclasses.replace(sample_form_record, recipient_type=RecipientType.CONSOLE)
|
||||
repo.get_by_token.return_value = console_record
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
form = service.get_form_definition_by_token_for_console("token")
|
||||
|
||||
repo.get_by_token.assert_called_once_with("token")
|
||||
@@ -245,9 +232,8 @@ def _build_resumption_context_state(*, options: list[str], workflow_run_id: str)
|
||||
|
||||
|
||||
def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
configured_input = SelectInputConfig(
|
||||
output_variable_name="decision",
|
||||
option_source=StringListSource(
|
||||
@@ -272,7 +258,7 @@ def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
"services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
resolved_inputs = service.resolve_form_inputs(Form(record))
|
||||
|
||||
@@ -284,13 +270,12 @@ def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
|
||||
|
||||
def test_submit_form_by_token_calls_repository_and_enqueue(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
repo.mark_submitted.return_value = sample_form_record
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -313,11 +298,10 @@ def test_submit_form_by_token_calls_repository_and_enqueue(
|
||||
|
||||
|
||||
def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
sample_form_record, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
# ENG-635: a conversation-owned (Agent v2 chat) form routes to the chat
|
||||
# resume, not the workflow resume.
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
conversation_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
@@ -326,7 +310,7 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
)
|
||||
repo.get_by_token.return_value = conversation_record
|
||||
repo.mark_submitted.return_value = conversation_record
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
workflow_enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
chat_enqueue_spy = mocker.patch.object(service, "enqueue_agent_app_resume")
|
||||
|
||||
@@ -343,9 +327,8 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
|
||||
|
||||
def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
test_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
@@ -354,7 +337,7 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
)
|
||||
repo.get_by_token.return_value = test_record
|
||||
repo.mark_submitted.return_value = test_record
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -368,13 +351,12 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
|
||||
|
||||
def test_submit_form_by_token_passes_submission_user_id(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
repo.mark_submitted.return_value = sample_form_record
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -391,11 +373,10 @@ def test_submit_form_by_token_passes_submission_user_id(
|
||||
enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id)
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -409,8 +390,7 @@ def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormR
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
|
||||
definition_with_input = FormDefinition(
|
||||
@@ -422,7 +402,7 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
|
||||
)
|
||||
form_with_input = dataclasses.replace(sample_form_record, definition=definition_with_input)
|
||||
repo.get_by_token.return_value = form_with_input
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -436,42 +416,6 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_human_input_submission_accepts_select_file_and_file_list(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
"form_content": "Pick one and upload files",
|
||||
"inputs": [
|
||||
{
|
||||
"type": "select",
|
||||
"output_variable_name": "decision",
|
||||
"option_source": {
|
||||
"type": "constant",
|
||||
"value": ["approve", "reject"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"output_variable_name": "attachment",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
},
|
||||
{
|
||||
"type": "file-list",
|
||||
"output_variable_name": "attachments",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
"number_limits": 3,
|
||||
},
|
||||
],
|
||||
"user_actions": [{"id": "submit", "title": "Submit"}],
|
||||
"rendered_content": "<p>Pick one and upload files</p>",
|
||||
"expiration_time": naive_utc_now() + timedelta(hours=1),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_definition", "submitted_value", "expected_message"),
|
||||
[
|
||||
@@ -522,12 +466,11 @@ def test_validate_human_input_submission_accepts_select_file_and_file_list(sqlit
|
||||
)
|
||||
def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads(
|
||||
sample_form_record,
|
||||
sqlite_session_factory,
|
||||
unbound_session_factory,
|
||||
input_definition,
|
||||
submitted_value,
|
||||
expected_message,
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
@@ -539,7 +482,7 @@ def test_validate_human_input_submission_rejects_invalid_select_and_file_payload
|
||||
}
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -569,7 +512,7 @@ def test_form_properties(sample_form_record: HumanInputFormRecord):
|
||||
|
||||
def test_form_submitted_error_init():
|
||||
error = FormSubmittedError(form_id="test-form")
|
||||
assert "form_id=test-form" in error.description
|
||||
assert error.description == "This form has already been submitted by another user, form_id=test-form"
|
||||
assert error.code == 412
|
||||
|
||||
|
||||
@@ -580,61 +523,55 @@ def test_human_input_service_init_with_engine(sqlite_engine: Engine):
|
||||
assert service._session_factory.kw["bind"] is sqlite_engine
|
||||
|
||||
|
||||
def test_get_form_by_token_none(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_get_form_by_token_none(unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
assert service.get_form_by_token("invalid") is None
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
# RecipientType mismatch
|
||||
assert service.get_form_definition_by_token(RecipientType.CONSOLE, "token") is None
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
form = service.get_form_definition_by_token(RecipientType.STANDALONE_WEB_APP, "token")
|
||||
assert form is not None
|
||||
assert form.id == sample_form_record.form_id
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_for_console_mismatch(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record # is STANDALONE_WEB_APP
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
assert service.get_form_definition_by_token_for_console("token") is None
|
||||
|
||||
|
||||
def test_submit_form_by_token_delivery_not_enabled(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
def test_submit_form_by_token_delivery_not_enabled(unbound_session_factory):
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
with pytest.raises(human_input_service_module.WebAppDeliveryNotEnabledError):
|
||||
service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "action", {})
|
||||
|
||||
|
||||
def test_submit_form_by_token_no_workflow_run_id(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
@@ -642,16 +579,15 @@ def test_submit_form_by_token_no_workflow_run_id(
|
||||
result_record = dataclasses.replace(sample_form_record, workflow_run_id=None)
|
||||
repo.mark_submitted.return_value = result_record
|
||||
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "submit", {})
|
||||
enqueue_spy.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
# Submitted
|
||||
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
|
||||
@@ -671,18 +607,16 @@ def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, sql
|
||||
service.ensure_form_active(Form(expired_time_record))
|
||||
|
||||
|
||||
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
|
||||
|
||||
with pytest.raises(human_input_service_module.FormSubmittedError):
|
||||
service._ensure_not_submitted(Form(submitted_record))
|
||||
|
||||
|
||||
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
workflow_run_repo = MagicMock()
|
||||
workflow_run_repo.get_workflow_run_by_id_without_tenant.return_value = None
|
||||
@@ -696,15 +630,12 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, sqlite_session
|
||||
assert "WorkflowRun not found" in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_app_not_found(
|
||||
mocker,
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -715,26 +646,31 @@ def test_enqueue_resume_app_not_found(
|
||||
"services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="services.human_input_service"):
|
||||
service.enqueue_resume("workflow-run-id")
|
||||
assert any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||
|
||||
assert (
|
||||
"services.human_input_service",
|
||||
logging.ERROR,
|
||||
"App not found for WorkflowRun, workflow_run_id=workflow-run-id, app_id=app-id",
|
||||
) in caplog.record_tuples
|
||||
resume_task.apply_async.assert_not_called()
|
||||
|
||||
|
||||
def test_is_globally_expired_zero_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
|
||||
assert service._is_globally_expired(Form(sample_form_record)) is False
|
||||
|
||||
|
||||
def test_submit_form_by_token_normalizes_select_and_files(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -753,7 +689,7 @@ def test_submit_form_by_token_normalizes_select_and_files(
|
||||
form_with_inputs = dataclasses.replace(sample_form_record, definition=definition)
|
||||
repo.get_by_token.return_value = form_with_inputs
|
||||
repo.mark_submitted.return_value = form_with_inputs
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
single_file = File(
|
||||
file_id="file-1",
|
||||
@@ -815,9 +751,8 @@ def test_submit_form_by_token_normalizes_select_and_files(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_select_value(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -832,7 +767,7 @@ def test_submit_form_by_token_invalid_select_value(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"):
|
||||
service.submit_form_by_token(
|
||||
@@ -844,9 +779,8 @@ def test_submit_form_by_token_invalid_select_value(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_file_list_item(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -856,7 +790,7 @@ def test_submit_form_by_token_invalid_file_list_item(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(
|
||||
InvalidFormDataError,
|
||||
@@ -871,9 +805,8 @@ def test_submit_form_by_token_invalid_file_list_item(
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -883,7 +816,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
mocker.patch("services.human_input_service.build_from_mapping", side_effect=ValueError("Invalid upload file"))
|
||||
|
||||
with pytest.raises(InvalidFormDataError, match="Invalid value for file input 'attachment'"):
|
||||
@@ -904,9 +837,8 @@ def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file_list(
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -916,7 +848,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file_list(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file"))
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
+42
-32
@@ -1,16 +1,20 @@
|
||||
"""Testcontainers integration tests for OAuthServerService."""
|
||||
"""Unit tests for OAuthServerService with SQLite-backed database access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from models.engine import db
|
||||
from models.model import OAuthProviderApp
|
||||
from services.oauth_server import (
|
||||
OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
@@ -23,10 +27,24 @@ from services.oauth_server import (
|
||||
)
|
||||
|
||||
|
||||
class TestOAuthServerServiceGetProviderApp:
|
||||
"""DB-backed tests for get_oauth_provider_app."""
|
||||
@pytest.fixture
|
||||
def oauth_db() -> Iterator[Session]:
|
||||
"""Provide the production database extension with an isolated SQLite provider table."""
|
||||
app = Flask(__name__)
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
db.init_app(app)
|
||||
|
||||
def _create_oauth_provider_app(self, db_session_with_containers: Session, *, client_id: str) -> OAuthProviderApp:
|
||||
with app.app_context():
|
||||
OAuthProviderApp.__table__.create(db.engine)
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
|
||||
class TestOAuthServerServiceGetProviderApp:
|
||||
"""Verify provider lookup against a real SQLAlchemy database."""
|
||||
|
||||
def test_get_oauth_provider_app_returns_app_when_exists(self, oauth_db: Session) -> None:
|
||||
client_id = f"client-{uuid4()}"
|
||||
app = OAuthProviderApp(
|
||||
app_icon="icon.png",
|
||||
client_id=client_id,
|
||||
@@ -35,35 +53,30 @@ class TestOAuthServerServiceGetProviderApp:
|
||||
redirect_uris=["https://example.com/callback"],
|
||||
scope="read",
|
||||
)
|
||||
db_session_with_containers.add(app)
|
||||
db_session_with_containers.commit()
|
||||
return app
|
||||
|
||||
def test_get_oauth_provider_app_returns_app_when_exists(self, db_session_with_containers: Session):
|
||||
client_id = f"client-{uuid4()}"
|
||||
created = self._create_oauth_provider_app(db_session_with_containers, client_id=client_id)
|
||||
oauth_db.add(app)
|
||||
oauth_db.commit()
|
||||
|
||||
result = OAuthServerService.get_oauth_provider_app(client_id)
|
||||
|
||||
assert result is not None
|
||||
assert result.client_id == client_id
|
||||
assert result.id == created.id
|
||||
assert result.id == app.id
|
||||
|
||||
def test_get_oauth_provider_app_returns_none_when_not_exists(self, db_session_with_containers: Session):
|
||||
def test_get_oauth_provider_app_returns_none_when_not_exists(self, oauth_db: Session) -> None:
|
||||
result = OAuthServerService.get_oauth_provider_app(f"nonexistent-{uuid4()}")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestOAuthServerServiceTokenOperations:
|
||||
"""Redis-backed tests for token sign/validate operations."""
|
||||
"""Verify Redis-backed token signing and validation branches."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self):
|
||||
with patch("services.oauth_server.redis_client") as mock:
|
||||
yield mock
|
||||
|
||||
def test_sign_authorization_code_stores_and_returns_code(self, mock_redis):
|
||||
def test_sign_authorization_code_stores_and_returns_code(self, mock_redis) -> None:
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000111")
|
||||
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
|
||||
code = OAuthServerService.sign_oauth_authorization_code("client-1", "user-1")
|
||||
@@ -75,7 +88,7 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=600,
|
||||
)
|
||||
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis):
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis) -> None:
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
with pytest.raises(BadRequest, match="invalid code"):
|
||||
@@ -85,14 +98,13 @@ class TestOAuthServerServiceTokenOperations:
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis):
|
||||
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis) -> None:
|
||||
token_uuids = [
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000201"),
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000202"),
|
||||
]
|
||||
with patch("services.oauth_server.uuid.uuid4", side_effect=token_uuids):
|
||||
mock_redis.get.return_value = b"user-1"
|
||||
|
||||
access_token, refresh_token = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.AUTHORIZATION_CODE,
|
||||
code="code-1",
|
||||
@@ -114,7 +126,7 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis):
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis) -> None:
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
with pytest.raises(BadRequest, match="invalid refresh token"):
|
||||
@@ -124,11 +136,10 @@ class TestOAuthServerServiceTokenOperations:
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis):
|
||||
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis) -> None:
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000301")
|
||||
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
|
||||
mock_redis.get.return_value = b"user-1"
|
||||
|
||||
access_token, returned_refresh = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.REFRESH_TOKEN,
|
||||
refresh_token="refresh-1",
|
||||
@@ -138,14 +149,14 @@ class TestOAuthServerServiceTokenOperations:
|
||||
assert access_token == str(deterministic_uuid)
|
||||
assert returned_refresh == "refresh-1"
|
||||
|
||||
def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis):
|
||||
def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis) -> None:
|
||||
grant_type = cast(OAuthGrantType, "invalid-grant-type")
|
||||
|
||||
result = OAuthServerService.sign_oauth_access_token(grant_type=grant_type, client_id="client-1")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis):
|
||||
def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis) -> None:
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000401")
|
||||
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
|
||||
refresh_token = OAuthServerService._sign_oauth_refresh_token("client-2", "user-2")
|
||||
@@ -157,22 +168,21 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, db_session_with_containers: Session):
|
||||
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, sqlite_engine: Engine) -> None:
|
||||
mock_redis.get.return_value = None
|
||||
session = MagicMock()
|
||||
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", db_session_with_containers)
|
||||
with Session(sqlite_engine) as session:
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", session)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_validate_access_token_loads_user_when_exists(self, mock_redis, db_session_with_containers: Session):
|
||||
def test_validate_access_token_loads_user_when_exists(self, mock_redis, sqlite_engine: Engine) -> None:
|
||||
mock_redis.get.return_value = b"user-88"
|
||||
expected_user = MagicMock()
|
||||
|
||||
with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load:
|
||||
result = OAuthServerService.validate_oauth_access_token(
|
||||
"client-1", "access-token", db_session_with_containers
|
||||
)
|
||||
with Session(sqlite_engine) as session:
|
||||
with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load:
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "access-token", session)
|
||||
mock_load.assert_called_once_with("user-88", session)
|
||||
|
||||
assert result is expected_user
|
||||
mock_load.assert_called_once_with("user-88", db_session_with_containers)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Unit tests for workflow app log views and trigger metadata helpers."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.enums import AppTriggerType, CreatorUserRole
|
||||
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom
|
||||
from services.workflow_app_service import LogView, WorkflowAppService
|
||||
|
||||
|
||||
class TestLogView:
|
||||
def test_details_and_proxy_attributes(self) -> None:
|
||||
log = WorkflowAppLog(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="run-1",
|
||||
created_from=WorkflowAppLogCreatedFrom.WEB_APP,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
)
|
||||
log.id = "log-1"
|
||||
|
||||
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
|
||||
|
||||
assert view.details == {"trigger_metadata": {"type": "plugin"}}
|
||||
assert view.id == "log-1"
|
||||
|
||||
|
||||
class TestHandleTriggerMetadata:
|
||||
def test_returns_empty_dict_when_metadata_missing(self) -> None:
|
||||
assert WorkflowAppService().handle_trigger_metadata("tenant-1", None) == {}
|
||||
|
||||
def test_enriches_plugin_icons(self) -> None:
|
||||
metadata = {
|
||||
"type": AppTriggerType.TRIGGER_PLUGIN.value,
|
||||
"icon_filename": "light.png",
|
||||
"icon_dark_filename": "dark.png",
|
||||
}
|
||||
with patch(
|
||||
"services.workflow_app_service.PluginService.get_plugin_icon_url",
|
||||
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
|
||||
) as mock_icon:
|
||||
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
|
||||
|
||||
assert result["icon"] == "https://cdn/light.png"
|
||||
assert result["icon_dark"] == "https://cdn/dark.png"
|
||||
assert mock_icon.call_count == 2
|
||||
|
||||
def test_non_plugin_metadata_without_icon_lookup(self) -> None:
|
||||
metadata = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
|
||||
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
|
||||
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
|
||||
|
||||
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
|
||||
mock_icon.assert_not_called()
|
||||
|
||||
|
||||
class TestSafeJsonLoads:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
('{"k":"v"}', {"k": "v"}),
|
||||
("not-json", None),
|
||||
({"raw": True}, {"raw": True}),
|
||||
],
|
||||
)
|
||||
def test_handles_various_inputs(self, value, expected) -> None:
|
||||
assert WorkflowAppService._safe_json_loads(value) == expected
|
||||
|
||||
|
||||
class TestSafeParseUuid:
|
||||
def test_returns_none_for_short_or_invalid_values(self) -> None:
|
||||
assert WorkflowAppService._safe_parse_uuid("short") is None
|
||||
assert WorkflowAppService._safe_parse_uuid("x" * 40) is None
|
||||
|
||||
def test_returns_uuid_for_valid_string(self) -> None:
|
||||
raw = str(uuid.uuid4())
|
||||
|
||||
result = WorkflowAppService._safe_parse_uuid(raw)
|
||||
|
||||
assert result is not None
|
||||
assert str(result) == raw
|
||||
@@ -1,178 +1,57 @@
|
||||
"""Comprehensive unit tests for WorkflowRunService class.
|
||||
"""Tests for the session lifecycle owned by ``WorkflowRunService``."""
|
||||
|
||||
This test suite covers all pause state management operations including:
|
||||
- Retrieving pause state for workflow runs
|
||||
- Saving pause state with file uploads
|
||||
- Marking paused workflows as resumed
|
||||
- Error handling and edge cases
|
||||
- Database transaction management
|
||||
- Repository-based approach testing
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, create_autospec, patch
|
||||
from unittest.mock import create_autospec, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy import Engine, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models.workflow import WorkflowPause
|
||||
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import _PrivateWorkflowPauseEntity
|
||||
from services.workflow_run_service import (
|
||||
WorkflowRunService,
|
||||
)
|
||||
from services.workflow_run_service import WorkflowRunService
|
||||
|
||||
|
||||
class TestDataFactory:
|
||||
"""Factory class for creating test data objects."""
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_run_mock(
|
||||
id: str = "workflow-run-123",
|
||||
tenant_id: str = "tenant-456",
|
||||
app_id: str = "app-789",
|
||||
workflow_id: str = "workflow-101",
|
||||
status: str | WorkflowExecutionStatus = "paused",
|
||||
**kwargs,
|
||||
) -> MagicMock:
|
||||
"""Create a mock WorkflowRun object."""
|
||||
mock_run = MagicMock()
|
||||
mock_run.id = id
|
||||
mock_run.tenant_id = tenant_id
|
||||
mock_run.app_id = app_id
|
||||
mock_run.workflow_id = workflow_id
|
||||
mock_run.status = status
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(mock_run, key, value)
|
||||
|
||||
return mock_run
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_pause_mock(
|
||||
id: str = "pause-123",
|
||||
tenant_id: str = "tenant-456",
|
||||
app_id: str = "app-789",
|
||||
workflow_id: str = "workflow-101",
|
||||
workflow_execution_id: str = "workflow-execution-123",
|
||||
state_file_id: str = "file-456",
|
||||
resumed_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> MagicMock:
|
||||
"""Create a mock WorkflowPauseModel object."""
|
||||
mock_pause = MagicMock(spec=WorkflowPause)
|
||||
mock_pause.id = id
|
||||
mock_pause.tenant_id = tenant_id
|
||||
mock_pause.app_id = app_id
|
||||
mock_pause.workflow_id = workflow_id
|
||||
mock_pause.workflow_execution_id = workflow_execution_id
|
||||
mock_pause.state_file_id = state_file_id
|
||||
mock_pause.resumed_at = resumed_at
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(mock_pause, key, value)
|
||||
|
||||
return mock_pause
|
||||
|
||||
@staticmethod
|
||||
def create_pause_entity_mock(
|
||||
pause_model: MagicMock | None = None,
|
||||
) -> _PrivateWorkflowPauseEntity:
|
||||
"""Create a mock _PrivateWorkflowPauseEntity object."""
|
||||
if pause_model is None:
|
||||
pause_model = TestDataFactory.create_workflow_pause_mock()
|
||||
|
||||
return _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[])
|
||||
@pytest.fixture
|
||||
def sqlite_session_factory(sqlite_engine: Engine) -> sessionmaker[Session]:
|
||||
"""Return a real factory whose sessions are bound to the isolated SQLite engine."""
|
||||
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class TestWorkflowRunService:
|
||||
"""Comprehensive unit tests for WorkflowRunService class."""
|
||||
@pytest.fixture
|
||||
def workflow_run_repository():
|
||||
"""Keep the repository boundary mocked while exercising real session construction."""
|
||||
return create_autospec(APIWorkflowRunRepository)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_factory(self):
|
||||
"""Create a mock session factory with proper session management."""
|
||||
mock_session = create_autospec(Session)
|
||||
|
||||
# Create a mock context manager for the session
|
||||
mock_session_cm = MagicMock()
|
||||
mock_session_cm.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_session_cm.__exit__ = MagicMock(return_value=None)
|
||||
def test_init_with_session_factory(
|
||||
sqlite_session_factory: sessionmaker[Session], workflow_run_repository: APIWorkflowRunRepository
|
||||
) -> None:
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as repository_factory:
|
||||
repository_factory.create_api_workflow_run_repository.return_value = workflow_run_repository
|
||||
|
||||
# Create a mock context manager for the transaction
|
||||
mock_transaction_cm = MagicMock()
|
||||
mock_transaction_cm.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_transaction_cm.__exit__ = MagicMock(return_value=None)
|
||||
service = WorkflowRunService(sqlite_session_factory)
|
||||
|
||||
mock_session.begin = MagicMock(return_value=mock_transaction_cm)
|
||||
assert service._session_factory is sqlite_session_factory
|
||||
repository_factory.create_api_workflow_run_repository.assert_called_once_with(sqlite_session_factory)
|
||||
with service._session_factory() as session:
|
||||
assert session.scalar(text("SELECT 1")) == 1
|
||||
|
||||
# Create mock factory that returns the context manager
|
||||
mock_factory = MagicMock(spec=sessionmaker)
|
||||
mock_factory.return_value = mock_session_cm
|
||||
|
||||
return mock_factory, mock_session
|
||||
def test_init_with_engine_creates_bound_session_factory(
|
||||
sqlite_engine: Engine, workflow_run_repository: APIWorkflowRunRepository
|
||||
) -> None:
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as repository_factory:
|
||||
repository_factory.create_api_workflow_run_repository.return_value = workflow_run_repository
|
||||
|
||||
@pytest.fixture
|
||||
def mock_workflow_run_repository(self):
|
||||
"""Create a mock APIWorkflowRunRepository."""
|
||||
mock_repo = create_autospec(APIWorkflowRunRepository)
|
||||
return mock_repo
|
||||
service = WorkflowRunService(sqlite_engine)
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_run_service(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Create WorkflowRunService instance with mocked dependencies."""
|
||||
session_factory, _ = mock_session_factory
|
||||
assert service._session_factory.kw["bind"] is sqlite_engine
|
||||
assert service._session_factory.kw["expire_on_commit"] is False
|
||||
repository_factory.create_api_workflow_run_repository.assert_called_once_with(service._session_factory)
|
||||
with service._session_factory() as session:
|
||||
assert session.scalar(text("SELECT 1")) == 1
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
service = WorkflowRunService(session_factory)
|
||||
return service
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_run_service_with_engine(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Create WorkflowRunService instance with Engine input."""
|
||||
mock_engine = create_autospec(Engine)
|
||||
session_factory, _ = mock_session_factory
|
||||
def test_init_with_default_repository_dependencies(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
service = WorkflowRunService(sqlite_session_factory)
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
service = WorkflowRunService(mock_engine)
|
||||
return service
|
||||
|
||||
# ==================== Initialization Tests ====================
|
||||
|
||||
def test_init_with_session_factory(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Test WorkflowRunService initialization with session_factory."""
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
service = WorkflowRunService(session_factory)
|
||||
|
||||
assert service._session_factory == session_factory
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory)
|
||||
|
||||
def test_init_with_engine(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Test WorkflowRunService initialization with Engine (should convert to sessionmaker)."""
|
||||
mock_engine = create_autospec(Engine)
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
with patch(
|
||||
"services.workflow_run_service.sessionmaker", return_value=session_factory, autospec=True
|
||||
) as mock_sessionmaker:
|
||||
service = WorkflowRunService(mock_engine)
|
||||
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_engine, expire_on_commit=False)
|
||||
assert service._session_factory == session_factory
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory)
|
||||
|
||||
def test_init_with_default_dependencies(self, mock_session_factory):
|
||||
"""Test WorkflowRunService initialization with default dependencies."""
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
service = WorkflowRunService(session_factory)
|
||||
|
||||
assert service._session_factory == session_factory
|
||||
assert service._session_factory is sqlite_session_factory
|
||||
|
||||
@@ -135,6 +135,19 @@ class TestMCPToolInvoke:
|
||||
values = {m.message.variable_name: m.message.variable_value for m in var_msgs}
|
||||
assert values == {"a": 1, "b": "x"}
|
||||
|
||||
def test_invoke_yields_json_when_structured_content_has_no_output_schema(self, orm_session: Session) -> None:
|
||||
tool = _make_mcp_tool()
|
||||
result = CallToolResult(content=[], structuredContent={"a": 1, "b": "x"})
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
assert len(messages) == 1
|
||||
msg = messages[0]
|
||||
assert msg.type == ToolInvokeMessage.MessageType.JSON
|
||||
assert isinstance(msg.message, ToolInvokeMessage.JsonMessage)
|
||||
assert msg.message.json_object == {"a": 1, "b": "x"}
|
||||
|
||||
|
||||
class TestMCPToolUsageExtraction:
|
||||
"""Test usage metadata extraction from MCP tool results."""
|
||||
|
||||
Generated
+7
-6
@@ -1281,7 +1281,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { editable = "../dify-agent" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1331,7 +1331,7 @@ docs = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
@@ -1738,7 +1738,7 @@ storage = [
|
||||
]
|
||||
tools = [
|
||||
{ name = "cloudscraper", specifier = ">=1.2.71,<2.0.0" },
|
||||
{ name = "nltk", specifier = ">=3.9.1,<4.0.0" },
|
||||
{ name = "nltk", specifier = ">=3.10.0,<4.0.0" },
|
||||
]
|
||||
trace-aliyun = [{ name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }]
|
||||
trace-all = [
|
||||
@@ -4113,17 +4113,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nltk"
|
||||
version = "3.9.4"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "joblib" },
|
||||
{ name = "regex" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
"channel": "alpha",
|
||||
"compat": {
|
||||
"minDify": "1.16.0",
|
||||
"maxDify": "1.16.0"
|
||||
"maxDify": "1.16.1"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
|
||||
@@ -114,9 +114,15 @@ function die(msg) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Tests point this at a fixture manifest so their assertions stay fixed while
|
||||
// the real version and compat window move with every release. The name is
|
||||
// mirrored in test/fixtures/pkg-manifest.ts rather than imported from here,
|
||||
// because this file's shebang breaks the Windows test runner.
|
||||
const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH'
|
||||
|
||||
function loadPkg() {
|
||||
const pkgUrl = new URL('../package.json', import.meta.url)
|
||||
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8'))
|
||||
const pkgPath = process.env[PKG_PATH_ENV] || new URL('../package.json', import.meta.url)
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
|
||||
return {
|
||||
version: pkg.version,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
|
||||
|
||||
function run(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
function run(
|
||||
args: string[],
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' })
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
return { code: 0, stdout, stderr: '' }
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: string; stderr?: string }
|
||||
@@ -14,45 +21,50 @@ function run(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
}
|
||||
}
|
||||
|
||||
describe('release-naming compat-check (compat 1.16.0..1.16.0)', () => {
|
||||
describe('release-naming compat-check', () => {
|
||||
const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0
|
||||
const pkgEnv = pkgManifestEnv()
|
||||
const compatCheck = (difyVersion?: string) =>
|
||||
run(difyVersion === undefined ? ['compat-check'] : ['compat-check', difyVersion], pkgEnv).code
|
||||
|
||||
it('accepts a version inside the window', () => {
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
expect(compatCheck('2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
expect(compatCheck(minDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
expect(compatCheck(maxDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(run(['compat-check', 'v1.16.0']).code).toBe(0)
|
||||
expect(compatCheck('v2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(run(['compat-check', '1.15.9']).code).not.toBe(0)
|
||||
expect(compatCheck('1.9.9')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(run(['compat-check', '1.16.1']).code).not.toBe(0)
|
||||
expect(compatCheck('2.5.1')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the bound as below it (1.16.0-rc1 < 1.16.0)', () => {
|
||||
expect(run(['compat-check', '1.16.0-rc1']).code).not.toBe(0)
|
||||
it('treats a prerelease of the lower bound as below it', () => {
|
||||
expect(compatCheck(`${minDify}-rc1`)).not.toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata on the bound (1.16.0+build == 1.16.0)', () => {
|
||||
expect(run(['compat-check', '1.16.0+build123']).code).toBe(0)
|
||||
it('ignores build metadata on the bound', () => {
|
||||
expect(compatCheck(`${maxDify}+build123`)).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata when out of range (1.16.1+build still rejected)', () => {
|
||||
expect(run(['compat-check', '1.16.1+build123']).code).not.toBe(0)
|
||||
it('ignores build metadata when out of range', () => {
|
||||
expect(compatCheck('2.5.1+build123')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('requires a version argument', () => {
|
||||
expect(run(['compat-check']).code).not.toBe(0)
|
||||
expect(compatCheck()).not.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,6 +79,14 @@ describe('release-naming github-env', () => {
|
||||
for (const key of ['version', 'channel', 'prerelease', 'minDify', 'maxDify', 'tagPrefix'])
|
||||
expect(stdout).toMatch(new RegExp(`^${key}=`, 'm'))
|
||||
})
|
||||
|
||||
// The only assertion against the live manifest: the window must exist and be
|
||||
// well-formed, whatever release it currently points at.
|
||||
it('emits a well-formed compat window from the real cli/package.json', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
expect(stdout).toMatch(/^minDify=\d+\.\d+\.\d+$/m)
|
||||
expect(stdout).toMatch(/^maxDify=\d+\.\d+\.\d+$/m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming edge channel', () => {
|
||||
|
||||
@@ -4,14 +4,20 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url))
|
||||
|
||||
const PKG_ENV = pkgManifestEnv()
|
||||
|
||||
function run(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
return {
|
||||
code: 0,
|
||||
stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }),
|
||||
stdout: execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...PKG_ENV },
|
||||
}),
|
||||
stderr: '',
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -108,7 +114,7 @@ describe('release-r2-edge manifest', () => {
|
||||
|
||||
it('carries the compat window from package.json', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.compat).toEqual({ minDify: '1.16.0', maxDify: '1.16.0' })
|
||||
expect(json.compat).toEqual(FIXTURE_COMPAT)
|
||||
})
|
||||
|
||||
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
|
||||
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Mirrors PKG_PATH_ENV in scripts/release-naming.mjs, which cannot be imported
|
||||
// here: its shebang breaks the Windows test runner. Divergence is self-
|
||||
// reporting, not silent — the script would fall back to the real
|
||||
// cli/package.json and every fixture-window assertion would fail.
|
||||
const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH'
|
||||
|
||||
// release-naming.mjs and release-r2-edge.mjs read their data from
|
||||
// cli/package.json. Tests spawn them against this fixture instead, so
|
||||
// assertions can name exact versions without tracking the live release.
|
||||
|
||||
// Deliberately far from any real Dify version, and min != max so "inside the
|
||||
// window" is a case distinct from either bound.
|
||||
export const FIXTURE_COMPAT = { minDify: '2.0.0', maxDify: '2.5.0' }
|
||||
|
||||
export const FIXTURE_TARGET_IDS = [
|
||||
'linux-x64',
|
||||
'linux-arm64',
|
||||
'darwin-x64',
|
||||
'darwin-arm64',
|
||||
'windows-x64',
|
||||
] as const
|
||||
|
||||
const FIXTURE_RELEASE = {
|
||||
tagPrefix: 'difyctl-v',
|
||||
binName: 'difyctl',
|
||||
checksumsSuffix: '-checksums.txt',
|
||||
targets: FIXTURE_TARGET_IDS.map((id) => ({
|
||||
id,
|
||||
bunTarget: `bun-${id}`,
|
||||
exe: id.startsWith('windows'),
|
||||
})),
|
||||
}
|
||||
|
||||
export type PkgManifestOverrides = {
|
||||
version?: string
|
||||
channel?: string
|
||||
compat?: { minDify: string; maxDify: string }
|
||||
}
|
||||
|
||||
// Returns the env additions that point a spawned script at the fixture.
|
||||
export function pkgManifestEnv(overrides: PkgManifestOverrides = {}): Record<string, string> {
|
||||
const manifest = {
|
||||
version: overrides.version ?? '0.2.0-alpha',
|
||||
difyctl: {
|
||||
channel: overrides.channel ?? 'alpha',
|
||||
compat: overrides.compat ?? FIXTURE_COMPAT,
|
||||
release: FIXTURE_RELEASE,
|
||||
},
|
||||
}
|
||||
const path = join(mkdtempSync(join(tmpdir(), 'difyctl-pkg-')), 'package.json')
|
||||
writeFileSync(path, JSON.stringify(manifest))
|
||||
return { [PKG_PATH_ENV]: path }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ REPO_ROOT="$SCRIPT_DIR/.."
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
EXCLUDES_FILE="api/pyrefly-local-excludes.txt"
|
||||
UNIT_TESTS_CONFIG="tests/unit_tests/pyrefly.toml"
|
||||
TEST_CONTAINERS_DIR="tests/test_containers_integration_tests"
|
||||
TEST_CONTAINERS_CONFIG="$TEST_CONTAINERS_DIR/pyrefly.toml"
|
||||
|
||||
@@ -74,6 +75,19 @@ fi
|
||||
run_pyrefly "${pyrefly_command[@]}" || status=$?
|
||||
|
||||
if (( ${#target_paths[@]} == 0 )); then
|
||||
unit_tests_args=(
|
||||
"--summary=none"
|
||||
"--use-ignore-files=false"
|
||||
"--config=$UNIT_TESTS_CONFIG"
|
||||
)
|
||||
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
|
||||
unit_tests_args+=("--output-format=github")
|
||||
fi
|
||||
run_pyrefly \
|
||||
uv run --directory api --dev pyrefly check \
|
||||
"${unit_tests_args[@]}" \
|
||||
|| status=$?
|
||||
|
||||
test_containers_args=(
|
||||
"--summary=none"
|
||||
"--use-ignore-files=false"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<4.0"
|
||||
|
||||
@@ -115,7 +115,7 @@ def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name) # noqa: no-new-getattr lazy export proxy
|
||||
value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name) # noqa: no-new-getattr lazy export proxy
|
||||
value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -581,7 +581,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -157,9 +157,6 @@ ENABLE_WEBSITE_JINAREADER=true
|
||||
ENABLE_WEBSITE_FIRECRAWL=true
|
||||
ENABLE_WEBSITE_WATERCRAWL=true
|
||||
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
# Enable preview features still in development (currently the /create and
|
||||
# /refine slash commands in the "Go to Anything" command palette).
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2=true
|
||||
EXPERIMENTAL_ENABLE_VINEXT=false
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -271,7 +271,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -297,7 +297,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -347,7 +347,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -380,7 +380,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.0
|
||||
image: langgenius/dify-web:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
@@ -542,7 +542,7 @@ services:
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/local-sandbox.env
|
||||
@@ -651,7 +651,7 @@ services:
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
image: langgenius/dify-agent-backend:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
|
||||
@@ -226,7 +226,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -277,7 +277,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -303,7 +303,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -353,7 +353,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -386,7 +386,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.0
|
||||
image: langgenius/dify-web:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
@@ -548,7 +548,7 @@ services:
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/local-sandbox.env
|
||||
@@ -657,7 +657,7 @@ services:
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
image: langgenius/dify-agent-backend:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
|
||||
@@ -73,6 +73,7 @@ SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
PLUGIN_MAX_PACKAGE_SIZE=52428800
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
# Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users.
|
||||
# Example: langgenius/openai,langgenius/gemini
|
||||
|
||||
@@ -31,6 +31,17 @@ const createApiResponse = ({
|
||||
status: () => status,
|
||||
statusText: () => statusText,
|
||||
text: async () => body,
|
||||
timing: () => ({
|
||||
connectEnd: -1,
|
||||
connectStart: -1,
|
||||
domainLookupEnd: -1,
|
||||
domainLookupStart: -1,
|
||||
requestStart: -1,
|
||||
responseEnd: -1,
|
||||
responseStart: -1,
|
||||
secureConnectionStart: -1,
|
||||
startTime: -1,
|
||||
}),
|
||||
url: () => url,
|
||||
[Symbol.asyncDispose]: async () => {},
|
||||
}
|
||||
|
||||
@@ -4,26 +4,11 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"cli/test/e2e/helpers/retry.ts": {
|
||||
"no-throw-literal": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"cli/test/e2e/suites/output/json-yaml-output.e2e.ts": {
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"e2e/support/web-server.ts": {
|
||||
"no-throw-literal": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"packages/dev-proxy/src/cli.spec.ts": {
|
||||
"no-throw-literal": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts": {
|
||||
"no-console": {
|
||||
"count": 11
|
||||
@@ -378,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
|
||||
@@ -2141,11 +2118,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/voice-input/recorder.ts": {
|
||||
"no-throw-literal": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/billing/plan/assets/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 4
|
||||
@@ -3225,9 +3197,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/hooks.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -6349,9 +6318,6 @@
|
||||
}
|
||||
},
|
||||
"web/service/use-pipeline.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -6370,9 +6336,6 @@
|
||||
}
|
||||
},
|
||||
"web/service/use-workflow.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
|
||||
+1
-1
@@ -56,5 +56,5 @@
|
||||
"engines": {
|
||||
"node": "^22.22.1"
|
||||
},
|
||||
"packageManager": "[email protected]5.0"
|
||||
"packageManager": "[email protected]7.0"
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ const comboboxTriggerVariants = cva(
|
||||
[
|
||||
'group/combobox-trigger flex w-full min-w-0 items-center border-0 bg-components-input-bg-normal text-start text-components-input-text-filled outline-hidden transition-colors',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
|
||||
'focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-active',
|
||||
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-placeholder:text-components-input-text-placeholder',
|
||||
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
|
||||
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
|
||||
|
||||
Generated
+1989
-1585
File diff suppressed because it is too large
Load Diff
+58
-58
@@ -39,32 +39,32 @@ overrides:
|
||||
postcss-selector-parser@>=6.0.0 <6.1.3: 6.1.4
|
||||
postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.4
|
||||
postcss@<8.5.10: ^8.5.10
|
||||
rollup@>=4.0.0 <4.59.0: 4.62.2
|
||||
rollup@>=4.0.0 <4.59.0: 4.62.3
|
||||
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
|
||||
side-channel: npm:@nolyfill/side-channel@^1.0.44
|
||||
solid-js: 1.9.14
|
||||
string-width: ~8.2.2
|
||||
tar@<=7.5.15: ^7.5.16
|
||||
vite: npm:@voidzero-dev/[email protected].5
|
||||
vite: npm:@voidzero-dev/[email protected].6
|
||||
ws@>=8.0.0 <8.20.1: ^8.21.1
|
||||
yaml@>=2.0.0 <2.8.3: 2.9.0
|
||||
yauzl@<3.2.1: 3.2.1
|
||||
catalog:
|
||||
'@amplitude/analytics-browser': 2.45.2
|
||||
'@amplitude/plugin-session-replay-browser': 1.33.4
|
||||
'@amplitude/analytics-browser': 2.45.4
|
||||
'@amplitude/plugin-session-replay-browser': 1.33.6
|
||||
'@base-ui/react': 1.6.0
|
||||
'@chromatic-com/storybook': 5.2.1
|
||||
'@cucumber/cucumber': 13.1.1
|
||||
'@cucumber/cucumber': 13.2.0
|
||||
'@egoist/tailwindcss-icons': 1.9.2
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@eslint-community/eslint-plugin-eslint-comments': 4.7.2
|
||||
'@eslint-react/eslint-plugin': 5.17.1
|
||||
'@eslint-react/eslint-plugin': 5.18.0
|
||||
'@eslint/markdown': 8.0.3
|
||||
'@floating-ui/react': 0.27.20
|
||||
'@formatjs/intl-localematcher': 0.8.13
|
||||
'@heroicons/react': 2.2.0
|
||||
'@hey-api/openapi-ts': 0.98.2
|
||||
'@hono/node-server': 2.0.10
|
||||
'@hono/node-server': 2.0.12
|
||||
'@iconify-json/heroicons': 1.2.3
|
||||
'@iconify-json/ri': 1.2.10
|
||||
'@lexical/code': 0.47.0
|
||||
@@ -77,41 +77,41 @@ catalog:
|
||||
'@mdx-js/loader': 3.1.1
|
||||
'@mdx-js/react': 3.1.1
|
||||
'@mdx-js/rollup': 3.1.1
|
||||
'@mediabunny/mp3-encoder': 1.50.9
|
||||
'@mediabunny/mp3-encoder': 1.51.0
|
||||
'@monaco-editor/react': 4.7.0
|
||||
'@napi-rs/keyring': 1.3.0
|
||||
'@next/mdx': 16.2.11
|
||||
'@orpc/client': 1.14.8
|
||||
'@orpc/contract': 1.14.8
|
||||
'@orpc/openapi-client': 1.14.8
|
||||
'@orpc/tanstack-query': 1.14.8
|
||||
'@playwright/test': 1.61.1
|
||||
'@next/mdx': 16.2.12
|
||||
'@orpc/client': 1.14.10
|
||||
'@orpc/contract': 1.14.10
|
||||
'@orpc/openapi-client': 1.14.10
|
||||
'@orpc/tanstack-query': 1.14.10
|
||||
'@playwright/test': 1.62.0
|
||||
'@remixicon/react': 4.9.0
|
||||
'@rgrove/parse-xml': 4.2.2
|
||||
'@sentry/react': 10.66.0
|
||||
'@storybook/addon-a11y': 10.5.2
|
||||
'@storybook/addon-docs': 10.5.2
|
||||
'@storybook/addon-links': 10.5.2
|
||||
'@storybook/addon-onboarding': 10.5.2
|
||||
'@storybook/addon-themes': 10.5.2
|
||||
'@storybook/addon-vitest': 10.5.2
|
||||
'@storybook/nextjs-vite': 10.5.2
|
||||
'@storybook/react': 10.5.2
|
||||
'@storybook/react-vite': 10.5.2
|
||||
'@rgrove/parse-xml': 4.2.3
|
||||
'@sentry/react': 10.68.0
|
||||
'@storybook/addon-a11y': 10.5.4
|
||||
'@storybook/addon-docs': 10.5.4
|
||||
'@storybook/addon-links': 10.5.4
|
||||
'@storybook/addon-onboarding': 10.5.4
|
||||
'@storybook/addon-themes': 10.5.4
|
||||
'@storybook/addon-vitest': 10.5.4
|
||||
'@storybook/nextjs-vite': 10.5.4
|
||||
'@storybook/react': 10.5.4
|
||||
'@storybook/react-vite': 10.5.4
|
||||
'@streamdown/math': 1.0.2
|
||||
'@svgdotjs/svg.js': 3.2.6
|
||||
'@svgdotjs/svg.js': 3.2.7
|
||||
'@t3-oss/env-core': 0.13.11
|
||||
'@t3-oss/env-nextjs': 0.13.11
|
||||
'@tailwindcss/postcss': 4.3.3
|
||||
'@tailwindcss/typography': 0.5.20
|
||||
'@tailwindcss/vite': 4.3.3
|
||||
'@tanstack/eslint-plugin-query': 5.101.2
|
||||
'@tanstack/eslint-plugin-query': 5.101.4
|
||||
'@tanstack/form-core': 1.33.2
|
||||
'@tanstack/query-core': 5.101.2
|
||||
'@tanstack/query-core': 5.101.4
|
||||
'@tanstack/react-form': 1.33.2
|
||||
'@tanstack/react-hotkeys': 0.10.0
|
||||
'@tanstack/react-query': 5.101.2
|
||||
'@tanstack/react-virtual': 3.14.6
|
||||
'@tanstack/react-query': 5.101.4
|
||||
'@tanstack/react-virtual': 3.14.8
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/jest-dom': 6.9.1
|
||||
'@testing-library/react': 16.3.2
|
||||
@@ -127,9 +127,9 @@ catalog:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3
|
||||
'@types/sortablejs': 1.15.9
|
||||
'@typescript-eslint/parser': 8.64.0
|
||||
'@typescript-eslint/parser': 8.65.0
|
||||
'@typescript/native': npm:[email protected]
|
||||
'@vitejs/plugin-react': 6.0.3
|
||||
'@vitejs/plugin-react': 6.0.4
|
||||
'@vitejs/plugin-rsc': 0.5.30
|
||||
'@vitest/browser': 4.1.10
|
||||
'@vitest/browser-playwright': 4.1.10
|
||||
@@ -143,7 +143,7 @@ catalog:
|
||||
cli-table3: 0.6.5
|
||||
clsx: 2.1.1
|
||||
code-inspector-plugin: 1.6.6
|
||||
concurrently: ^10.0.3
|
||||
concurrently: 10.0.4
|
||||
copy-to-clipboard: 4.0.2
|
||||
cron-parser: 5.6.2
|
||||
dayjs: 1.11.21
|
||||
@@ -156,32 +156,32 @@ catalog:
|
||||
embla-carousel-fade: 8.6.0
|
||||
embla-carousel-react: 8.6.0
|
||||
emoji-mart: 5.6.0
|
||||
es-toolkit: 1.49.0
|
||||
eslint: 10.7.0
|
||||
es-toolkit: 1.50.0
|
||||
eslint: 10.8.0
|
||||
eslint-markdown: 0.12.1
|
||||
eslint-plugin-antfu: 3.2.3
|
||||
eslint-plugin-command: 3.5.3
|
||||
eslint-plugin-erasable-syntax-only: 0.4.2
|
||||
eslint-plugin-hyoban: 0.14.1
|
||||
eslint-plugin-jsdoc: 63.1.0
|
||||
eslint-plugin-jsdoc: 63.3.1
|
||||
eslint-plugin-jsonc: 3.3.0
|
||||
eslint-plugin-markdown-preferences: 0.41.1
|
||||
eslint-plugin-n: 18.2.2
|
||||
eslint-plugin-no-barrel-files: 1.3.1
|
||||
eslint-plugin-perfectionist: 5.10.0
|
||||
eslint-plugin-pnpm: 1.6.1
|
||||
eslint-plugin-pnpm: 1.7.0
|
||||
eslint-plugin-regexp: 3.1.1
|
||||
eslint-plugin-storybook: 10.5.2
|
||||
eslint-plugin-toml: 1.4.0
|
||||
eslint-plugin-storybook: 10.5.4
|
||||
eslint-plugin-toml: 1.5.0
|
||||
eslint-plugin-unicorn: 71.1.0
|
||||
eslint-plugin-yml: 3.6.0
|
||||
eventsource-parser: 3.1.0
|
||||
fast-deep-equal: 3.1.3
|
||||
foxact: 0.3.8
|
||||
fuse.js: 7.5.0
|
||||
happy-dom: 20.11.0
|
||||
happy-dom: 20.11.1
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
hono: 4.12.31
|
||||
hono: 4.12.32
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
i18next: 26.3.6
|
||||
@@ -189,39 +189,39 @@ catalog:
|
||||
iconify-import-svg: 0.2.0
|
||||
immer: 11.1.15
|
||||
jotai: 2.20.2
|
||||
jotai-effect: 2.3.1
|
||||
jotai-effect: 2.4.1
|
||||
jotai-scope: 0.11.0
|
||||
jotai-tanstack-query: 0.11.0
|
||||
js-cookie: 3.0.8
|
||||
js-yaml: 5.2.1
|
||||
js-yaml: 5.2.2
|
||||
jsonschema: 1.5.0
|
||||
katex: 0.17.0
|
||||
knip: 6.27.0
|
||||
knip: 6.29.0
|
||||
ky: 2.0.2
|
||||
lexical: 0.47.0
|
||||
lockfile: 1.0.4
|
||||
loro-crdt: 1.13.7
|
||||
mediabunny: 1.50.9
|
||||
loro-crdt: 1.13.8
|
||||
mediabunny: 1.51.0
|
||||
mermaid: 11.16.0
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
motion: 12.42.2
|
||||
negotiator: 1.0.0
|
||||
next: 16.2.11
|
||||
next: 16.2.12
|
||||
next-themes: 0.4.6
|
||||
nuqs: 2.9.1
|
||||
nuqs: 2.9.2
|
||||
open: 11.0.0
|
||||
ora: 9.4.1
|
||||
picocolors: 1.1.1
|
||||
pinyin-pro: 3.28.1
|
||||
playwright: 1.61.1
|
||||
postcss: 8.5.19
|
||||
pinyin-pro: 3.28.2
|
||||
playwright: 1.62.0
|
||||
postcss: 8.5.23
|
||||
qrcode.react: 4.2.0
|
||||
qs: 6.15.3
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8
|
||||
react-easy-crop: 6.2.2
|
||||
react-i18next: 17.0.10
|
||||
react-easy-crop: 6.2.3
|
||||
react-i18next: 17.0.11
|
||||
react-papaparse: 4.4.0
|
||||
react-pdf-highlighter: 8.0.0-rc.0
|
||||
react-server-dom-webpack: 19.2.8
|
||||
@@ -237,7 +237,7 @@ catalog:
|
||||
socket.io-client: 4.8.3
|
||||
sortablejs: 1.15.7
|
||||
std-semver: 1.0.8
|
||||
storybook: 10.5.2
|
||||
storybook: 10.5.4
|
||||
streamdown: 2.5.0
|
||||
string-ts: 2.3.1
|
||||
tailwind-merge: 3.6.0
|
||||
@@ -246,14 +246,14 @@ catalog:
|
||||
tsx: 4.23.1
|
||||
typescript: npm:@typescript/[email protected]
|
||||
uglify-js: 3.19.3
|
||||
undici: 7.28.0
|
||||
undici: 7.29.0
|
||||
unist-util-visit: 5.1.0
|
||||
use-context-selector: 2.0.0
|
||||
uuid: 14.0.1
|
||||
vinext: 1.0.0-beta.2
|
||||
vite: npm:@voidzero-dev/[email protected].5
|
||||
vinext: 1.0.0-beta.4
|
||||
vite: npm:@voidzero-dev/[email protected].6
|
||||
vite-plugin-inspect: 12.0.2
|
||||
vite-plus: 0.2.5
|
||||
vite-plus: 0.2.6
|
||||
vitest: 4.1.10
|
||||
vitest-browser-react: 2.2.0
|
||||
vitest-canvas-mock: 1.1.4
|
||||
|
||||
@@ -245,13 +245,13 @@ def extract_meta_variables(raw_match: dict[str, Any]) -> dict[str, str]:
|
||||
return result
|
||||
|
||||
|
||||
def has_reasoned_noqa(source_line: str, rule_id: str) -> bool:
|
||||
pattern = re.compile(rf"# noqa: {re.escape(rule_id)}(?:\s+(?P<reason>\S.*))?\s*$")
|
||||
def has_reasoned_guard_ignore(source_line: str, rule_id: str) -> bool:
|
||||
pattern = re.compile(rf"# guard-ignore: {re.escape(rule_id)} -- (?P<reason>\S.*)\s*$")
|
||||
match = pattern.search(source_line)
|
||||
if not match:
|
||||
return False
|
||||
reason = match.group("reason")
|
||||
return reason is not None and bool(reason.strip())
|
||||
return bool(reason.strip())
|
||||
|
||||
|
||||
def collect_hunk_violations(
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from ast_grep_guard import Match, has_reasoned_noqa, rule_path, run_guard
|
||||
from ast_grep_guard import Match, has_reasoned_guard_ignore, rule_path, run_guard
|
||||
|
||||
|
||||
RULE_ID = "no-new-controller-sqlalchemy"
|
||||
@@ -40,7 +40,7 @@ def is_flask_session_get(match: Match) -> bool:
|
||||
|
||||
|
||||
def is_suppressed(match: Match) -> bool:
|
||||
return has_reasoned_noqa(match.source_line, RULE_ID)
|
||||
return has_reasoned_guard_ignore(match.source_line, RULE_ID)
|
||||
|
||||
|
||||
def is_reportable_match(match: Match) -> bool:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ast_grep_guard import Match, has_reasoned_noqa, is_python_source_path, rule_path, run_guard
|
||||
from ast_grep_guard import Match, has_reasoned_guard_ignore, is_python_source_path, rule_path, run_guard
|
||||
|
||||
|
||||
RULE_ID = "no-new-getattr"
|
||||
@@ -12,7 +12,7 @@ VIOLATION_MESSAGE = "no-new-getattr net-new getattr() in added code"
|
||||
|
||||
|
||||
def is_reportable_match(match: Match) -> bool:
|
||||
return not has_reasoned_noqa(match.source_line, RULE_ID)
|
||||
return not has_reasoned_guard_ignore(match.source_line, RULE_ID)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -44,7 +44,7 @@ def parse_args() -> argparse.Namespace:
|
||||
help="Files or directories to scan. Defaults to api/controllers.",
|
||||
)
|
||||
parser.add_argument("--include-allowed", action="store_true", help="Print allowed flush()/commit() findings.")
|
||||
parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned noqa suppressions.")
|
||||
parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned guard suppressions.")
|
||||
parser.add_argument("--summary-only", action="store_true", help="Print counts without per-finding details.")
|
||||
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
||||
parser.add_argument(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppAccessPointDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppDeployDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Dependency } from '@/app/components/plugins/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useStore as usePluginDependencyStore } from '@/app/components/workflow/plugin-dependency/store'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guardAgentV2Route: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-bundle', () => ({
|
||||
default: ({ fromDSLPayload }: { fromDSLPayload: Dependency[] }) => (
|
||||
<div role="dialog" aria-label="Install missing plugins">
|
||||
{`bundle-size:${fromDSLPayload.length}`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../feature-guard', () => ({
|
||||
guardAgentV2Route: () => mocks.guardAgentV2Route(),
|
||||
}))
|
||||
@@ -18,6 +28,7 @@ vi.mock('../agents-access-guard', () => ({
|
||||
describe('RosterLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
usePluginDependencyStore.setState({ dependencies: [] })
|
||||
})
|
||||
|
||||
it('should render children when Agent v2 is enabled', async () => {
|
||||
@@ -33,6 +44,34 @@ describe('RosterLayout', () => {
|
||||
expect(screen.getByText('Roster content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the missing-plugin installer across Agent routes', async () => {
|
||||
usePluginDependencyStore.setState({
|
||||
dependencies: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
organization: 'langgenius',
|
||||
plugin: 'sample-plugin',
|
||||
version: '1.0.0',
|
||||
plugin_unique_identifier: 'langgenius/sample-plugin:1.0.0',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const { default: RosterLayout } = await import('../layout')
|
||||
|
||||
render(
|
||||
<RosterLayout>
|
||||
<div>Agent route content</div>
|
||||
</RosterLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Install missing plugins' })).toHaveTextContent(
|
||||
'bundle-size:1',
|
||||
)
|
||||
expect(screen.getByText('Agent route content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should block rendering when the roster guard throws notFound', async () => {
|
||||
mocks.guardAgentV2Route.mockImplementation(() => {
|
||||
throw new Error('NEXT_NOT_FOUND')
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import PluginDependency from '@/app/components/workflow/plugin-dependency'
|
||||
import { AgentsAccessGuard } from './agents-access-guard'
|
||||
import { guardAgentV2Route } from './feature-guard'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
guardAgentV2Route()
|
||||
|
||||
return <AgentsAccessGuard>{children}</AgentsAccessGuard>
|
||||
return (
|
||||
<AgentsAccessGuard>
|
||||
<PluginDependency />
|
||||
{children}
|
||||
</AgentsAccessGuard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function AppAccessPointPage() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function AppDeployPage() {
|
||||
return null
|
||||
}
|
||||
+100
-144
@@ -388,12 +388,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as ArizeConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Space ID"
|
||||
@@ -401,12 +399,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as ArizeConfig).space_id}
|
||||
onChange={handleConfigChange('space_id')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'Space ID',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'Space ID',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
@@ -414,12 +410,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as ArizeConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -438,12 +432,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as PhoenixConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
@@ -451,12 +443,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as PhoenixConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -475,12 +465,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as AliyunConfig).license_key}
|
||||
onChange={handleConfigChange('license_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'License Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'License Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -505,9 +493,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as TencentConfig).token}
|
||||
onChange={handleConfigChange('token')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'Token' })!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'Token',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -535,12 +524,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as WeaveConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
@@ -548,21 +535,20 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as WeaveConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Entity"
|
||||
labelClassName="text-sm!"
|
||||
value={(config as WeaveConfig).entity}
|
||||
onChange={handleConfigChange('entity')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'Entity' })!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'Entity',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -588,12 +574,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as LangSmithConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
@@ -601,12 +585,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as LangSmithConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -625,12 +607,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
value={(config as LangFuseConfig).secret_key}
|
||||
isRequired
|
||||
onChange={handleConfigChange('secret_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' })!}
|
||||
@@ -638,12 +618,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as LangFuseConfig).public_key}
|
||||
onChange={handleConfigChange('public_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Host"
|
||||
@@ -662,24 +640,20 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
labelClassName="text-sm!"
|
||||
value={(config as OpikConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as OpikConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Workspace"
|
||||
@@ -713,36 +687,30 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as MLflowConfig).experiment_id}
|
||||
onChange={handleConfigChange('experiment_id')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.username`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as MLflowConfig).username}
|
||||
onChange={handleConfigChange('username')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.username`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.username`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.password`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as MLflowConfig).password}
|
||||
onChange={handleConfigChange('password')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.password`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.password`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -753,12 +721,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).experiment_id}
|
||||
onChange={handleConfigChange('experiment_id')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }),
|
||||
})!}
|
||||
isRequired
|
||||
/>
|
||||
<Field
|
||||
@@ -766,12 +732,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).host}
|
||||
onChange={handleConfigChange('host')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.databricksHost`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.databricksHost`], { ns: 'app' }),
|
||||
})!}
|
||||
isRequired
|
||||
/>
|
||||
<Field
|
||||
@@ -779,36 +743,30 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).client_id}
|
||||
onChange={handleConfigChange('client_id')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.clientId`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.clientId`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).client_secret}
|
||||
onChange={handleConfigChange('client_secret')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).personal_access_token}
|
||||
onChange={handleConfigChange('personal_access_token')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -884,12 +842,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
{
|
||||
t(($) => $[`${I18N_PREFIX}.removeConfirmTitle`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`tracing.${type}.title`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
{t(($) => $[`${I18N_PREFIX}.removeConfirmTitle`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`tracing.${type}.title`], { ns: 'app' }),
|
||||
})!}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $[`${I18N_PREFIX}.removeConfirmContent`], { ns: 'app' })}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function AddMemberOrGroupDialog() {
|
||||
aria-label={t(($) => $['operation.add'], { ns: 'common' })}
|
||||
icon={false}
|
||||
size="small"
|
||||
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
|
||||
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
|
||||
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user