Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c32e1bc8 |
@@ -66,7 +66,7 @@ from services.enterprise.plugin_manager_service import (
|
||||
PreUninstallPluginRequest,
|
||||
)
|
||||
from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import FeatureService, PluginInstallationPermissionModel, PluginInstallationScope
|
||||
from services.feature_service import FeatureService, PluginInstallationScope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity])
|
||||
@@ -604,30 +604,22 @@ class PluginService:
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _check_marketplace_only_permission() -> None:
|
||||
def _check_marketplace_only_permission():
|
||||
"""
|
||||
Check if the marketplace only permission is enabled
|
||||
"""
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
if permission.restrict_to_marketplace_only:
|
||||
features = FeatureService.get_system_features()
|
||||
if features.plugin_installation_permission.restrict_to_marketplace_only:
|
||||
raise PluginInstallationForbiddenError("Plugin installation is restricted to marketplace only")
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None):
|
||||
"""
|
||||
Check the plugin installation scope
|
||||
"""
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
match permission.plugin_installation_scope:
|
||||
match features.plugin_installation_permission.plugin_installation_scope:
|
||||
case PluginInstallationScope.OFFICIAL_ONLY:
|
||||
if (
|
||||
plugin_verification is None
|
||||
@@ -642,10 +634,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:
|
||||
@@ -915,7 +907,7 @@ class PluginService:
|
||||
# check if plugin pkg is already downloaded
|
||||
manager = PluginInstaller()
|
||||
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
try:
|
||||
manager.fetch_plugin_manifest(tenant_id, new_plugin_unique_identifier)
|
||||
@@ -927,7 +919,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
|
||||
# check if the plugin is available to install
|
||||
@@ -982,11 +974,11 @@ class PluginService:
|
||||
"""
|
||||
PluginService._check_marketplace_only_permission()
|
||||
manager = PluginInstaller()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -1004,13 +996,13 @@ class PluginService:
|
||||
pkg = download_with_size_limit(
|
||||
f"https://github.com/{repo}/releases/download/{version}/{package}", dify_config.PLUGIN_MAX_PACKAGE_SIZE
|
||||
)
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
manager = PluginInstaller()
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -1084,7 +1076,7 @@ class PluginService:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError("marketplace is not enabled")
|
||||
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
manager = PluginInstaller()
|
||||
try:
|
||||
@@ -1094,7 +1086,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
@@ -1116,7 +1108,7 @@ class PluginService:
|
||||
# collect actual plugin_unique_identifiers
|
||||
actual_plugin_unique_identifiers = []
|
||||
metas = []
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
# check if already downloaded
|
||||
for plugin_unique_identifier in plugin_unique_identifiers:
|
||||
@@ -1134,7 +1126,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -107,8 +107,6 @@ 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."""
|
||||
|
||||
+1
-5
@@ -289,11 +289,7 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)]
|
||||
|
||||
def alphanumeric(value: str):
|
||||
# check if the value is alphanumeric and underlined
|
||||
# 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):
|
||||
if re.match(r"^[a-zA-Z0-9_]+$", value):
|
||||
return value
|
||||
|
||||
raise ValueError(f"{value} is not a valid alphanumeric value")
|
||||
|
||||
+12
-21
@@ -1,5 +1,3 @@
|
||||
"""Unit tests for Aliyun trace utility transformations and database lookups."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
@@ -27,13 +25,11 @@ 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):
|
||||
@@ -44,40 +40,35 @@ 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"
|
||||
|
||||
|
||||
@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:
|
||||
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
message_data = MagicMock()
|
||||
message_data.from_account_id = "account_id"
|
||||
message_data.from_end_user_id = "end_user_id"
|
||||
|
||||
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()
|
||||
end_user_data = MagicMock(spec=EndUser)
|
||||
end_user_data.session_id = "session_id"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = end_user_data
|
||||
|
||||
from dify_trace_aliyun.utils import db
|
||||
|
||||
monkeypatch.setattr(db, "session", sqlite3_session)
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "session_id"
|
||||
|
||||
|
||||
@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:
|
||||
def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch):
|
||||
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", sqlite3_session)
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "account_id"
|
||||
|
||||
|
||||
+29
-67
@@ -1,8 +1,5 @@
|
||||
"""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
|
||||
|
||||
@@ -14,7 +11,6 @@ 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,
|
||||
@@ -28,7 +24,6 @@ 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:
|
||||
@@ -113,8 +108,7 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
mocks["generate_name_trace"].assert_called_once_with(info)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
# Setup trace info
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
@@ -143,10 +137,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
# 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"))
|
||||
|
||||
# Mock node executions
|
||||
node_llm = MagicMock()
|
||||
@@ -234,10 +228,7 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3
|
||||
assert call_args[4].run_type == LangSmithRunType.retriever
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_no_start_time(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -265,10 +256,9 @@ def test_workflow_trace_no_start_time(
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
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"))
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
mock_factory = MagicMock()
|
||||
@@ -281,10 +271,7 @@ def test_workflow_trace_no_start_time(
|
||||
assert trace_instance.add_run.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_missing_app_id(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.trace_id = "trace-1"
|
||||
trace_info.message_id = None
|
||||
@@ -300,17 +287,15 @@ def test_workflow_trace_missing_app_id(
|
||||
trace_info.workflow_run_outputs = {}
|
||||
trace_info.error = ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
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"))
|
||||
|
||||
with pytest.raises(ValueError, match="No app_id found in trace_info metadata"):
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
message_data = MagicMock()
|
||||
message_data.id = "msg-1"
|
||||
message_data.from_account_id = "acc-1"
|
||||
@@ -336,19 +321,10 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_
|
||||
message_file_data=MagicMock(url="file-url"),
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
# 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)
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
@@ -545,13 +521,9 @@ 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,
|
||||
sqlite3_session: Session,
|
||||
) -> None:
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -604,10 +576,8 @@ 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.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
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(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock())
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
@@ -674,11 +644,9 @@ def _make_workflow_trace_info(
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
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"))
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
factory = MagicMock()
|
||||
@@ -688,17 +656,14 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Ses
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
|
||||
@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:
|
||||
def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
"""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, sqlite3_session)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
@@ -712,17 +677,14 @@ def test_workflow_trace_id_uses_message_id_not_external(
|
||||
assert trace_info.metadata.get("external_trace_id") == "external-999"
|
||||
|
||||
|
||||
@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:
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
"""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, sqlite3_session)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from configs import dify_config
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
@@ -12,8 +10,6 @@ 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=())
|
||||
@@ -135,13 +131,6 @@ 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()
|
||||
@@ -296,14 +285,6 @@ 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.
|
||||
@@ -471,33 +452,6 @@ 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()
|
||||
@@ -545,4 +499,11 @@ class FeatureService:
|
||||
status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
|
||||
)
|
||||
|
||||
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)
|
||||
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"
|
||||
]
|
||||
|
||||
+480
-71
@@ -1,85 +1,494 @@
|
||||
"""Integration coverage for Notion page bindings backed by persisted documents."""
|
||||
"""Testcontainers integration tests for controllers.console.datasets.data_source endpoints."""
|
||||
|
||||
from inspect import unwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, PropertyMock, 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.data_source import DataSourceNotionListApi
|
||||
from models import Account
|
||||
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 models.dataset import Document
|
||||
from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus
|
||||
|
||||
|
||||
def test_notion_page_is_marked_bound_from_persisted_document(
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
dataset_id = str(uuid4())
|
||||
account = Account(name="Test User", email="user@example.com")
|
||||
account.id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info='{"notion_page_id": "page-1"}',
|
||||
batch=f"batch-{uuid4()}",
|
||||
name="Notion Page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
indexing_status=IndexingStatus.COMPLETED,
|
||||
enabled=True,
|
||||
)
|
||||
db_session_with_containers.add(document)
|
||||
db_session_with_containers.commit()
|
||||
runtime = MagicMock(
|
||||
get_online_document_pages=lambda **_kwargs: iter(
|
||||
[
|
||||
MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="workspace-1",
|
||||
workspace_name="Workspace",
|
||||
workspace_icon=None,
|
||||
pages=[
|
||||
MagicMock(
|
||||
page_id="page-1",
|
||||
page_name="Page",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
datasource_provider_type=lambda: None,
|
||||
)
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
account = Account(name="Test User", email="u1@example.com")
|
||||
account.id = "u1"
|
||||
return account
|
||||
|
||||
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,
|
||||
),
|
||||
|
||||
@pytest.fixture
|
||||
def mock_engine() -> Iterator[None]:
|
||||
with patch.object(
|
||||
type(data_source.db),
|
||||
"engine",
|
||||
new_callable=PropertyMock,
|
||||
return_value=MagicMock(),
|
||||
):
|
||||
response, status = unwrap(DataSourceNotionListApi().get)(
|
||||
DataSourceNotionListApi(), db_session_with_containers, tenant_id, account
|
||||
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,
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert response["notion_info"][0]["pages"][0]["is_bound"] is True
|
||||
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")
|
||||
|
||||
+58
-1
@@ -4,7 +4,7 @@ import datetime
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@@ -1172,8 +1172,65 @@ 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()
|
||||
|
||||
+31
-41
@@ -1,20 +1,16 @@
|
||||
"""Unit tests for OAuthServerService with SQLite-backed database access."""
|
||||
"""Testcontainers integration tests for OAuthServerService."""
|
||||
|
||||
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,
|
||||
@@ -27,24 +23,10 @@ from services.oauth_server import (
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
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."""
|
||||
"""DB-backed tests for get_oauth_provider_app."""
|
||||
|
||||
def test_get_oauth_provider_app_returns_app_when_exists(self, oauth_db: Session) -> None:
|
||||
client_id = f"client-{uuid4()}"
|
||||
def _create_oauth_provider_app(self, db_session_with_containers: Session, *, client_id: str) -> OAuthProviderApp:
|
||||
app = OAuthProviderApp(
|
||||
app_icon="icon.png",
|
||||
client_id=client_id,
|
||||
@@ -53,30 +35,35 @@ class TestOAuthServerServiceGetProviderApp:
|
||||
redirect_uris=["https://example.com/callback"],
|
||||
scope="read",
|
||||
)
|
||||
oauth_db.add(app)
|
||||
oauth_db.commit()
|
||||
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)
|
||||
|
||||
result = OAuthServerService.get_oauth_provider_app(client_id)
|
||||
|
||||
assert result is not None
|
||||
assert result.client_id == client_id
|
||||
assert result.id == app.id
|
||||
assert result.id == created.id
|
||||
|
||||
def test_get_oauth_provider_app_returns_none_when_not_exists(self, oauth_db: Session) -> None:
|
||||
def test_get_oauth_provider_app_returns_none_when_not_exists(self, db_session_with_containers: Session):
|
||||
result = OAuthServerService.get_oauth_provider_app(f"nonexistent-{uuid4()}")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestOAuthServerServiceTokenOperations:
|
||||
"""Verify Redis-backed token signing and validation branches."""
|
||||
"""Redis-backed tests for token sign/validate operations."""
|
||||
|
||||
@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) -> None:
|
||||
def test_sign_authorization_code_stores_and_returns_code(self, mock_redis):
|
||||
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")
|
||||
@@ -88,7 +75,7 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=600,
|
||||
)
|
||||
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis) -> None:
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis):
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
with pytest.raises(BadRequest, match="invalid code"):
|
||||
@@ -98,13 +85,14 @@ class TestOAuthServerServiceTokenOperations:
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis) -> None:
|
||||
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis):
|
||||
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",
|
||||
@@ -126,7 +114,7 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis) -> None:
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis):
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
with pytest.raises(BadRequest, match="invalid refresh token"):
|
||||
@@ -136,10 +124,11 @@ class TestOAuthServerServiceTokenOperations:
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis) -> None:
|
||||
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis):
|
||||
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",
|
||||
@@ -149,14 +138,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) -> None:
|
||||
def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis):
|
||||
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) -> None:
|
||||
def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis):
|
||||
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")
|
||||
@@ -168,21 +157,22 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, sqlite_engine: Engine) -> None:
|
||||
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, db_session_with_containers: Session):
|
||||
mock_redis.get.return_value = None
|
||||
session = MagicMock()
|
||||
|
||||
with Session(sqlite_engine) as session:
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", session)
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", db_session_with_containers)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_validate_access_token_loads_user_when_exists(self, mock_redis, sqlite_engine: Engine) -> None:
|
||||
def test_validate_access_token_loads_user_when_exists(self, mock_redis, db_session_with_containers: Session):
|
||||
mock_redis.get.return_value = b"user-88"
|
||||
expected_user = MagicMock()
|
||||
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
||||
assert result is expected_user
|
||||
mock_load.assert_called_once_with("user-88", db_session_with_containers)
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -11,13 +12,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
|
||||
from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.enums import AppTriggerType, 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 WorkflowAppService
|
||||
from services.workflow_app_service import LogView, WorkflowAppService
|
||||
from tests.test_containers_integration_tests.helpers import generate_valid_password
|
||||
|
||||
|
||||
@@ -1626,3 +1627,73 @@ 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
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -16,12 +15,9 @@ 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 (
|
||||
@@ -63,40 +59,6 @@ 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()]
|
||||
|
||||
@@ -401,35 +363,56 @@ 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")
|
||||
_persist_dataset(rbac_session)
|
||||
dataset_row = SimpleNamespace(
|
||||
id="dataset-1",
|
||||
tenant_id="tenant-1",
|
||||
permission="only_me",
|
||||
created_by="creator-account-1",
|
||||
)
|
||||
execute_results = [[dataset_row], [], []]
|
||||
calls: list[dict[str, object]] = []
|
||||
read_transaction_ended = False
|
||||
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()
|
||||
|
||||
def fake_replace_whitelist(**kwargs):
|
||||
assert read_transaction_ended is True
|
||||
assert session_closed is True
|
||||
calls.append(kwargs)
|
||||
|
||||
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
|
||||
|
||||
sa.event.listen(Session, "after_transaction_end", _record_transaction_end)
|
||||
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
|
||||
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)
|
||||
|
||||
command_module.migrate_dataset_permissions_to_rbac.callback(
|
||||
tenant_id=None,
|
||||
dataset_id=None,
|
||||
batch_size=500,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
assert calls[0]["tenant_id"] == "tenant-1"
|
||||
assert calls[0]["account_id"] == "creator-account-1"
|
||||
@@ -439,19 +422,41 @@ 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 = _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,
|
||||
)
|
||||
dataset_row = SimpleNamespace(
|
||||
id="dataset-1",
|
||||
tenant_id="tenant-1",
|
||||
permission="partial_members",
|
||||
created_by="creator-account-1",
|
||||
)
|
||||
rbac_session.commit()
|
||||
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)
|
||||
monkeypatch.setattr(
|
||||
rbac_module.RBACService.DatasetAccess,
|
||||
"replace_whitelist",
|
||||
@@ -1301,36 +1306,50 @@ def test_provider_models_processing_uses_same_plan_locking_and_transaction_entry
|
||||
begin_calls: list[str] = []
|
||||
configure_calls: list[str] = []
|
||||
|
||||
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"])
|
||||
class _FakeBeginContext:
|
||||
def __init__(self, phase: str) -> None:
|
||||
self._phase = phase
|
||||
|
||||
def _fake_build_plan(self, session: Session, candidate, *, lock_rows: bool):
|
||||
assert session.get_bind() is sqlite_engine
|
||||
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):
|
||||
lock_rows_seen.append((current_phase["name"], lock_rows))
|
||||
return migration_module._ProviderModelGroupPlan(
|
||||
group_row_ids=[str(candidate.row.id)],
|
||||
winner=None,
|
||||
loser_rows=[],
|
||||
)
|
||||
return SimpleNamespace(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: Session) -> None:
|
||||
assert session.get_bind() is sqlite_engine
|
||||
def _fake_configure(self, session) -> None:
|
||||
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)
|
||||
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)
|
||||
|
||||
dry_migration._process_provider_model_group(candidate, business_key)
|
||||
current_phase["name"] = "apply"
|
||||
apply_migration._process_provider_model_group(candidate, business_key)
|
||||
|
||||
assert [phase for phase, _ in lock_rows_seen] == ["dry", "apply"]
|
||||
assert lock_rows_seen[0][1] == lock_rows_seen[1][1]
|
||||
@@ -1373,22 +1392,6 @@ 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",
|
||||
@@ -1398,18 +1401,37 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
model_types=(ModelType.LLM,),
|
||||
orm_models=(migration_module.LoadBalancingModelConfig,),
|
||||
)
|
||||
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
|
||||
candidate = migration_module._RowWithRawModelType(
|
||||
row=SimpleNamespace(id="lb-row-1"),
|
||||
raw_model_type="text-generation",
|
||||
canonical_model_type=ModelType.LLM,
|
||||
)
|
||||
lock_timeout_exc = OperationalError("SELECT 1", {}, SimpleNamespace(pgcode="55P03"))
|
||||
transaction_begins = 0
|
||||
|
||||
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
|
||||
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 _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,
|
||||
@@ -1417,22 +1439,17 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
_fake_reload,
|
||||
)
|
||||
|
||||
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)
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
|
||||
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"] == str(candidate.row.id)
|
||||
assert attrs["id"] == "lb-row-1"
|
||||
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(
|
||||
@@ -1440,23 +1457,6 @@ 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,33 +1465,42 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
model_types=(ModelType.LLM,),
|
||||
orm_models=(migration_module.LoadBalancingModelConfig,),
|
||||
)
|
||||
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
|
||||
candidate = migration_module._RowWithRawModelType(
|
||||
row=SimpleNamespace(id="lb-row-1"),
|
||||
raw_model_type="text-generation",
|
||||
canonical_model_type=ModelType.LLM,
|
||||
)
|
||||
action_log: list[str] = []
|
||||
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
class _FakeBeginContext:
|
||||
def __enter__(self) -> None:
|
||||
action_log.append("begin")
|
||||
|
||||
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"):
|
||||
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:
|
||||
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")
|
||||
|
||||
original_reload = migration_module.Migration._reload_load_balancing_model_config_candidate
|
||||
|
||||
def _record_reload(self, session: Session, original_candidate, *, lock_rows: bool):
|
||||
def _fake_reload(self, session, original_candidate, *, lock_rows: bool):
|
||||
action_log.append(f"reload_candidate:{lock_rows}")
|
||||
return original_reload(self, session, original_candidate, lock_rows=lock_rows)
|
||||
return candidate
|
||||
|
||||
def _fake_log_row_updated(self, *args, **kwargs) -> None:
|
||||
action_log.append("log_row_updated")
|
||||
@@ -1499,11 +1508,12 @@ 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",
|
||||
_record_reload,
|
||||
_fake_reload,
|
||||
)
|
||||
monkeypatch.setattr(migration_module.Migration, "_log_row_updated", _fake_log_row_updated)
|
||||
monkeypatch.setattr(
|
||||
@@ -1512,13 +1522,7 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
_fake_cache_cleanup,
|
||||
)
|
||||
|
||||
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)
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
|
||||
assert action_log == [
|
||||
"begin",
|
||||
@@ -1528,10 +1532,6 @@ 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,22 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal, cast
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
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]]
|
||||
|
||||
@@ -26,15 +22,10 @@ def unwrap(func: object) -> ControllerMethod:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app() -> Iterator[Flask]:
|
||||
def flask_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
DataSourceOauthBinding.__table__.create(db.engine)
|
||||
yield app
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -44,13 +35,9 @@ def current_user() -> Account:
|
||||
return account
|
||||
|
||||
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
BINDING_ID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
|
||||
def _add_binding(session: Session, *, disabled: bool) -> DataSourceOauthBinding:
|
||||
def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) -> None:
|
||||
binding = DataSourceOauthBinding(
|
||||
tenant_id=TENANT_ID,
|
||||
tenant_id="tenant-1",
|
||||
access_token="token",
|
||||
provider="notion",
|
||||
source_info={
|
||||
@@ -68,31 +55,24 @@ def _add_binding(session: Session, *, disabled: bool) -> DataSourceOauthBinding:
|
||||
}
|
||||
],
|
||||
},
|
||||
disabled=disabled,
|
||||
)
|
||||
binding.id = BINDING_ID
|
||||
binding.id = "binding-1"
|
||||
binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC)
|
||||
session.add(binding)
|
||||
session.commit()
|
||||
return binding
|
||||
binding.disabled = False
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
assert status == 200
|
||||
assert response == {
|
||||
"data": [
|
||||
{
|
||||
"id": BINDING_ID,
|
||||
"id": "binding-1",
|
||||
"provider": "notion",
|
||||
"created_at": expected_created_at,
|
||||
"created_at": 1779670923,
|
||||
"is_bound": True,
|
||||
"disabled": False,
|
||||
"source_info": {
|
||||
@@ -116,75 +96,34 @@ def test_get_data_source_integrates_serializes_orm_binding(
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
assert status == 200
|
||||
assert response == {"data": []}
|
||||
|
||||
|
||||
@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()
|
||||
def test_patch_data_source_binding_uses_injected_session(flask_app: Flask) -> None:
|
||||
binding = MagicMock(disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().patch)(
|
||||
DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action
|
||||
)
|
||||
response, status = unwrap(DataSourceApi().patch)(DataSourceApi(), session, "tenant-1", uuid4(), "enable")
|
||||
|
||||
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 is not None
|
||||
assert binding.disabled is expected_disabled
|
||||
assert binding.disabled is False
|
||||
session.scalar.assert_called_once()
|
||||
session.add.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
@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:
|
||||
def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask, current_user: Account) -> None:
|
||||
page = MagicMock(
|
||||
page_id="page-1",
|
||||
page_name="Page",
|
||||
@@ -206,6 +145,8 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(
|
||||
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(
|
||||
@@ -217,7 +158,7 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(
|
||||
patch("core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", return_value=runtime),
|
||||
):
|
||||
response, status = unwrap(DataSourceNotionListApi().get)(
|
||||
DataSourceNotionListApi(), sqlite_session, "tenant-1", current_user
|
||||
DataSourceNotionListApi(), session, "tenant-1", current_user
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
@@ -242,38 +183,3 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Unit tests for controllers.console.datasets.data_source Notion endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets.data_source import (
|
||||
DataSourceNotionDatasetSyncApi,
|
||||
DataSourceNotionDocumentSyncApi,
|
||||
DataSourceNotionIndexingEstimateApi,
|
||||
DataSourceNotionPreviewApi,
|
||||
)
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from models import Account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
account = Account(name="Test User", email="u1@example.com")
|
||||
account.id = "u1"
|
||||
return account
|
||||
|
||||
|
||||
class TestDataSourceNotionPreviewApi:
|
||||
def test_get_preview_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionPreviewApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")])
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"integration_secret": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.NotionExtractor",
|
||||
return_value=extractor,
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1", "p1", "page")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionIndexingEstimateApi:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_post_indexing_estimate_success(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionIndexingEstimateApi()
|
||||
method = inspect.unwrap(api.post)
|
||||
|
||||
empty_rules: dict[str, object] = {}
|
||||
payload: dict[str, object] = {
|
||||
"notion_info_list": [
|
||||
{
|
||||
"workspace_id": "w1",
|
||||
"credential_id": "c1",
|
||||
"pages": [{"page_id": "p1", "type": "page"}],
|
||||
}
|
||||
],
|
||||
"process_rule": {"rules": empty_rules},
|
||||
"doc_form": IndexStructureType.PARAGRAPH_INDEX,
|
||||
"doc_language": "English",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.estimate_args_validate",
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.IndexingRunner.indexing_estimate",
|
||||
return_value=MagicMock(model_dump=lambda: {"total_pages": 1}),
|
||||
),
|
||||
):
|
||||
response, status = method(api, sqlite_session, "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionDatasetSyncApi:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_success(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id",
|
||||
return_value=[MagicMock(id="d1")],
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, sqlite_session, "ds-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_dataset_not_found(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, sqlite_session, "ds-1")
|
||||
|
||||
|
||||
class TestDataSourceNotionDocumentSyncApi:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_success(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, sqlite_session, "ds-1", "doc-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_document_not_found(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, sqlite_session, "ds-1", "doc-1")
|
||||
@@ -15,15 +15,12 @@ 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
|
||||
@@ -47,14 +44,6 @@ 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."""
|
||||
|
||||
@@ -264,7 +253,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, orm_session: Session):
|
||||
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination):
|
||||
"""Test pagination_by_first_id returns expected format."""
|
||||
mock_result = Mock()
|
||||
mock_result.data = []
|
||||
@@ -278,7 +267,7 @@ class TestMessageService:
|
||||
conversation_id=str(uuid.uuid4()),
|
||||
first_id=None,
|
||||
limit=20,
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
assert hasattr(result, "data")
|
||||
@@ -286,7 +275,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, orm_session: Session):
|
||||
def test_pagination_raises_conversation_not_exists_error(self, mock_pagination):
|
||||
"""Test pagination raises ConversationNotExistsError."""
|
||||
import services.errors.conversation
|
||||
|
||||
@@ -299,11 +288,11 @@ class TestMessageService:
|
||||
conversation_id="invalid_id",
|
||||
first_id=None,
|
||||
limit=20,
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination, orm_session: Session):
|
||||
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination):
|
||||
"""Test pagination raises FirstMessageNotExistsError."""
|
||||
mock_pagination.side_effect = FirstMessageNotExistsError()
|
||||
|
||||
@@ -314,11 +303,11 @@ class TestMessageService:
|
||||
conversation_id=str(uuid.uuid4()),
|
||||
first_id="invalid_first_id",
|
||||
limit=20,
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "create_feedback")
|
||||
def test_create_feedback_with_rating_and_content(self, mock_create_feedback, orm_session: Session):
|
||||
def test_create_feedback_with_rating_and_content(self, mock_create_feedback):
|
||||
"""Test create_feedback with rating and content."""
|
||||
mock_create_feedback.return_value = None
|
||||
|
||||
@@ -328,13 +317,13 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
rating=FeedbackRating.LIKE,
|
||||
content="Great response!",
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
mock_create_feedback.assert_called_once()
|
||||
|
||||
@patch.object(MessageService, "create_feedback")
|
||||
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback, orm_session: Session):
|
||||
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback):
|
||||
"""Test create_feedback raises MessageNotExistsError."""
|
||||
mock_create_feedback.side_effect = MessageNotExistsError()
|
||||
|
||||
@@ -345,11 +334,11 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
rating=FeedbackRating.LIKE,
|
||||
content=None,
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "get_all_messages_feedbacks")
|
||||
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks, orm_session: Session):
|
||||
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks):
|
||||
"""Test get_all_messages_feedbacks returns list of feedbacks."""
|
||||
mock_feedbacks = [
|
||||
{"message_id": str(uuid.uuid4()), "rating": "like"},
|
||||
@@ -357,15 +346,13 @@ 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=orm_session
|
||||
)
|
||||
result = MessageService.get_all_messages_feedbacks(app_model=Mock(spec=App), page=1, limit=20, session=Mock())
|
||||
|
||||
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, orm_session: Session):
|
||||
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions):
|
||||
"""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
|
||||
@@ -375,14 +362,14 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
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, orm_session: Session):
|
||||
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions):
|
||||
"""Test get_suggested_questions_after_answer raises SuggestedQuestionsAfterAnswerDisabledError."""
|
||||
mock_get_questions.side_effect = SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
@@ -392,11 +379,11 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions, orm_session: Session):
|
||||
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions):
|
||||
"""Test get_suggested_questions_after_answer raises MessageNotExistsError."""
|
||||
mock_get_questions.side_effect = MessageNotExistsError()
|
||||
|
||||
@@ -406,7 +393,7 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id="invalid_message_id",
|
||||
invoke_from=Mock(),
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+16
-34
@@ -24,7 +24,6 @@ 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
|
||||
|
||||
@@ -39,7 +38,6 @@ 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,
|
||||
@@ -48,20 +46,6 @@ 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."""
|
||||
|
||||
@@ -566,15 +550,13 @@ 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")
|
||||
@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
|
||||
):
|
||||
def test_post_success_streaming(self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app):
|
||||
"""Test successful pipeline run with streaming response."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
|
||||
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
session = Mock()
|
||||
session.scalar.return_value = Mock()
|
||||
|
||||
mock_ns.payload = {
|
||||
"inputs": {"key": "val"},
|
||||
@@ -595,33 +577,33 @@ class TestPipelineRunApiPost:
|
||||
|
||||
with app.test_request_context("/datasets/test/pipeline/run", method="POST"):
|
||||
api = PipelineRunApi()
|
||||
response = api.post.__wrapped__(api, sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
response = api.post.__wrapped__(api, session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
|
||||
assert response == {"result": "ok"}
|
||||
mock_svc_cls.assert_called_once_with(sqlite_session)
|
||||
mock_svc_cls.assert_called_once_with(session)
|
||||
mock_gen_svc.generate.assert_called_once()
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_not_found(self, app: Flask, sqlite_session: Session):
|
||||
def test_post_not_found(self, app: Flask):
|
||||
"""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,
|
||||
sqlite_session,
|
||||
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")
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask, sqlite_session: Session):
|
||||
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask):
|
||||
"""Test Forbidden when current_user is not an Account."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
session = Mock()
|
||||
session.scalar.return_value = Mock()
|
||||
mock_ns.payload = {
|
||||
"inputs": {},
|
||||
"datasource_type": "online_document",
|
||||
@@ -636,9 +618,9 @@ class TestPipelineRunApiPost:
|
||||
with pytest.raises(Forbidden):
|
||||
api.post.__wrapped__(
|
||||
api,
|
||||
sqlite_session,
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
session,
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
dataset_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,13 +3,10 @@ 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 (
|
||||
@@ -24,11 +21,12 @@ from controllers.service_api.wraps import (
|
||||
validate_dataset_token,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
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
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _configure_current_app_mock(mock_current_app):
|
||||
@@ -36,51 +34,6 @@ 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"""
|
||||
|
||||
@@ -117,24 +70,21 @@ 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
|
||||
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_api_token = Mock(spec=ApiToken)
|
||||
mock_api_token.token = "valid_token_123"
|
||||
mock_api_token.type = "app"
|
||||
|
||||
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 = api_token
|
||||
mock_fetch_token.return_value = mock_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 == api_token
|
||||
assert result == mock_api_token
|
||||
|
||||
@patch("controllers.service_api.wraps.record_token_usage")
|
||||
@patch("controllers.service_api.wraps.ApiTokenCache")
|
||||
@@ -167,124 +117,116 @@ 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_user_logged_in,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app
|
||||
):
|
||||
"""Test that valid app token allows access to decorated view."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
@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"}),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
result = protected_view()
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
assert result["app_id"] == app_model.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
assert result["app_id"] == mock_app.id
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_not_found_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
def test_app_not_found_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""Test that Forbidden is raised when app no longer exists."""
|
||||
# Arrange
|
||||
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
|
||||
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
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with app.test_request_context("/", method="GET"):
|
||||
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")
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""Test that Forbidden is raised when app status is abnormal."""
|
||||
# Arrange
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with app.test_request_context("/", method="GET"):
|
||||
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")
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""Test that Forbidden is raised when app API is disabled."""
|
||||
# Arrange
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "API service has been disabled" in str(exc_info.value)
|
||||
@@ -526,35 +468,26 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit")
|
||||
@pytest.mark.parametrize("sqlite_session", [(RateLimitLog,)], indirect=True)
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.sessionmaker")
|
||||
def test_rejects_over_rate_limit(
|
||||
self,
|
||||
mock_get_rate_limit,
|
||||
mock_validate_token,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
self, mock_sessionmaker, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask
|
||||
):
|
||||
"""Test that Forbidden is raised when over rate limit."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=tenant_id,
|
||||
token_type=ApiTokenType.DATASET,
|
||||
)
|
||||
mock_validate_token.return_value = Mock(tenant_id="tenant123")
|
||||
|
||||
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,
|
||||
patch(
|
||||
"controllers.service_api.wraps.db",
|
||||
SimpleNamespace(engine=sqlite_session.get_bind()),
|
||||
),
|
||||
):
|
||||
with patch("controllers.service_api.wraps.redis_client") as mock_redis:
|
||||
mock_redis.zcard.return_value = 15 # Over limit
|
||||
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
@@ -566,12 +499,9 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
knowledge_request()
|
||||
assert "rate limit" in str(exc_info.value)
|
||||
|
||||
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"
|
||||
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()
|
||||
|
||||
|
||||
class TestValidateDatasetToken:
|
||||
@@ -585,62 +515,65 @@ 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")
|
||||
@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,
|
||||
):
|
||||
def test_valid_dataset_token(self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app: Flask):
|
||||
"""Test that valid dataset token allows access."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@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"}),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
result = protected_view()
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
assert result["tenant_id"] == tenant.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
assert result["tenant_id"] == tenant_id
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@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):
|
||||
def test_dataset_not_found_raises_not_found(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""Test that NotFound is raised when dataset doesn't exist."""
|
||||
# Arrange
|
||||
api_token = _api_token(tenant_id=str(uuid.uuid4()), token_type=ApiTokenType.DATASET)
|
||||
mock_validate_token.return_value = api_token
|
||||
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
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(dataset_id=None, **kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with pytest.raises(NotFound) as exc_info:
|
||||
protected_view(dataset_id=str(uuid.uuid4()))
|
||||
assert "Dataset not found" in str(exc_info.value)
|
||||
|
||||
@@ -6,19 +6,13 @@ 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()
|
||||
@@ -39,27 +33,17 @@ def app():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_wraps(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
def _patch_wraps():
|
||||
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,7 +54,6 @@ 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
|
||||
|
||||
|
||||
@@ -194,7 +193,7 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert isinstance(responses[0], ValueError)
|
||||
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine):
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._graph_runtime_state = GraphRuntimeState(
|
||||
variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")),
|
||||
@@ -202,10 +201,11 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
)
|
||||
pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.generate_task_pipeline.db",
|
||||
SimpleNamespace(engine=sqlite_engine),
|
||||
)
|
||||
@contextmanager
|
||||
def _fake_session():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
|
||||
monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None)
|
||||
|
||||
responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))
|
||||
@@ -339,18 +339,19 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert responses == ["finish"]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_created_from(self, sqlite_session: Session):
|
||||
def test_save_workflow_app_log_created_from(self):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
pipeline._user_id = "user"
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
|
||||
sqlite_session.flush()
|
||||
added: list[object] = []
|
||||
|
||||
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"
|
||||
class _Session:
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
|
||||
assert added
|
||||
|
||||
def test_iteration_loop_and_human_input_handlers(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -673,29 +674,35 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
assert "Fails to get audio trunk, task_id: task" in caplog.messages
|
||||
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
|
||||
|
||||
@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
|
||||
):
|
||||
def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch):
|
||||
pipeline = _make_pipeline()
|
||||
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),
|
||||
)
|
||||
calls = {"enter": 0, "exit_exc": None}
|
||||
|
||||
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")
|
||||
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"):
|
||||
persist_then_fail()
|
||||
with pipeline._database_session():
|
||||
raise RuntimeError("db error")
|
||||
|
||||
sqlite_session.expire_all()
|
||||
assert sqlite_session.scalar(select(WorkflowAppLog)) is None
|
||||
assert calls["enter"] == 1
|
||||
assert calls["exit_exc"] is RuntimeError
|
||||
|
||||
def test_node_retry_and_started_handlers_cover_none_and_value(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -855,30 +862,31 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["stopped"])
|
||||
assert list(pipeline._process_stream_response()) == ["stopped"]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self, sqlite_session: Session):
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self):
|
||||
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=sqlite_session, workflow_run_id="run-id")
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "installed-app"
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.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"]
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "web-app"
|
||||
|
||||
count_before = len(saved_logs)
|
||||
count_before = len(added)
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER
|
||||
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._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert len(added) == count_before
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
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
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id=None)
|
||||
assert len(added) == count_before
|
||||
|
||||
def test_save_output_for_event_writes_draft_variables(self):
|
||||
pipeline = _make_pipeline()
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
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
|
||||
@@ -17,63 +13,19 @@ from graphon.graph_events import (
|
||||
GraphRunSucceededEvent,
|
||||
)
|
||||
from graphon.runtime import VariablePool
|
||||
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
|
||||
from models.enums import WorkflowTriggerStatus
|
||||
|
||||
|
||||
class TestTriggerPostLayer:
|
||||
def test_on_event_updates_trigger_log(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
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,
|
||||
)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"answer": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -83,10 +35,19 @@ 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),
|
||||
@@ -96,18 +57,25 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent())
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
def test_on_event_updates_trigger_log_for_aborted_event(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
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,
|
||||
)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"partial": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -117,10 +85,19 @@ 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),
|
||||
@@ -130,22 +107,17 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunAbortedEvent(reason="timeout"))
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
def test_on_event_handles_missing_trigger_log(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
trigger_database: TriggerDatabase,
|
||||
):
|
||||
def test_on_event_handles_missing_trigger_log(self, caplog: pytest.LogCaptureFixture):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -154,20 +126,31 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
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 (
|
||||
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
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
|
||||
layer.on_event(GraphRunFailedEvent(error="boom"))
|
||||
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"))
|
||||
|
||||
assert any(record.levelno == logging.ERROR for record in caplog.records)
|
||||
assert trigger_database.session.get(WorkflowTriggerLog, "missing") is None
|
||||
session.commit.assert_not_called()
|
||||
|
||||
def test_on_event_ignores_non_status_events(self, trigger_database: TriggerDatabase):
|
||||
def test_on_event_ignores_non_status_events(self):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -176,14 +159,14 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
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())
|
||||
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())
|
||||
|
||||
trigger_database.statements.clear()
|
||||
layer.on_event(Mock())
|
||||
layer.on_event(Mock())
|
||||
|
||||
assert trigger_database.statements == []
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import types
|
||||
from collections.abc import Generator, Iterator
|
||||
from collections.abc import Generator
|
||||
|
||||
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
|
||||
@@ -15,34 +12,6 @@ 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]:
|
||||
@@ -404,8 +373,7 @@ 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, tool_file_session: Session):
|
||||
_persist_tool_file(tool_file_session, file_id="tool_file_1", tenant_id="t1")
|
||||
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture):
|
||||
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
|
||||
|
||||
def _transformed(**_kwargs):
|
||||
@@ -450,6 +418,19 @@ 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,
|
||||
@@ -500,8 +481,7 @@ 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, tool_file_session: Session):
|
||||
_persist_tool_file(tool_file_session, file_id="missing", tenant_id="other-tenant")
|
||||
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture):
|
||||
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
|
||||
|
||||
def _transformed(**_kwargs):
|
||||
@@ -516,6 +496,18 @@ 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,64 +14,18 @@ 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:
|
||||
@@ -809,14 +763,9 @@ 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,
|
||||
extractor_page: NotionExtractor,
|
||||
database: _Database,
|
||||
persisted_document: DocumentModel,
|
||||
):
|
||||
def test_update_last_edited_time(self, mock_request, mock_db, extractor_page, mock_document_model):
|
||||
"""Test updating document model with last edited time."""
|
||||
# Arrange
|
||||
mock_response = Mock()
|
||||
@@ -828,11 +777,11 @@ class TestNotionExtractorLastEditedTime:
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
# Act
|
||||
extractor_page.update_last_edited_time(persisted_document)
|
||||
extractor_page.update_last_edited_time(mock_document_model)
|
||||
|
||||
# Assert
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
|
||||
assert mock_document_model.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
def test_update_last_edited_time_no_document(self, extractor_page):
|
||||
"""Test update_last_edited_time with None document model."""
|
||||
@@ -858,10 +807,9 @@ 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, database: _Database, persisted_document: DocumentModel
|
||||
):
|
||||
def test_extract_page_complete_workflow(self, mock_request, mock_db, mock_document_model):
|
||||
"""Test complete page extraction workflow."""
|
||||
# Arrange
|
||||
extractor = NotionExtractor(
|
||||
@@ -870,7 +818,7 @@ class TestNotionExtractorIntegration:
|
||||
notion_page_type="page",
|
||||
tenant_id="tenant-789",
|
||||
notion_access_token="test-token",
|
||||
document_model=persisted_document,
|
||||
document_model=mock_document_model,
|
||||
)
|
||||
|
||||
# Mock last edited time request
|
||||
@@ -921,18 +869,11 @@ 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,
|
||||
mock_post: Mock,
|
||||
database: _Database,
|
||||
persisted_document: DocumentModel,
|
||||
):
|
||||
def test_extract_database_complete_workflow(self, mock_request, mock_post, mock_db, mock_document_model):
|
||||
"""Test complete database extraction workflow."""
|
||||
# Arrange
|
||||
extractor = NotionExtractor(
|
||||
@@ -941,7 +882,7 @@ class TestNotionExtractorIntegration:
|
||||
notion_page_type="database",
|
||||
tenant_id="tenant-789",
|
||||
notion_access_token="test-token",
|
||||
document_model=persisted_document,
|
||||
document_model=mock_document_model,
|
||||
)
|
||||
|
||||
# Mock last edited time request
|
||||
@@ -980,8 +921,6 @@ class TestNotionExtractorIntegration:
|
||||
assert isinstance(documents[0], Document)
|
||||
assert "Name:Item 1" in documents[0].page_content
|
||||
assert "Status:Active" in documents[0].page_content
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
|
||||
|
||||
def test_extract_invalid_page_type(self):
|
||||
"""Test extract with invalid page type."""
|
||||
|
||||
@@ -2,21 +2,9 @@ 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:
|
||||
@@ -70,22 +58,82 @@ 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))
|
||||
|
||||
monkeypatch.setattr(excel_module.storage, "save", save)
|
||||
_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.dify_config, "FILES_URL", "http://files.local", raising=False)
|
||||
monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False)
|
||||
|
||||
return saves
|
||||
|
||||
|
||||
def _get_upload_files(session_maker: sessionmaker[Session]) -> list[UploadFile]:
|
||||
with session_maker() as session:
|
||||
return list(session.scalars(select(UploadFile)).all())
|
||||
return saves, session_factory
|
||||
|
||||
|
||||
class TestExcelExtractor:
|
||||
@@ -112,11 +160,7 @@ 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,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
def test_extract_xlsx_turns_embedded_images_into_markdown_links(self, monkeypatch: pytest.MonkeyPatch):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nexcel-image"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -131,7 +175,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -140,30 +184,23 @@ 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";'
|
||||
f'"Image":" '
|
||||
f'"'
|
||||
'"Image":" '
|
||||
'"'
|
||||
)
|
||||
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 upload_files[0].tenant_id == "tenant-1"
|
||||
assert upload_files[0].key == saves[0][0]
|
||||
assert upload_files[0].used is True
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1]
|
||||
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(self, monkeypatch: pytest.MonkeyPatch):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nimage-only-row"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -175,7 +212,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -184,21 +221,17 @@ 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 == (
|
||||
f'"Question":"";"Answer":"";"Image":""'
|
||||
'"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,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(self, monkeypatch: pytest.MonkeyPatch):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nretry-safe-image"
|
||||
workbooks = [
|
||||
_FakeWorkbook(
|
||||
@@ -221,7 +254,7 @@ class TestExcelExtractor:
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbooks.pop(0))
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -231,17 +264,16 @@ 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";'
|
||||
f'"Image":""'
|
||||
'"Question":"Q1";"Answer":"A1";"Image":""'
|
||||
)
|
||||
|
||||
assert first_docs[0].page_content == expected_page_content
|
||||
assert second_docs[0].page_content == expected_page_content
|
||||
assert len(saves) == 1
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1, 0]
|
||||
|
||||
def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
|
||||
class FakeExcelFile:
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.helper import OptionalTimestampField, alphanumeric, email, escape_like_pattern, extract_tenant_id
|
||||
from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id
|
||||
from models.account import Account
|
||||
from models.model import EndUser
|
||||
|
||||
@@ -153,47 +153,3 @@ 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")
|
||||
|
||||
@@ -1,105 +1,89 @@
|
||||
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 _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 [],
|
||||
)
|
||||
def _patched_session():
|
||||
"""Return a mock SQLAlchemy session for service calls."""
|
||||
session = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
class TestGetStrategy:
|
||||
@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()
|
||||
def test_returns_strategy_when_found(self):
|
||||
session = _patched_session()
|
||||
strategy = MagicMock()
|
||||
session.scalar.return_value = strategy
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy(tenant_id, PLUGIN_CATEGORY, session=sqlite_session)
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is strategy
|
||||
|
||||
@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
|
||||
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
|
||||
|
||||
|
||||
class TestChangeStrategy:
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_new_strategy(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
def test_creates_new_strategy(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
tenant_id,
|
||||
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
3,
|
||||
TenantPluginAutoUpgradeMode.ALL,
|
||||
[],
|
||||
[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=sqlite_session,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
session.add.assert_called_once()
|
||||
|
||||
@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()
|
||||
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
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
tenant_id,
|
||||
"t1",
|
||||
TenantPluginAutoUpgradeStrategySetting.LATEST,
|
||||
5,
|
||||
TenantPluginAutoUpgradeMode.PARTIAL,
|
||||
["p1"],
|
||||
["p2"],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=sqlite_session,
|
||||
session=session,
|
||||
)
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
assert result is True
|
||||
assert existing.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
|
||||
assert existing.upgrade_time_of_day == 5
|
||||
@@ -109,115 +93,157 @@ class TestChangeStrategy:
|
||||
|
||||
|
||||
class TestExcludePlugin:
|
||||
@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())
|
||||
def test_creates_default_strategy_when_none_exists(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin(tenant_id, "plugin-1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
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,
|
||||
)
|
||||
|
||||
strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy))
|
||||
assert result is True
|
||||
assert strategy is not None
|
||||
assert strategy.exclude_plugins == ["plugin-1"]
|
||||
session.add.assert_called_once()
|
||||
|
||||
@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()
|
||||
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
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p-new", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
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
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
assert existing.exclude_plugins == ["p-existing", "p-new"]
|
||||
|
||||
@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()
|
||||
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
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
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
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
assert existing.include_plugins == ["p2"]
|
||||
|
||||
@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()
|
||||
def test_switches_to_exclude_mode_from_all(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.ALL
|
||||
session.scalar.return_value = existing
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
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
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
assert existing.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
@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()
|
||||
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
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
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)
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
|
||||
class TestBackfillStrategyCategories:
|
||||
@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()
|
||||
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]
|
||||
installer = MagicMock()
|
||||
|
||||
with patch(f"{MODULE}.PluginInstaller", return_value=installer):
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
|
||||
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
|
||||
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")
|
||||
|
||||
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 strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
|
||||
strategy for strategy in created_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) -> None:
|
||||
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day(str(uuid4()))
|
||||
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")
|
||||
|
||||
assert default_time % (15 * 60) == 0
|
||||
assert 0 <= default_time < 24 * 60 * 60
|
||||
|
||||
@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"],
|
||||
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"],
|
||||
)
|
||||
model_strategy = _strategy(
|
||||
tenant_id,
|
||||
model_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeCategory.MODEL,
|
||||
exclude=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include=["model-plugin", "tool-plugin"],
|
||||
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"],
|
||||
)
|
||||
sqlite_session.add_all([tool_strategy, model_strategy])
|
||||
sqlite_session.commit()
|
||||
session.scalars.return_value.all.return_value = [tool_strategy, model_strategy]
|
||||
|
||||
installed_plugins = [
|
||||
SimpleNamespace(
|
||||
plugin_id="tool-plugin",
|
||||
@@ -235,17 +261,18 @@ class TestBackfillStrategyCategories:
|
||||
patch(f"{MODULE}.PluginInstaller", return_value=installer),
|
||||
caplog.at_level(logging.WARNING, logger=MODULE),
|
||||
):
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session)
|
||||
|
||||
strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all())
|
||||
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 2
|
||||
assert result.normalized is True
|
||||
assert len(strategies) == len(TenantPluginAutoUpgradeCategory)
|
||||
assert session.add.call_count == len(TenantPluginAutoUpgradeCategory) - 2
|
||||
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: "
|
||||
f"tenant_id={tenant_id}, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
|
||||
"tenant_id=t1, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
|
||||
)
|
||||
|
||||
@@ -7,19 +7,28 @@ 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"
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
USER_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _build_provider_entity(provider: str = "openai") -> ProviderEntity:
|
||||
@@ -1157,72 +1166,19 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
assert result is True
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
@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:
|
||||
def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(self) -> 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=plugin_id,
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||
)
|
||||
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
|
||||
|
||||
session = _FakeSession()
|
||||
with (
|
||||
patch(f"{MODULE}.db", SimpleNamespace(engine=sqlite_session.get_bind())),
|
||||
patch(f"{MODULE}.db", SimpleNamespace(engine=object())),
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.ProviderCredentialsCache") as credentials_cache,
|
||||
patch(f"{MODULE}.Session", return_value=session),
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
@@ -1232,26 +1188,8 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.uninstall(TENANT_ID, "installation-1")
|
||||
result = PluginService.uninstall("tenant-1", "installation-1")
|
||||
|
||||
assert result is True
|
||||
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
|
||||
installer.uninstall.assert_called_once_with("tenant-1", "installation-1")
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
|
||||
@@ -20,6 +19,7 @@ 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,16 +27,20 @@ from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import (
|
||||
PluginInstallationPermissionModel,
|
||||
PluginInstallationScope,
|
||||
SystemFeatureModel,
|
||||
)
|
||||
|
||||
|
||||
def _make_permission(
|
||||
def _make_features(
|
||||
restrict_to_marketplace: bool = False,
|
||||
scope: PluginInstallationScope = PluginInstallationScope.ALL,
|
||||
) -> PluginInstallationPermissionModel:
|
||||
return PluginInstallationPermissionModel(
|
||||
restrict_to_marketplace_only=restrict_to_marketplace,
|
||||
plugin_installation_scope=scope,
|
||||
) -> SystemFeatureModel:
|
||||
return SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
plugin_installation_permission=PluginInstallationPermissionModel(
|
||||
restrict_to_marketplace_only=restrict_to_marketplace,
|
||||
plugin_installation_scope=scope,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -115,31 +119,22 @@ class TestFetchLatestPluginVersion:
|
||||
class TestCheckMarketplaceOnlyPermission:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_raises_when_restricted(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=True)
|
||||
mock_fs.get_system_features.return_value = _make_features(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_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=False)
|
||||
mock_fs.get_system_features.return_value = _make_features(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_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_ONLY
|
||||
)
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
verification = MagicMock()
|
||||
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
|
||||
|
||||
@@ -147,16 +142,14 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_rejects_third_party(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_ONLY
|
||||
)
|
||||
mock_fs.get_system_features.return_value = _make_features(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_plugin_installation_permission.return_value = _make_permission(
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
)
|
||||
verification = MagicMock()
|
||||
@@ -166,7 +159,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_rejects_none(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
)
|
||||
|
||||
@@ -175,7 +168,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_none_scope_always_raises(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.NONE)
|
||||
verification = MagicMock()
|
||||
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
|
||||
|
||||
@@ -184,19 +177,10 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_all_scope_passes_any(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.ALL)
|
||||
mock_fs.get_system_features.return_value = _make_features(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")
|
||||
@@ -264,7 +248,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_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
installer.upgrade_plugin.return_value = MagicMock()
|
||||
@@ -280,7 +264,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_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
mock_download.return_value = b"pkg-bytes"
|
||||
@@ -299,7 +283,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_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.upgrade_plugin.return_value = MagicMock()
|
||||
|
||||
@@ -314,7 +298,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_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
upload_resp = MagicMock()
|
||||
upload_resp.verification = None
|
||||
mock_installer_cls.return_value.upload_pkg.return_value = upload_resp
|
||||
@@ -338,7 +322,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_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
mock_download.return_value = b"pkg"
|
||||
@@ -360,7 +344,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_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
decode_resp = MagicMock()
|
||||
|
||||
+270
-213
@@ -10,19 +10,14 @@ 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, patch
|
||||
from unittest.mock import Mock, create_autospec, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
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 sqlalchemy import Column, Integer, MetaData, String, Table
|
||||
|
||||
from libs.archive_storage import ArchiveStorageNotConfiguredError
|
||||
from models.enums import CreatorUserRole
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
from models.workflow import (
|
||||
WorkflowAppLog,
|
||||
@@ -33,7 +28,6 @@ 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,
|
||||
@@ -42,49 +36,24 @@ 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 persisted-model-compatible test data.
|
||||
Factory for creating test data and mock objects.
|
||||
|
||||
Provides reusable methods to create consistent mock objects for testing
|
||||
workflow run restore operations.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_run(
|
||||
def create_workflow_run_mock(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> WorkflowRun:
|
||||
) -> Mock:
|
||||
"""
|
||||
Create a concrete WorkflowRun object.
|
||||
Create a mock WorkflowRun object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -94,44 +63,27 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
WorkflowRun object with specified attributes
|
||||
Mock WorkflowRun object with specified attributes
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
return run
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_archive_log(
|
||||
def create_workflow_archive_log_mock(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> WorkflowArchiveLog:
|
||||
) -> Mock:
|
||||
"""
|
||||
Create a concrete WorkflowArchiveLog object.
|
||||
Create a mock WorkflowArchiveLog object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -141,32 +93,16 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
WorkflowArchiveLog object with specified attributes
|
||||
Mock WorkflowArchiveLog object with specified attributes
|
||||
"""
|
||||
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)
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def create_archive_zip_mock(
|
||||
@@ -201,7 +137,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"triggered_from": "app",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -215,7 +151,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "service-api",
|
||||
"created_from": "app",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -225,7 +161,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "service-api",
|
||||
"created_from": "app",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -289,10 +225,14 @@ class TestGetWorkflowRunRepo:
|
||||
"""Tests for WorkflowRunRestore._get_workflow_run_repo method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.DifyAPIRepositoryFactory")
|
||||
def test_first_call_creates_repo(self, mock_factory, database: Database):
|
||||
@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):
|
||||
"""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
|
||||
|
||||
@@ -300,9 +240,8 @@ class TestGetWorkflowRunRepo:
|
||||
|
||||
assert result is mock_repo
|
||||
assert restore.workflow_run_repo is mock_repo
|
||||
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
|
||||
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)
|
||||
|
||||
def test_cached_repo_returned(self):
|
||||
"""Subsequent calls should return cached repository."""
|
||||
@@ -553,27 +492,47 @@ class TestGetModelColumnInfo:
|
||||
class TestRestoreTableRecords:
|
||||
"""Tests for WorkflowRunRestore._restore_table_records method."""
|
||||
|
||||
def test_unknown_table_returns_zero(self, database: Database, caplog: pytest.LogCaptureFixture):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.TABLE_MODELS")
|
||||
def test_unknown_table_returns_zero(self, mock_table_models, 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(database.session, "unknown_table", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(mock_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, database: Database):
|
||||
def test_empty_records_returns_zero(self):
|
||||
"""Should return 0 for empty records list."""
|
||||
restore = WorkflowRunRestore()
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", [], schema_version="1.0")
|
||||
mock_session = Mock()
|
||||
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", [], schema_version="1.0")
|
||||
assert result == 0
|
||||
|
||||
def test_successful_restore(self, database: Database):
|
||||
@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):
|
||||
"""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",
|
||||
@@ -581,7 +540,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"triggered_from": "app",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -593,7 +552,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"triggered_from": "app",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -601,20 +560,38 @@ class TestRestoreTableRecords:
|
||||
},
|
||||
]
|
||||
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", records, schema_version="1.0")
|
||||
|
||||
assert result == 2
|
||||
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
|
||||
mock_session.execute.assert_called_once()
|
||||
|
||||
def test_missing_required_columns_raises_error(self, database: Database):
|
||||
def test_missing_required_columns_raises_error(self):
|
||||
"""Should raise ValueError for missing required columns."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
records = [{"id": "test"}]
|
||||
mock_session = Mock()
|
||||
# Use a dedicated mock model to isolate required-column validation behavior.
|
||||
mock_model = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required columns for workflow_runs"):
|
||||
restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
# 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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -626,38 +603,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, database: Database):
|
||||
def test_archive_storage_not_configured(self, mock_get_storage):
|
||||
"""Should handle ArchiveStorageNotConfiguredError."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("Storage not configured")
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
|
||||
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, database: Database):
|
||||
def test_archive_bundle_not_found(self, mock_get_storage):
|
||||
"""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()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
|
||||
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, database: Database):
|
||||
def test_dry_run_mode(self, mock_get_storage):
|
||||
"""Should handle dry run mode correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -667,16 +644,23 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
# 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)
|
||||
|
||||
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")
|
||||
def test_successful_restore(self, mock_get_storage, database: Database):
|
||||
@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):
|
||||
"""Should successfully restore from archive."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -686,57 +670,53 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
# 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()
|
||||
|
||||
# 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=database.session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts["workflow_runs"] == 1
|
||||
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
|
||||
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)
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_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):
|
||||
def test_invalid_archive_bundle(self, mock_get_storage):
|
||||
"""Should handle invalid archive bundle."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -745,17 +725,22 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = b"invalid zip data"
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
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)
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
|
||||
|
||||
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, database: Database):
|
||||
def test_workflow_archive_log_input(self, mock_get_storage):
|
||||
"""Should handle WorkflowArchiveLog input correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -765,11 +750,14 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
|
||||
result = restore._restore_from_run(archive_log, session_maker=database.session_maker)
|
||||
# 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)
|
||||
|
||||
assert result.success is True
|
||||
assert result.run_id == archive_log.workflow_run_id
|
||||
@@ -784,29 +772,39 @@ class TestRestoreFromRun:
|
||||
class TestRestoreBatch:
|
||||
"""Tests for WorkflowRunRestore.restore_batch method."""
|
||||
|
||||
def test_empty_tenant_ids_returns_empty(self, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_empty_tenant_ids_returns_empty(self, mock_sessionmaker):
|
||||
"""Should return empty list when tenant_ids is empty list."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
result = restore.restore_batch(
|
||||
tenant_ids=[],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
# 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),
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_successful_batch_restore(self, mock_executor, database: Database):
|
||||
def test_successful_batch_restore(self, mock_executor):
|
||||
"""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("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-2")
|
||||
database.session.add_all([archive_log1, archive_log2])
|
||||
database.session.commit()
|
||||
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-2")
|
||||
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log1, archive_log2]
|
||||
|
||||
# Mock restore results
|
||||
@@ -823,25 +821,38 @@ 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:
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
# 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),
|
||||
)
|
||||
|
||||
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, database: Database):
|
||||
def test_dry_run_batch_restore(self, mock_executor):
|
||||
"""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()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
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})
|
||||
@@ -856,11 +867,18 @@ 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:
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
# 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),
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].success is True
|
||||
@@ -889,14 +907,16 @@ class TestRestoreByRunId:
|
||||
assert "not found" in result.error
|
||||
assert result.run_id == "nonexistent-run"
|
||||
|
||||
def test_successful_restore_by_id(self, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_successful_restore_by_id(self, mock_sessionmaker):
|
||||
"""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()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
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={})
|
||||
@@ -904,19 +924,24 @@ 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:
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
# 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")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
|
||||
def test_dry_run_restore_by_id(self, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_dry_run_restore_by_id(self, mock_sessionmaker):
|
||||
"""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()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
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})
|
||||
@@ -924,7 +949,10 @@ 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:
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
# 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")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
@@ -1010,7 +1038,8 @@ class TestIntegration:
|
||||
"""Integration tests combining multiple components."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_full_restore_flow(self, mock_get_storage, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_full_restore_flow(self, mock_executor, mock_get_storage):
|
||||
"""Test complete restore flow with all components."""
|
||||
restore = WorkflowRunRestore(workers=1)
|
||||
|
||||
@@ -1030,7 +1059,7 @@ class TestIntegration:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"triggered_from": "app",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -1043,20 +1072,48 @@ 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()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
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 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
|
||||
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
|
||||
result = restore.restore_by_run_id("run-123")
|
||||
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")
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts.get("workflow_runs") == 1
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
|
||||
|
||||
@@ -7,16 +7,14 @@ 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:
|
||||
@@ -73,47 +71,45 @@ 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:
|
||||
@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,
|
||||
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
|
||||
)
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
account.id = ACCOUNT_ID
|
||||
sqlite_session.add_all([account, app_model])
|
||||
sqlite_session.commit()
|
||||
account = SimpleNamespace(id="acct-1")
|
||||
|
||||
new_config = AgentAppFeatureConfigService.update_features(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
app_model=app_model, # type: ignore[arg-type]
|
||||
account=account, # type: ignore[arg-type]
|
||||
config={"opening_statement": "Hi!", "suggested_questions_after_answer": {"enabled": True}},
|
||||
session=sqlite_session,
|
||||
session=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_ID
|
||||
assert new_config.app_id == "app-1"
|
||||
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 == 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
|
||||
assert app_model.updated_by == "acct-1"
|
||||
assert new_config in session.added
|
||||
assert session.flushed == 1
|
||||
assert session.committed == 1
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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,8 +1,6 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -11,8 +9,6 @@ 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
|
||||
@@ -21,54 +17,31 @@ from services.file_service import FileService
|
||||
|
||||
class TestFileService:
|
||||
@pytest.fixture
|
||||
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)
|
||||
def mock_db_session(self):
|
||||
session = MagicMock(spec=Session)
|
||||
# Mock context manager behavior
|
||||
session.__enter__.return_value = session
|
||||
return session
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(self, sqlite_session_maker: sessionmaker[Session]) -> Iterator[Session]:
|
||||
with sqlite_session_maker() as session:
|
||||
yield session
|
||||
def mock_session_maker(self, mock_db_session):
|
||||
maker = MagicMock(spec=sessionmaker)
|
||||
maker.return_value = mock_db_session
|
||||
return maker
|
||||
|
||||
@pytest.fixture
|
||||
def file_service(self, sqlite_session_maker: sessionmaker[Session]) -> FileService:
|
||||
return FileService(session_factory=sqlite_session_maker)
|
||||
def file_service(self, mock_session_maker):
|
||||
return FileService(session_factory=mock_session_maker)
|
||||
|
||||
@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)
|
||||
def test_init_with_engine(self):
|
||||
engine = MagicMock(spec=Engine)
|
||||
service = FileService(session_factory=engine)
|
||||
assert isinstance(service._session_maker, sessionmaker)
|
||||
|
||||
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_with_sessionmaker(self):
|
||||
maker = MagicMock(spec=sessionmaker)
|
||||
service = FileService(session_factory=maker)
|
||||
assert service._session_maker == maker
|
||||
|
||||
def test_init_invalid_factory(self):
|
||||
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
|
||||
@@ -79,11 +52,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, db_session: Session
|
||||
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, mock_db_session
|
||||
):
|
||||
# Setup
|
||||
mock_tenant_id.return_value = "tenant_id"
|
||||
mock_now.return_value = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
mock_now.return_value = "2024-01-01"
|
||||
mock_get_url.return_value = "http://signed-url"
|
||||
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -108,9 +81,8 @@ class TestFileService:
|
||||
assert result.source_url == "http://signed-url"
|
||||
|
||||
mock_storage.save.assert_called_once()
|
||||
persisted = db_session.get(UploadFile, result.id)
|
||||
assert persisted is not None
|
||||
assert persisted.hash == result.hash
|
||||
mock_db_session.add.assert_called_once_with(result)
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
def test_upload_file_uses_explicit_resource_tenant(self, file_service: FileService):
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -137,7 +109,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, db_session: Session):
|
||||
def test_upload_file_long_filename(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
long_name = "a" * 210 + ".txt"
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -152,7 +124,6 @@ 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"):
|
||||
@@ -174,7 +145,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, db_session: Session):
|
||||
def test_upload_file_end_user(self, file_service: FileService, mock_db_session):
|
||||
user = MagicMock(spec=EndUser)
|
||||
user.id = "end_user_id"
|
||||
|
||||
@@ -186,7 +157,6 @@ 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 (
|
||||
@@ -211,8 +181,12 @@ 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, db_session: Session):
|
||||
self._persist_upload_file(db_session, key="test_key")
|
||||
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
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load_once.return_value = b"test content"
|
||||
@@ -224,17 +198,16 @@ 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):
|
||||
def test_get_file_base64_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
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, db_session: Session):
|
||||
self._persist_upload_file(
|
||||
db_session,
|
||||
extension="png",
|
||||
mime_type="image/png",
|
||||
key="upload_files/tenant_id/icon.png",
|
||||
)
|
||||
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
|
||||
|
||||
with (
|
||||
patch.object(dify_config, "FILES_ACCESS_TIMEOUT", 300),
|
||||
@@ -251,11 +224,13 @@ class TestFileService:
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
def test_get_file_presigned_url_not_found(self, file_service: FileService):
|
||||
def test_get_file_presigned_url_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
|
||||
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, db_session: Session):
|
||||
def test_upload_text_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
text = "sample text"
|
||||
text_name = "test.txt"
|
||||
@@ -274,17 +249,21 @@ class TestFileService:
|
||||
assert result.used is True
|
||||
assert result.extension == "txt"
|
||||
mock_storage.save.assert_called_once()
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
mock_db_session.add.assert_called_once()
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
def test_upload_text_long_name(self, file_service: FileService, db_session: Session):
|
||||
def test_upload_text_long_name(self, file_service: FileService, mock_db_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, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="pdf", mime_type="application/pdf")
|
||||
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
|
||||
|
||||
with patch("services.file_service.ExtractProcessor.load_from_upload_file") as mock_extract:
|
||||
mock_extract.return_value = "Extracted text content"
|
||||
@@ -295,17 +274,27 @@ class TestFileService:
|
||||
# Assert
|
||||
assert result == "Extracted text content"
|
||||
|
||||
def test_get_file_preview_not_found(self, file_service: FileService):
|
||||
def test_get_file_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
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, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="exe", mime_type="application/octet-stream")
|
||||
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
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_file_preview("file_id", "tenant_id")
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_image_signature") as mock_verify,
|
||||
@@ -327,21 +316,28 @@ 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):
|
||||
def test_get_image_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
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, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
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
|
||||
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, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session)
|
||||
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
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_file_signature") as mock_verify,
|
||||
@@ -352,8 +348,7 @@ class TestFileService:
|
||||
|
||||
gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
assert list(gen) == [b"chunk"]
|
||||
assert file.id == upload_file.id
|
||||
assert file.key == upload_file.key
|
||||
assert file == upload_file
|
||||
|
||||
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:
|
||||
@@ -361,14 +356,20 @@ 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):
|
||||
def test_get_file_generator_by_file_id_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
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, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="png", mime_type="image/png")
|
||||
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
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = b"image content"
|
||||
@@ -376,56 +377,66 @@ class TestFileService:
|
||||
assert gen == b"image content"
|
||||
assert mime == "image/png"
|
||||
|
||||
def test_get_public_image_preview_not_found(self, file_service: FileService):
|
||||
def test_get_public_image_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
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, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
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
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_public_image_preview("file_id")
|
||||
|
||||
def test_get_file_content_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
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
|
||||
|
||||
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):
|
||||
def test_get_file_content_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_content("file_id")
|
||||
|
||||
def test_delete_file_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
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
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
file_service.delete_file("file_id")
|
||||
mock_storage.delete.assert_called_once_with("key")
|
||||
db_session.expire_all()
|
||||
assert db_session.get(UploadFile, "file_id") is None
|
||||
mock_db_session.delete.assert_called_once_with(upload_file)
|
||||
|
||||
def test_delete_file_not_found(self, file_service: FileService):
|
||||
def test_delete_file_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
file_service.delete_file("file_id")
|
||||
# Should return without doing anything
|
||||
|
||||
def test_get_upload_files_by_ids_empty(self, db_session: Session):
|
||||
result = FileService.get_upload_files_by_ids("tenant_id", [], session=db_session)
|
||||
def test_get_upload_files_by_ids_empty(self):
|
||||
session = MagicMock()
|
||||
result = FileService.get_upload_files_by_ids("tenant_id", [], session=session)
|
||||
assert result == {}
|
||||
|
||||
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",
|
||||
)
|
||||
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]
|
||||
|
||||
result = FileService.get_upload_files_by_ids(
|
||||
"tenant_id",
|
||||
["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001"],
|
||||
session=db_session,
|
||||
"tenant_id", ["550e8400-e29b-41d4-a716-446655440000"], session=session
|
||||
)
|
||||
assert result["550e8400-e29b-41d4-a716-446655440000"] == upload_file
|
||||
|
||||
@@ -442,8 +453,10 @@ 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, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session)
|
||||
def test_build_upload_files_zip_tempfile(self):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.name = "test.txt"
|
||||
upload_file.key = "key"
|
||||
|
||||
with (
|
||||
patch("services.file_service.storage") as mock_storage,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -41,13 +42,14 @@ from services.human_input_service import (
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unbound_session_factory() -> sessionmaker[Session]:
|
||||
"""Supply the required constructor dependency without enabling database access."""
|
||||
return sessionmaker()
|
||||
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 _make_app(mode: AppMode) -> App:
|
||||
return App(
|
||||
def _persist_app(sqlite_session: Session, mode: AppMode) -> App:
|
||||
app = App(
|
||||
id="app-id",
|
||||
tenant_id="tenant-id",
|
||||
name="Test App",
|
||||
@@ -58,6 +60,9 @@ def _make_app(mode: AppMode) -> App:
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
sqlite_session.add(app)
|
||||
sqlite_session.commit()
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -92,11 +97,14 @@ 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: sessionmaker[Session],
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -108,8 +116,7 @@ def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.WORKFLOW))
|
||||
_persist_app(sqlite_session, AppMode.WORKFLOW)
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -121,9 +128,10 @@ def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
|
||||
|
||||
def test_ensure_form_active_respects_global_timeout(
|
||||
monkeypatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
monkeypatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
expired_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
created_at=naive_utc_now() - timedelta(hours=2),
|
||||
@@ -135,11 +143,14 @@ 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: sessionmaker[Session],
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -151,8 +162,7 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.ADVANCED_CHAT))
|
||||
_persist_app(sqlite_session, AppMode.ADVANCED_CHAT)
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -163,11 +173,14 @@ 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: sessionmaker[Session],
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -179,8 +192,7 @@ def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.COMPLETION))
|
||||
_persist_app(sqlite_session, AppMode.COMPLETION)
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -190,13 +202,14 @@ def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_for_console_uses_repository(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord, sqlite_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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
form = service.get_form_definition_by_token_for_console("token")
|
||||
|
||||
repo.get_by_token.assert_called_once_with("token")
|
||||
@@ -232,8 +245,9 @@ 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, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
configured_input = SelectInputConfig(
|
||||
output_variable_name="decision",
|
||||
option_source=StringListSource(
|
||||
@@ -258,7 +272,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(unbound_session_factory)
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
resolved_inputs = service.resolve_form_inputs(Form(record))
|
||||
|
||||
@@ -270,12 +284,13 @@ def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
|
||||
|
||||
def test_submit_form_by_token_calls_repository_and_enqueue(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -298,10 +313,11 @@ 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, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record, sqlite_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,
|
||||
@@ -310,7 +326,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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(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")
|
||||
|
||||
@@ -327,8 +343,9 @@ 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, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
test_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
@@ -337,7 +354,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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -351,12 +368,13 @@ 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, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -373,10 +391,11 @@ 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, unbound_session_factory):
|
||||
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -390,7 +409,8 @@ 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, unbound_session_factory):
|
||||
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
|
||||
definition_with_input = FormDefinition(
|
||||
@@ -402,7 +422,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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -416,6 +436,42 @@ 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"),
|
||||
[
|
||||
@@ -466,11 +522,12 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
|
||||
)
|
||||
def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads(
|
||||
sample_form_record,
|
||||
unbound_session_factory,
|
||||
sqlite_session_factory,
|
||||
input_definition,
|
||||
submitted_value,
|
||||
expected_message,
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
@@ -482,7 +539,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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -512,7 +569,7 @@ def test_form_properties(sample_form_record: HumanInputFormRecord):
|
||||
|
||||
def test_form_submitted_error_init():
|
||||
error = FormSubmittedError(form_id="test-form")
|
||||
assert error.description == "This form has already been submitted by another user, form_id=test-form"
|
||||
assert "form_id=test-form" in error.description
|
||||
assert error.code == 412
|
||||
|
||||
|
||||
@@ -523,55 +580,61 @@ 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(unbound_session_factory):
|
||||
def test_get_form_by_token_none(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(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, unbound_session_factory):
|
||||
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(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, unbound_session_factory):
|
||||
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(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, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord, sqlite_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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(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(unbound_session_factory):
|
||||
def test_submit_form_by_token_delivery_not_enabled(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(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, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
@@ -579,15 +642,16 @@ 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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(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, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
# Submitted
|
||||
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
|
||||
@@ -607,16 +671,18 @@ def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unb
|
||||
service.ensure_form_active(Form(expired_time_record))
|
||||
|
||||
|
||||
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(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, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run_repo = MagicMock()
|
||||
workflow_run_repo.get_workflow_run_by_id_without_tenant.return_value = None
|
||||
@@ -630,12 +696,15 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_sessio
|
||||
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: sessionmaker[Session],
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -646,31 +715,26 @@ 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 (
|
||||
"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()
|
||||
assert any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||
|
||||
|
||||
def test_is_globally_expired_zero_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(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, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -689,7 +753,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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
single_file = File(
|
||||
file_id="file-1",
|
||||
@@ -751,8 +815,9 @@ def test_submit_form_by_token_normalizes_select_and_files(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_select_value(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -767,7 +832,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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"):
|
||||
service.submit_form_by_token(
|
||||
@@ -779,8 +844,9 @@ def test_submit_form_by_token_invalid_select_value(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_file_list_item(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -790,7 +856,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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(
|
||||
InvalidFormDataError,
|
||||
@@ -805,8 +871,9 @@ def test_submit_form_by_token_invalid_file_list_item(
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -816,7 +883,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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(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'"):
|
||||
@@ -837,8 +904,9 @@ 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, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -848,7 +916,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(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file"))
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""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,57 +1,178 @@
|
||||
"""Tests for the session lifecycle owned by ``WorkflowRunService``."""
|
||||
"""Comprehensive unit tests for WorkflowRunService class.
|
||||
|
||||
from unittest.mock import create_autospec, patch
|
||||
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
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, text
|
||||
from sqlalchemy import Engine
|
||||
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 services.workflow_run_service import WorkflowRunService
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import _PrivateWorkflowPauseEntity
|
||||
from services.workflow_run_service import (
|
||||
WorkflowRunService,
|
||||
)
|
||||
|
||||
|
||||
@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 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 workflow_run_repository():
|
||||
"""Keep the repository boundary mocked while exercising real session construction."""
|
||||
return create_autospec(APIWorkflowRunRepository)
|
||||
class TestWorkflowRunService:
|
||||
"""Comprehensive unit tests for WorkflowRunService class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_factory(self):
|
||||
"""Create a mock session factory with proper session management."""
|
||||
mock_session = create_autospec(Session)
|
||||
|
||||
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 session
|
||||
mock_session_cm = MagicMock()
|
||||
mock_session_cm.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_session_cm.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
service = WorkflowRunService(sqlite_session_factory)
|
||||
# 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)
|
||||
|
||||
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
|
||||
mock_session.begin = MagicMock(return_value=mock_transaction_cm)
|
||||
|
||||
# Create mock factory that returns the context manager
|
||||
mock_factory = MagicMock(spec=sessionmaker)
|
||||
mock_factory.return_value = mock_session_cm
|
||||
|
||||
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
|
||||
return mock_factory, mock_session
|
||||
|
||||
service = WorkflowRunService(sqlite_engine)
|
||||
@pytest.fixture
|
||||
def mock_workflow_run_repository(self):
|
||||
"""Create a mock APIWorkflowRunRepository."""
|
||||
mock_repo = create_autospec(APIWorkflowRunRepository)
|
||||
return mock_repo
|
||||
|
||||
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
|
||||
@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
|
||||
|
||||
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
|
||||
|
||||
def test_init_with_default_repository_dependencies(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
service = WorkflowRunService(sqlite_session_factory)
|
||||
@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
|
||||
|
||||
assert service._session_factory is 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
|
||||
|
||||
@@ -135,19 +135,6 @@ 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
+2
-2
@@ -1281,7 +1281,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
source = { editable = "../dify-agent" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1331,7 +1331,7 @@ docs = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
"channel": "alpha",
|
||||
"compat": {
|
||||
"minDify": "1.16.0",
|
||||
"maxDify": "1.16.1"
|
||||
"maxDify": "1.16.0"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./install-cli.sh', import.meta.url))
|
||||
|
||||
function pickAsset(target: string, releaseJson: string): string {
|
||||
return execFileSync('sh', ['-c', `. "${SCRIPT}"; pick_asset "$1"`, 'sh', target], {
|
||||
input: releaseJson,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function assetVersion(name: string, target: string): string {
|
||||
return execFileSync('sh', ['-c', `. "${SCRIPT}"; asset_version "$1" "$2"`, 'sh', name, target], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
|
||||
}).trim()
|
||||
}
|
||||
|
||||
// Stubs the only network primitive (fetch_json) so resolution logic runs fully
|
||||
// offline. Routes by URL; release bodies come from env (TAG_<tag-with-._->_>),
|
||||
// the latest release from LATEST_JSON, the listing from LIST_JSON. A missing
|
||||
// fixture returns 22 to mimic `curl -f` on a 4xx.
|
||||
/* oxlint-disable no-template-curly-in-string -- shell parameter expansions, not JS template literals */
|
||||
const FETCH_STUB = [
|
||||
'fetch_json() {',
|
||||
' case "$1" in',
|
||||
' *"/releases/latest") [ -n "${LATEST_JSON:-}" ] || return 22; printf "%s" "$LATEST_JSON" ;;',
|
||||
' *"/releases?per_page=100") [ -n "${LIST_JSON:-}" ] || return 22; printf "%s" "$LIST_JSON" ;;',
|
||||
' *"/releases/tags/"*)',
|
||||
' _t=${1##*/releases/tags/};',
|
||||
' _k=$(printf "TAG_%s" "$_t" | tr ".-" "__");',
|
||||
' eval "_v=\\${$_k:-}";',
|
||||
' [ -n "$_v" ] || return 22;',
|
||||
' printf "%s" "$_v" ;;',
|
||||
' *) return 22 ;;',
|
||||
' esac',
|
||||
'}',
|
||||
].join('\n')
|
||||
/* oxlint-enable no-template-curly-in-string */
|
||||
|
||||
function runLib(
|
||||
program: string,
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
const full = `. "${SCRIPT}"\n${FETCH_STUB}\n${program}`
|
||||
const r = spawnSync('sh', ['-c', full], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
DIFYCTL_INSTALL_LIB: '1',
|
||||
DIFY_VERSION: '',
|
||||
DIFYCTL_VERSION: '',
|
||||
...env,
|
||||
},
|
||||
})
|
||||
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
// Like runLib but with a caller-supplied fetch_json stub, so we can drive the
|
||||
// real rate_limit_hint / maybe_ratelimit_exit / fetch_hit_ratelimit (which the
|
||||
// script defines) by writing a classified reason to FETCH_ERR_FILE.
|
||||
function runLibStub(
|
||||
stub: string,
|
||||
program: string,
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stderr: string } {
|
||||
const full = `. "${SCRIPT}"\n${stub}\n${program}`
|
||||
const r = spawnSync('sh', ['-c', full], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
DIFYCTL_INSTALL_LIB: '1',
|
||||
DIFY_VERSION: '',
|
||||
DIFYCTL_VERSION: '',
|
||||
...env,
|
||||
},
|
||||
})
|
||||
return { code: r.status ?? 1, stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
// A fetch_json that always fails with the given classification, mimicking the
|
||||
// real one writing to FETCH_ERR_FILE from inside a command-substitution subshell.
|
||||
function failStub(reason: string): string {
|
||||
return `fetch_json() { printf '%s' '${reason}' > "$FETCH_ERR_FILE"; return 1; }`
|
||||
}
|
||||
|
||||
const REL_1142 = JSON.stringify({
|
||||
tag_name: '1.14.2',
|
||||
assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }],
|
||||
})
|
||||
const REL_1150 = JSON.stringify({
|
||||
tag_name: '1.15.0',
|
||||
assets: [{ name: 'difyctl-v0.3.0-linux-x64' }],
|
||||
})
|
||||
const LIST_NEWEST_FIRST = JSON.stringify({
|
||||
releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }],
|
||||
})
|
||||
|
||||
const RELEASE = JSON.stringify({
|
||||
tag_name: '1.14.2',
|
||||
name: 'Dify 1.14.2',
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.1.0-rc.1-linux-x64' },
|
||||
{ name: 'difyctl-v0.2.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.2.0-linux-arm64' },
|
||||
{ name: 'difyctl-v0.2.0-darwin-arm64' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.2.0-checksums.txt' },
|
||||
{ name: 'some-other-asset.zip' },
|
||||
],
|
||||
})
|
||||
|
||||
describe('install-cli pick_asset', () => {
|
||||
it('picks the highest difyctl version for a linux target', () => {
|
||||
expect(pickAsset('linux-x64', RELEASE)).toBe('difyctl-v0.2.0-linux-x64')
|
||||
})
|
||||
|
||||
it('matches the windows .exe asset', () => {
|
||||
expect(pickAsset('windows-x64', RELEASE)).toBe('difyctl-v0.2.0-windows-x64.exe')
|
||||
})
|
||||
|
||||
it('matches an arm64 target exactly (no x64 bleed-through)', () => {
|
||||
expect(pickAsset('darwin-arm64', RELEASE)).toBe('difyctl-v0.2.0-darwin-arm64')
|
||||
})
|
||||
|
||||
it('excludes the checksums asset', () => {
|
||||
expect(pickAsset('linux-x64', RELEASE)).not.toContain('checksums')
|
||||
})
|
||||
|
||||
it('yields empty when no asset matches the target', () => {
|
||||
expect(pickAsset('darwin-x64', RELEASE)).toBe('')
|
||||
})
|
||||
|
||||
it('picks the highest semver when several difyctl versions are present', () => {
|
||||
const many = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.10.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.9.0-linux-x64' },
|
||||
],
|
||||
})
|
||||
expect(pickAsset('linux-x64', many)).toBe('difyctl-v0.10.0-linux-x64')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-cli asset_version', () => {
|
||||
it('extracts the version from a posix asset name', () => {
|
||||
expect(assetVersion('difyctl-v0.2.0-linux-x64', 'linux-x64')).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('extracts the version from a windows .exe asset name', () => {
|
||||
expect(assetVersion('difyctl-v0.2.0-windows-x64.exe', 'windows-x64')).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('extracts a prerelease version', () => {
|
||||
expect(assetVersion('difyctl-v0.1.0-rc.1-linux-x64', 'linux-x64')).toBe('0.1.0-rc.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-cli resolve_release', () => {
|
||||
it('DIFY_VERSION pins the release directly', () => {
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
|
||||
DIFY_VERSION: '1.14.2',
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('DIFY_VERSION that does not exist dies with a clear message', () => {
|
||||
const r = runLib('resolve_release linux-x64', { DIFY_VERSION: '9.9.9' })
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('Dify release 9.9.9 not found')
|
||||
})
|
||||
|
||||
it('blank resolves to the latest stable release', () => {
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
|
||||
LATEST_JSON: REL_1150,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.15.0')
|
||||
})
|
||||
|
||||
it('blank dies when the latest query fails (no silent fallback)', () => {
|
||||
const r = runLib('resolve_release linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('failed to query latest Dify release')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
|
||||
DIFYCTL_VERSION: '0.2.0',
|
||||
LIST_JSON: LIST_NEWEST_FIRST,
|
||||
TAG_1_15_0: REL_1150,
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION not hosted anywhere dies', () => {
|
||||
const r = runLib('resolve_release linux-x64', {
|
||||
DIFYCTL_VERSION: '9.9.9',
|
||||
LIST_JSON: LIST_NEWEST_FIRST,
|
||||
TAG_1_15_0: REL_1150,
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-cli find_release_for_difyctl', () => {
|
||||
it('returns the newest release whose assets host the wanted build', () => {
|
||||
const r = runLib('find_release_for_difyctl 0.2.0 linux-x64', {
|
||||
LIST_JSON: LIST_NEWEST_FIRST,
|
||||
TAG_1_15_0: REL_1150,
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('dies (not false-negative) when the releases listing fails', () => {
|
||||
const r = runLib('find_release_for_difyctl 0.2.0 linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('failed to query')
|
||||
})
|
||||
|
||||
it('warns and skips a release whose fetch fails, then finds it later', () => {
|
||||
const r = runLib('find_release_for_difyctl 0.2.0 linux-x64', {
|
||||
LIST_JSON: LIST_NEWEST_FIRST,
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
expect(r.stderr).toContain('fetch failed for 1.15.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-cli rate limit', () => {
|
||||
const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
|
||||
|
||||
it('latest: reports the rate limit with reset ETA and remediation, not a generic error', () => {
|
||||
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).toContain('resets in ~')
|
||||
expect(r.stderr).toContain('GITHUB_TOKEN')
|
||||
expect(r.stderr).not.toContain('failed to query latest')
|
||||
})
|
||||
|
||||
it('DIFY_VERSION: rate limit wins over the misleading "not found" message', () => {
|
||||
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
|
||||
DIFY_VERSION: '1.15.0',
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('not found')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION: rate limit surfaces from the nested subshell, not "not found"', () => {
|
||||
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
|
||||
DIFYCTL_VERSION: '0.2.0',
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('not found')
|
||||
})
|
||||
|
||||
it('omits the ETA line when the reset epoch is missing', () => {
|
||||
const r = runLibStub(failStub('ratelimit:'), 'resolve_release linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('resets in ~')
|
||||
})
|
||||
|
||||
it('a non-rate-limit HTTP error falls back to the generic message (no false hint)', () => {
|
||||
const r = runLibStub(failStub('http:500'), 'resolve_release linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('failed to query latest')
|
||||
expect(r.stderr).not.toContain('rate limit exceeded')
|
||||
})
|
||||
})
|
||||
|
||||
// A stand-in for curl that honours the flags fetch_json passes (-D/-o/-w/-H) and
|
||||
// fabricates a response per FAKE_MODE, so the tests exercise the REAL fetch_json
|
||||
// (its curl invocation, header parsing, classification and token handling) rather
|
||||
// than a stub. Header names it emits are lowercase, as HTTP/2 delivers them.
|
||||
const FAKE_CURL = `#!/bin/sh
|
||||
hdr=""; body=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-D) hdr="$2"; shift 2 ;;
|
||||
-o) body="$2"; shift 2 ;;
|
||||
-H) printf '%s\\n' "$2" >> "\${FAKE_HDR_LOG:-/dev/null}"; shift 2 ;;
|
||||
-w) shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
case "\${FAKE_MODE:-ok}" in
|
||||
ok)
|
||||
[ -n "$hdr" ] && printf 'HTTP/2 200\\r\\n\\r\\n' > "$hdr"
|
||||
[ -n "$body" ] && printf '%s' "\${FAKE_BODY:-}" > "$body"
|
||||
printf '200' ;;
|
||||
ratelimit)
|
||||
[ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 0\\r\\nx-ratelimit-reset: %s\\r\\n\\r\\n' "\${FAKE_RESET:-9999999999}" > "$hdr"
|
||||
printf '403' ;;
|
||||
perm403)
|
||||
[ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 59\\r\\n\\r\\n' > "$hdr"
|
||||
printf '403' ;;
|
||||
notfound)
|
||||
[ -n "$hdr" ] && printf 'HTTP/2 404\\r\\n\\r\\n' > "$hdr"
|
||||
printf '404' ;;
|
||||
net) exit 6 ;;
|
||||
esac
|
||||
`
|
||||
|
||||
// Drive the real fetch_json with FAKE_CURL first on PATH. Returns "OK|<body>" or
|
||||
// "FAIL|<FETCH_ERR_FILE contents>", plus any -H lines the fake curl received.
|
||||
function runRealFetch(
|
||||
mode: string,
|
||||
env: Record<string, string> = {},
|
||||
): { result: string; headers: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-fakecurl-'))
|
||||
const hdrLog = join(dir, 'hdrlog')
|
||||
writeFileSync(join(dir, 'curl'), FAKE_CURL)
|
||||
chmodSync(join(dir, 'curl'), 0o755)
|
||||
const program =
|
||||
'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi'
|
||||
const r = spawnSync('sh', ['-c', `. "${SCRIPT}"\n${program}`], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${dir}:${process.env.PATH ?? ''}`,
|
||||
DIFYCTL_INSTALL_LIB: '1',
|
||||
DIFY_VERSION: '',
|
||||
DIFYCTL_VERSION: '',
|
||||
FAKE_MODE: mode,
|
||||
FAKE_HDR_LOG: hdrLog,
|
||||
...env,
|
||||
},
|
||||
})
|
||||
let headers = ''
|
||||
try {
|
||||
headers = readFileSync(hdrLog, 'utf8')
|
||||
} catch {
|
||||
/* no headers logged */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return { result: (r.stdout ?? '').trim(), headers }
|
||||
}
|
||||
|
||||
describe('install-cli fetch_json (real, fake curl on PATH)', () => {
|
||||
it('returns the response body on 200', () => {
|
||||
expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe(
|
||||
'OK|{"tag_name":"1.15.0"}',
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies a 403 with x-ratelimit-remaining:0 as a rate limit and captures the reset', () => {
|
||||
expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe(
|
||||
'FAIL|ratelimit:1893456000',
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies a 403 with tokens left as a plain http error, not a rate limit', () => {
|
||||
expect(runRealFetch('perm403').result).toBe('FAIL|http:403')
|
||||
})
|
||||
|
||||
it('classifies a 404 as an http error', () => {
|
||||
expect(runRealFetch('notfound').result).toBe('FAIL|http:404')
|
||||
})
|
||||
|
||||
it('classifies a curl transport failure as a network error', () => {
|
||||
expect(runRealFetch('net').result).toBe('FAIL|network')
|
||||
})
|
||||
|
||||
it('sends an Authorization bearer header when GITHUB_TOKEN is set', () => {
|
||||
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain(
|
||||
'Authorization: Bearer ghp_secret',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to GH_TOKEN when GITHUB_TOKEN is unset', () => {
|
||||
expect(
|
||||
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers,
|
||||
).toContain('Authorization: Bearer gho_fallback')
|
||||
})
|
||||
|
||||
it('sends no Authorization header when neither token is set', () => {
|
||||
expect(
|
||||
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers,
|
||||
).not.toContain('Authorization')
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./install-r2.ps1', import.meta.url))
|
||||
const hasPwsh = spawnSync('pwsh', ['-v'], { encoding: 'utf8' }).status === 0
|
||||
const d = hasPwsh ? describe : describe.skip
|
||||
|
||||
const MANIFEST = JSON.stringify({
|
||||
schema: 1,
|
||||
name: 'difyctl',
|
||||
channel: 'edge',
|
||||
version: '0.1.0-edge.2fd7b82',
|
||||
baseUrl: 'https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82',
|
||||
targets: {
|
||||
'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' },
|
||||
},
|
||||
})
|
||||
|
||||
function pwsh(program: string): { code: number; stdout: string; stderr: string } {
|
||||
const full = `. '${SCRIPT}'\n${program}`
|
||||
const r = spawnSync('pwsh', ['-NoProfile', '-Command', full], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
|
||||
})
|
||||
return {
|
||||
code: r.status ?? 1,
|
||||
stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(),
|
||||
stderr: r.stderr ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
d('install-r2.ps1', () => {
|
||||
it('parses a target asset + sha from the manifest', () => {
|
||||
const prog =
|
||||
`$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n` +
|
||||
`Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n` +
|
||||
`Write-Output (Get-TargetField $m 'windows-x64' 'sha256')`
|
||||
const { stdout } = pwsh(prog)
|
||||
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe\ndeadbeef')
|
||||
})
|
||||
|
||||
it('errors when DIFYCTL_R2_BASE is unset', () => {
|
||||
const r = spawnSync('pwsh', ['-NoProfile', '-File', SCRIPT], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_R2_BASE: '' },
|
||||
})
|
||||
if (hasPwsh) {
|
||||
expect(r.status).not.toBe(0)
|
||||
expect(r.stderr + r.stdout).toMatch(/DIFYCTL_R2_BASE/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./install-r2.sh', import.meta.url))
|
||||
|
||||
const MANIFEST = [
|
||||
'{',
|
||||
' "schema": 1,',
|
||||
' "name": "difyctl",',
|
||||
' "channel": "edge",',
|
||||
' "version": "0.1.0-edge.2fd7b82",',
|
||||
' "commit": "abc1234",',
|
||||
' "buildDate": "2026-06-14T12:00:00Z",',
|
||||
' "compat": {"minDify":"1.14.0","maxDify":"1.15.0"},',
|
||||
' "baseUrl": "https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82",',
|
||||
' "targets": {',
|
||||
' "linux-x64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-linux-x64", "sha256": "deadbeef" },',
|
||||
' "darwin-arm64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-darwin-arm64", "sha256": "cafef00d" }',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const INDEX = [
|
||||
'{',
|
||||
' "schema": 1,',
|
||||
' "channel": "edge",',
|
||||
' "updated": "2026-06-15T00:00:00Z",',
|
||||
' "builds": [',
|
||||
' {',
|
||||
' "version": "0.1.0-edge.ce4af868",',
|
||||
' "commit": "ce4af868d653f405070fabb3be3303430cc030ad",',
|
||||
' "buildDate": "2026-06-15T00:00:00Z",',
|
||||
' "dir": "0.1.0-edge.ce4af868"',
|
||||
' },',
|
||||
' {',
|
||||
' "version": "0.1.0-edge.aaaa111",',
|
||||
' "commit": "aaaa111bbbbcccc000011112222333344445555",',
|
||||
' "buildDate": "2026-06-14T00:00:00Z",',
|
||||
' "dir": "0.1.0-edge.aaaa111"',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const CHECKSUMS = [
|
||||
'deadbeef difyctl-v0.1.0-edge.ce4af868-linux-x64',
|
||||
'cafef00d difyctl-v0.1.0-edge.ce4af868-darwin-arm64',
|
||||
'beadc0de difyctl-v0.1.0-edge.ce4af868-windows-x64.exe',
|
||||
].join('\n')
|
||||
|
||||
function lib(
|
||||
program: string,
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
const full = `. "${SCRIPT}"\n${program}`
|
||||
const r = spawnSync('sh', ['-c', full], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', ...env },
|
||||
})
|
||||
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
describe('install-r2 manifest parsing', () => {
|
||||
// install-r2.sh is POSIX-only; under git-bash on Windows `uname -s` is MINGW*,
|
||||
// so detect_target intentionally dies (Windows installs go through install-r2.ps1).
|
||||
it.skipIf(process.platform === 'win32')('detect_target maps to one of the 5 ids', () => {
|
||||
const { stdout } = lib('detect_target')
|
||||
expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(
|
||||
stdout,
|
||||
)
|
||||
})
|
||||
|
||||
it('manifest_str reads a top-level string field', () => {
|
||||
const { stdout } = lib(
|
||||
`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`,
|
||||
{},
|
||||
)
|
||||
expect(stdout).toBe('edge')
|
||||
})
|
||||
|
||||
it('manifest_target_field extracts per-target values from a single line', () => {
|
||||
const prog =
|
||||
`printf '%s' '${MANIFEST}' > "$tmp_m"\n` +
|
||||
'manifest_target_field "$tmp_m" darwin-arm64 asset\n' +
|
||||
'manifest_target_field "$tmp_m" darwin-arm64 sha256'
|
||||
const { stdout } = lib(prog)
|
||||
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-darwin-arm64\ncafef00d')
|
||||
})
|
||||
|
||||
it('requires DIFYCTL_R2_BASE when run as the installer (not lib)', () => {
|
||||
const r = spawnSync('sh', [SCRIPT], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_R2_BASE: '' },
|
||||
})
|
||||
expect(r.status).not.toBe(0)
|
||||
expect(r.stderr).toMatch(/DIFYCTL_R2_BASE/)
|
||||
})
|
||||
|
||||
it('sha256_check aborts on a checksum mismatch', () => {
|
||||
const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" deadbeef')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toMatch(/checksum mismatch/)
|
||||
})
|
||||
|
||||
it('sha256_check passes on the correct hash', () => {
|
||||
// sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
||||
const r = lib(
|
||||
'f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK',
|
||||
)
|
||||
expect(r.stdout).toBe('OK')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-r2 version/commit pin', () => {
|
||||
it('index_resolve matches a build by exact version', () => {
|
||||
const r = lib(
|
||||
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`,
|
||||
)
|
||||
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
|
||||
})
|
||||
|
||||
it('index_resolve matches a build by commit prefix', () => {
|
||||
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ce4af868`)
|
||||
expect(r.stdout).toBe('0.1.0-edge.ce4af868\t0.1.0-edge.ce4af868')
|
||||
})
|
||||
|
||||
it('index_resolve matches the full 40-char commit too', () => {
|
||||
const r = lib(
|
||||
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`,
|
||||
)
|
||||
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
|
||||
})
|
||||
|
||||
it('index_resolve prints nothing when no build matches', () => {
|
||||
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ffffff`)
|
||||
expect(r.stdout).toBe('')
|
||||
})
|
||||
|
||||
it('checksums_target extracts sha and asset for a posix target', () => {
|
||||
const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" darwin-arm64`)
|
||||
expect(r.stdout).toBe('cafef00d\tdifyctl-v0.1.0-edge.ce4af868-darwin-arm64')
|
||||
})
|
||||
|
||||
it('checksums_target does not bleed x64 into arm64', () => {
|
||||
const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" linux-x64`)
|
||||
expect(r.stdout).toBe('deadbeef\tdifyctl-v0.1.0-edge.ce4af868-linux-x64')
|
||||
})
|
||||
})
|
||||
@@ -1,297 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./install.ps1', import.meta.url))
|
||||
|
||||
function hasPwsh(): boolean {
|
||||
const r = spawnSync(
|
||||
'pwsh',
|
||||
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
return r.status === 0
|
||||
}
|
||||
|
||||
const PWSH = hasPwsh()
|
||||
|
||||
const STUB = [
|
||||
'function Invoke-RestMethod {',
|
||||
' param([string]$Uri, $Headers)',
|
||||
" if ($Uri -like '*/releases/latest') {",
|
||||
" if (-not $env:HX_LATEST) { throw 'mock 404' }",
|
||||
' return ($env:HX_LATEST | ConvertFrom-Json)',
|
||||
' }',
|
||||
" elseif ($Uri -like '*/releases?per_page=100') {",
|
||||
" if (-not $env:HX_LIST) { throw 'mock 404' }",
|
||||
' return ($env:HX_LIST | ConvertFrom-Json)',
|
||||
' }',
|
||||
" elseif ($Uri -like '*/releases/tags/*') {",
|
||||
" $t = $Uri -replace '.*/releases/tags/', ''",
|
||||
" $k = 'HX_TAG_' + ($t -replace '[.\\-]', '_')",
|
||||
' $v = [Environment]::GetEnvironmentVariable($k)',
|
||||
" if (-not $v) { throw 'mock 404' }",
|
||||
' return ($v | ConvertFrom-Json)',
|
||||
' }',
|
||||
' throw "unexpected uri $Uri"',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
type Run = { code: number; stdout: string; stderr: string }
|
||||
|
||||
function runPwsh(body: string, env: Record<string, string> = {}): Run {
|
||||
const script = `$ErrorActionPreference='Stop'\n${STUB}\n. '${SCRIPT}'\n${body}`
|
||||
const r = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
DIFYCTL_INSTALL_LIB: '1',
|
||||
DIFY_VERSION: '',
|
||||
DIFYCTL_VERSION: '',
|
||||
LOCALAPPDATA: process.env.LOCALAPPDATA || '/tmp',
|
||||
TEMP: process.env.TEMP || '/tmp',
|
||||
...env,
|
||||
},
|
||||
})
|
||||
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
const REL_1142 = JSON.stringify({
|
||||
tag_name: '1.14.2',
|
||||
assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }],
|
||||
})
|
||||
const REL_1150 = JSON.stringify({
|
||||
tag_name: '1.15.0',
|
||||
assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }],
|
||||
})
|
||||
const LIST_NEWEST_FIRST = JSON.stringify([
|
||||
{ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] },
|
||||
{ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] },
|
||||
])
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
|
||||
it('extracts the version from a windows .exe asset name', () => {
|
||||
const r = runPwsh("(Get-AssetSemver 'difyctl-v0.2.0-windows-x64.exe').Version")
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('extracts a prerelease version and its rc number', () => {
|
||||
const r = runPwsh(
|
||||
'$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"',
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.1.0-rc.1 1')
|
||||
})
|
||||
|
||||
it('rejects a non-windows asset (returns null)', () => {
|
||||
const r = runPwsh(
|
||||
"if ($null -eq (Get-AssetSemver 'difyctl-v0.2.0-linux-x64')) { 'NULL' } else { 'OBJ' }",
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
|
||||
it('rejects a malformed core version (returns null)', () => {
|
||||
const r = runPwsh(
|
||||
"if ($null -eq (Get-AssetSemver 'difyctl-vx.y.z-windows-x64.exe')) { 'NULL' } else { 'OBJ' }",
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
|
||||
it('picks the highest semver among several windows builds', () => {
|
||||
const rel = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
|
||||
],
|
||||
})
|
||||
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.10.0')
|
||||
})
|
||||
|
||||
it('prefers the stable release over an rc of the same core', () => {
|
||||
const rel = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
],
|
||||
})
|
||||
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('ignores checksums and non-windows assets', () => {
|
||||
const rel = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.2.0-checksums.txt' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'some-other-asset.zip' },
|
||||
],
|
||||
})
|
||||
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Name`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('difyctl-v0.2.0-windows-x64.exe')
|
||||
})
|
||||
|
||||
it('yields null when no windows asset is present', () => {
|
||||
const rel = JSON.stringify({ assets: [{ name: 'difyctl-v0.2.0-linux-x64' }] })
|
||||
const r = runPwsh(
|
||||
`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`,
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
|
||||
it('DIFY_VERSION pins the release directly', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', {
|
||||
DIFY_VERSION: '1.14.2',
|
||||
HX_TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('DIFY_VERSION that does not exist throws a clear message', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', { DIFY_VERSION: '9.9.9' })
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('Dify release 9.9.9 not found')
|
||||
})
|
||||
|
||||
it('blank resolves to the latest release', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', { HX_LATEST: REL_1150 })
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.15.0')
|
||||
})
|
||||
|
||||
it('blank throws when the latest query fails (no silent fallback)', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('failed to query latest Dify release')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', {
|
||||
DIFYCTL_VERSION: '0.2.0',
|
||||
HX_LIST: LIST_NEWEST_FIRST,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION not hosted anywhere throws', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', {
|
||||
DIFYCTL_VERSION: '9.9.9',
|
||||
HX_LIST: LIST_NEWEST_FIRST,
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => {
|
||||
it('returns the newest release whose assets host the wanted build', () => {
|
||||
const r = runPwsh("(Find-ReleaseForDifyctl '0.2.0').tag_name", { HX_LIST: LIST_NEWEST_FIRST })
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('returns nothing when no release hosts the wanted build', () => {
|
||||
const r = runPwsh(
|
||||
"$x = Find-ReleaseForDifyctl '9.9.9'; if ($null -eq $x) { 'NULL' } else { $x.tag_name }",
|
||||
{ HX_LIST: LIST_NEWEST_FIRST },
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
})
|
||||
|
||||
// Build a fake ErrorRecord whose Exception.Response is a real HttpResponseMessage
|
||||
// carrying the given status + headers, matching what Get-RateLimitInfo inspects.
|
||||
function fakeErr(status: number, headers: Record<string, string>): string {
|
||||
const adds = Object.entries(headers)
|
||||
.map(([k, v]) => `$resp.Headers.TryAddWithoutValidation('${k}','${v}') | Out-Null`)
|
||||
.join('\n')
|
||||
return [
|
||||
`$resp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]${status})`,
|
||||
adds,
|
||||
'$err = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = $resp } }',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
|
||||
it('classifies a 403 with x-ratelimit-remaining:0 as rate-limited, returning the reset', () => {
|
||||
const r =
|
||||
runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })}
|
||||
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { $i.Reset }`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe(futureReset)
|
||||
})
|
||||
|
||||
it('does not classify a 403 with remaining tokens as rate-limited', () => {
|
||||
const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '59' })}
|
||||
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
|
||||
it('always treats a 429 as rate-limited', () => {
|
||||
const r = runPwsh(`${fakeErr(429, {})}
|
||||
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('LIMITED')
|
||||
})
|
||||
|
||||
it('returns null for an error without a response (e.g. a plain string throw)', () => {
|
||||
const r = runPwsh(
|
||||
"$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { 'NULL' } else { 'OBJ' }",
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
|
||||
it('Write-RateLimitHint prints cause, ETA, and remediation to stderr', () => {
|
||||
const r = runPwsh(`Write-RateLimitHint '${futureReset}'`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).toContain('resets in ~')
|
||||
expect(r.stderr).toContain('GITHUB_TOKEN')
|
||||
})
|
||||
|
||||
it('Write-RateLimitHint omits the ETA line when the reset epoch is missing', () => {
|
||||
const r = runPwsh("Write-RateLimitHint ''")
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('resets in ~')
|
||||
})
|
||||
|
||||
it('sends an Authorization header when GITHUB_TOKEN is set', () => {
|
||||
const r = runPwsh('$headers.Authorization', { GITHUB_TOKEN: 'ghp_secret123' })
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('Bearer ghp_secret123')
|
||||
})
|
||||
|
||||
it('Resolve-Release surfaces the rate-limit hint and exits, not "not found"', () => {
|
||||
const stub = `function Invoke-RestMethod { throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('rate limited', $script:rlResp) }`
|
||||
const setup = `$script:rlResp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]403)
|
||||
$script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-remaining','0') | Out-Null
|
||||
$script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-reset','${futureReset}') | Out-Null`
|
||||
const r = runPwsh(`${setup}\n${stub}\nResolve-Release`, { DIFY_VERSION: '1.15.0' })
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('not found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { assetNameFor } from './release-rules.mjs'
|
||||
|
||||
// checksums lines are "<sha256> <assetName>"
|
||||
export function parseChecksums(text) {
|
||||
const map = new Map()
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i)
|
||||
if (m) map.set(m[2], m[1])
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// Newline-delimited dir names of binaries that still exist in R2.
|
||||
export function parseDirList(text) {
|
||||
const set = new Set()
|
||||
for (const line of text.split('\n')) {
|
||||
const d = line.trim()
|
||||
if (d) set.add(d)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
export function resolveTargets(release, version, shas) {
|
||||
const targets = []
|
||||
const missing = []
|
||||
for (const t of release.targets) {
|
||||
const asset = assetNameFor(release, version, t.id)
|
||||
const sha = shas.get(asset)
|
||||
if (sha) targets.push({ id: t.id, asset, sha })
|
||||
else missing.push(asset)
|
||||
}
|
||||
return { targets, missing }
|
||||
}
|
||||
|
||||
export function renderManifest({
|
||||
binName,
|
||||
channel,
|
||||
version,
|
||||
commit,
|
||||
buildDate,
|
||||
compat,
|
||||
baseUrl,
|
||||
targets,
|
||||
}) {
|
||||
const head = {
|
||||
schema: 1,
|
||||
name: binName,
|
||||
channel,
|
||||
version,
|
||||
commit,
|
||||
buildDate,
|
||||
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
|
||||
baseUrl,
|
||||
}
|
||||
const headLines = Object.entries(head)
|
||||
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
||||
.join(',\n')
|
||||
const targetLines = targets
|
||||
.map(
|
||||
(t) =>
|
||||
` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(t.asset)}, "sha256": ${JSON.stringify(t.sha)} }`,
|
||||
)
|
||||
.join(',\n')
|
||||
return `{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`
|
||||
}
|
||||
|
||||
// `current` is the parsed ledger or null (first publish). `existingDirs` is a
|
||||
// Set of build dirs still present in R2, or null when the caller could not list
|
||||
// them — lifecycle/TTL on the bin prefix is the only deletion mechanism, so the
|
||||
// ledger must never advertise a build whose binary is gone. The new build is
|
||||
// always kept (just uploaded). No count cap.
|
||||
export function buildIndex({ channel, version, commit, buildDate, current, existingDirs }) {
|
||||
const entry = { version, commit, buildDate, dir: version }
|
||||
const kept = (current?.builds ?? []).filter((b) => b.version !== entry.version)
|
||||
let builds = [entry, ...kept]
|
||||
if (existingDirs) builds = builds.filter((b) => b.dir === entry.dir || existingDirs.has(b.dir))
|
||||
return { schema: 1, channel, updated: buildDate, builds }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildIndex,
|
||||
parseChecksums,
|
||||
parseDirList,
|
||||
renderManifest,
|
||||
resolveTargets,
|
||||
} from './edge-manifest.mjs'
|
||||
|
||||
const RELEASE = {
|
||||
tagPrefix: 'testctl-v',
|
||||
binName: 'testctl',
|
||||
checksumsSuffix: '.sums',
|
||||
targets: [
|
||||
{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false },
|
||||
{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: true },
|
||||
],
|
||||
}
|
||||
|
||||
const VERSION = '7.7.7-edge.2fd7b82'
|
||||
const SHA_LINUX = 'a'.repeat(64)
|
||||
const SHA_WINDOWS = 'b'.repeat(64)
|
||||
|
||||
const CHECKSUMS = [
|
||||
`${SHA_LINUX} testctl-v${VERSION}-linux-x64`,
|
||||
`${SHA_WINDOWS} testctl-v${VERSION}-windows-x64.exe`,
|
||||
].join('\n')
|
||||
|
||||
describe('parseChecksums', () => {
|
||||
it('maps asset name to sha256', () => {
|
||||
const map = parseChecksums(CHECKSUMS)
|
||||
expect(map.get(`testctl-v${VERSION}-linux-x64`)).toBe(SHA_LINUX)
|
||||
expect(map.get(`testctl-v${VERSION}-windows-x64.exe`)).toBe(SHA_WINDOWS)
|
||||
})
|
||||
|
||||
it('ignores blank and malformed lines', () => {
|
||||
expect(parseChecksums('\nnot a checksum line\n\n').size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDirList', () => {
|
||||
it('collects non-blank trimmed lines', () => {
|
||||
expect([...parseDirList(' a \n\n b\n')]).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('yields an empty set for empty input', () => {
|
||||
expect(parseDirList('').size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTargets', () => {
|
||||
it('pairs every target with its sha', () => {
|
||||
const { targets, missing } = resolveTargets(RELEASE, VERSION, parseChecksums(CHECKSUMS))
|
||||
expect(missing).toEqual([])
|
||||
expect(targets).toEqual([
|
||||
{ id: 'linux-x64', asset: `testctl-v${VERSION}-linux-x64`, sha: SHA_LINUX },
|
||||
{ id: 'windows-x64', asset: `testctl-v${VERSION}-windows-x64.exe`, sha: SHA_WINDOWS },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports assets with no checksum', () => {
|
||||
const partial = parseChecksums(`${SHA_LINUX} testctl-v${VERSION}-linux-x64`)
|
||||
const { targets, missing } = resolveTargets(RELEASE, VERSION, partial)
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(missing).toEqual([`testctl-v${VERSION}-windows-x64.exe`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderManifest', () => {
|
||||
const manifest = () =>
|
||||
renderManifest({
|
||||
binName: RELEASE.binName,
|
||||
channel: 'edge',
|
||||
version: VERSION,
|
||||
commit: 'abc1234',
|
||||
buildDate: '2026-06-14T12:00:00Z',
|
||||
compat: { minDify: '2.0.0', maxDify: '2.5.0' },
|
||||
baseUrl: 'https://example.r2.dev/testctl/edge',
|
||||
targets: resolveTargets(RELEASE, VERSION, parseChecksums(CHECKSUMS)).targets,
|
||||
})
|
||||
|
||||
it('emits the pointer fields', () => {
|
||||
const json = JSON.parse(manifest())
|
||||
expect(json).toMatchObject({
|
||||
schema: 1,
|
||||
name: 'testctl',
|
||||
channel: 'edge',
|
||||
version: VERSION,
|
||||
commit: 'abc1234',
|
||||
buildDate: '2026-06-14T12:00:00Z',
|
||||
baseUrl: 'https://example.r2.dev/testctl/edge',
|
||||
})
|
||||
})
|
||||
|
||||
it('carries both compat bounds through unswapped', () => {
|
||||
expect(JSON.parse(manifest()).compat).toEqual({ minDify: '2.0.0', maxDify: '2.5.0' })
|
||||
})
|
||||
|
||||
it('lists each target with asset name and sha256', () => {
|
||||
expect(JSON.parse(manifest()).targets).toEqual({
|
||||
'linux-x64': { asset: `testctl-v${VERSION}-linux-x64`, sha256: SHA_LINUX },
|
||||
'windows-x64': { asset: `testctl-v${VERSION}-windows-x64.exe`, sha256: SHA_WINDOWS },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders each target on a single line (install-r2.sh greps it)', () => {
|
||||
expect(manifest()).toMatch(/^ {4}"linux-x64": \{ "asset": ".*", "sha256": ".*" \}/m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildIndex', () => {
|
||||
const B1 = { version: '7.7.7-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
|
||||
const B2 = { version: '7.7.7-edge.bbbbbbb', commit: 'bbbbbbb', buildDate: '2026-06-14T10:00:00Z' }
|
||||
const entryOf = (b: typeof B1) => ({ ...b, dir: b.version })
|
||||
const build = (over = {}) =>
|
||||
buildIndex({ channel: 'edge', ...B2, current: null, existingDirs: null, ...over })
|
||||
|
||||
it('creates a fresh ledger when there is no current index', () => {
|
||||
const index = build()
|
||||
expect(index).toMatchObject({ schema: 1, channel: 'edge', updated: B2.buildDate })
|
||||
expect(index.builds).toEqual([entryOf(B2)])
|
||||
})
|
||||
|
||||
it('prepends the new build, newest first', () => {
|
||||
const index = build({ current: { builds: [entryOf(B1)] } })
|
||||
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
|
||||
})
|
||||
|
||||
it('replaces an existing entry for the same version rather than duplicating', () => {
|
||||
const stale = { ...entryOf(B2), commit: 'oldsha' }
|
||||
const index = build({ current: { builds: [stale, entryOf(B1)] } })
|
||||
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
|
||||
})
|
||||
|
||||
it('drops builds whose binaries no longer exist in R2', () => {
|
||||
const index = build({
|
||||
current: { builds: [entryOf(B1)] },
|
||||
existingDirs: new Set<string>(),
|
||||
})
|
||||
expect(index.builds).toEqual([entryOf(B2)])
|
||||
})
|
||||
|
||||
it('keeps builds that still exist in R2', () => {
|
||||
const index = build({
|
||||
current: { builds: [entryOf(B1)] },
|
||||
existingDirs: new Set([B1.version]),
|
||||
})
|
||||
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
|
||||
})
|
||||
|
||||
it('reconciles nothing when the caller could not list R2', () => {
|
||||
const index = build({ current: { builds: [entryOf(B1)] }, existingDirs: null })
|
||||
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// Pure release naming and version rules. Nothing here reads a file, prints, or
|
||||
// exits — the CLI shells own all of that.
|
||||
|
||||
const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
|
||||
const SEMVER_CORE_LEN = 3
|
||||
|
||||
// Add channels here: { name, prerelease, versionForm }.
|
||||
export const CHANNELS = [
|
||||
{ name: 'stable', prerelease: false, versionForm: /^\d+\.\d+\.\d+(\+[0-9A-Z.-]+)?$/i },
|
||||
{ name: 'alpha', prerelease: true, versionForm: /^\d+\.\d+\.\d+-alpha(\.\d+)?$/ },
|
||||
{ name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ },
|
||||
{ name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ },
|
||||
]
|
||||
|
||||
export const EDGE_SHA_RE = /^[0-9a-f]{7,40}$/
|
||||
|
||||
export const channelByName = (name) => CHANNELS.find((c) => c.name === name)
|
||||
export const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
|
||||
|
||||
export function versionCore(v) {
|
||||
return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0]
|
||||
}
|
||||
|
||||
// Compat ordering compares the numeric A.B.C core only: prerelease and build
|
||||
// suffixes are stripped first, so 1.16.0-rc.1 ranks equal to 1.16.0. This
|
||||
// matches the runtime check in src/version/compat.ts.
|
||||
function compareCore(a, b) {
|
||||
const A = versionCore(a).split('.').map(Number)
|
||||
const B = versionCore(b).split('.').map(Number)
|
||||
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
|
||||
const x = A[i] ?? 0
|
||||
const y = B[i] ?? 0
|
||||
if (x !== y) return x < y ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isWithinCompatWindow(version, { minDify, maxDify }) {
|
||||
return compareCore(version, minDify) >= 0 && compareCore(version, maxDify) <= 0
|
||||
}
|
||||
|
||||
// Returns a problem string if `version` cannot be resolved under `channel`,
|
||||
// else null.
|
||||
export function channelVersionProblem(version, channel) {
|
||||
if (typeof version !== 'string' || version.length === 0)
|
||||
return 'version must be a non-empty string'
|
||||
const ch = channelByName(channel)
|
||||
if (!ch) return `unknown channel: ${channel} (expected one of: ${channelNames()})`
|
||||
if (!ch.versionForm.test(version))
|
||||
return `version ${version} does not match the ${channel} channel form`
|
||||
return null
|
||||
}
|
||||
|
||||
// Returns a problem string if the edge base cannot be derived, else null.
|
||||
export function edgeVersionProblem(version, sha) {
|
||||
if (!EDGE_SHA_RE.test(sha ?? '')) return 'edge-version requires a git short sha (7-40 hex chars)'
|
||||
if (!/^\d+\.\d+\.\d+$/.test(versionCore(version)))
|
||||
return `cannot derive edge base from version: ${version}`
|
||||
return null
|
||||
}
|
||||
|
||||
export function edgeVersionFrom(version, sha) {
|
||||
return `${versionCore(version)}-edge.${sha}`
|
||||
}
|
||||
|
||||
export function tagName(release, version) {
|
||||
return `${release.tagPrefix}${version}`
|
||||
}
|
||||
|
||||
export function checksumsName(release, version) {
|
||||
return `${release.tagPrefix}${version}${release.checksumsSuffix}`
|
||||
}
|
||||
|
||||
export function assetNameFor(release, version, id) {
|
||||
const target = release.targets.find((t) => t.id === id)
|
||||
if (!target) return null
|
||||
return `${release.tagPrefix}${version}-${id}${target.exe ? '.exe' : ''}`
|
||||
}
|
||||
|
||||
export function compatProblems(compat) {
|
||||
const str = (v) => typeof v === 'string' && v.length > 0
|
||||
const problems = []
|
||||
if (!str(compat?.minDify)) problems.push('compat.minDify must be a non-empty string')
|
||||
if (!str(compat?.maxDify)) problems.push('compat.maxDify must be a non-empty string')
|
||||
if (problems.length === 0 && compareCore(compat.minDify, compat.maxDify) > 0)
|
||||
problems.push(
|
||||
`compat.minDify ${compat.minDify} must not exceed compat.maxDify ${compat.maxDify}`,
|
||||
)
|
||||
return problems
|
||||
}
|
||||
|
||||
export function releaseConfigProblems(release) {
|
||||
const problems = []
|
||||
const str = (v) => typeof v === 'string' && v.length > 0
|
||||
if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string')
|
||||
if (!str(release.binName)) problems.push('binName must be a non-empty string')
|
||||
if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string')
|
||||
if (!Array.isArray(release.targets) || release.targets.length === 0) {
|
||||
problems.push('targets must be a non-empty array')
|
||||
return problems
|
||||
}
|
||||
const seen = new Set()
|
||||
for (const t of release.targets) {
|
||||
const label = t?.id ?? JSON.stringify(t)
|
||||
if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`)
|
||||
else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`)
|
||||
else seen.add(t.id)
|
||||
if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget))
|
||||
problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`)
|
||||
if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`)
|
||||
else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-'))
|
||||
problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assetNameFor,
|
||||
channelVersionProblem,
|
||||
checksumsName,
|
||||
compatProblems,
|
||||
edgeVersionFrom,
|
||||
edgeVersionProblem,
|
||||
isWithinCompatWindow,
|
||||
releaseConfigProblems,
|
||||
tagName,
|
||||
} from './release-rules.mjs'
|
||||
|
||||
const RELEASE = {
|
||||
tagPrefix: 'testctl-v',
|
||||
binName: 'testctl',
|
||||
checksumsSuffix: '.sums',
|
||||
targets: [
|
||||
{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false },
|
||||
{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: true },
|
||||
],
|
||||
}
|
||||
|
||||
const WINDOW = { minDify: '2.0.0', maxDify: '2.5.0' }
|
||||
|
||||
describe('isWithinCompatWindow', () => {
|
||||
it.each([
|
||||
['2.3.0', true, 'inside the window'],
|
||||
['2.0.0', true, 'the inclusive lower bound'],
|
||||
['2.5.0', true, 'the inclusive upper bound'],
|
||||
['v2.3.0', true, 'a v-prefixed tag'],
|
||||
['1.9.9', false, 'below the lower bound'],
|
||||
['2.5.1', false, 'above the upper bound'],
|
||||
])('%s -> %s (%s)', (version, expected) => {
|
||||
expect(isWithinCompatWindow(version, WINDOW)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['2.0.0-rc.1', true, 'a prerelease of the lower bound ranks equal to it'],
|
||||
['2.5.0-rc.1', true, 'a prerelease of the upper bound ranks equal to it'],
|
||||
['2.3.0-alpha.7+build9', true, 'both suffixes stripped before comparing'],
|
||||
['2.5.0+build123', true, 'build metadata on the upper bound'],
|
||||
['2.5.1-rc.1', false, 'a prerelease whose core is above the window'],
|
||||
['1.9.9-rc.1', false, 'a prerelease whose core is below the window'],
|
||||
['2.5.1+build123', false, 'build metadata out of range'],
|
||||
])('ignores suffixes: %s -> %s (%s)', (version, expected) => {
|
||||
expect(isWithinCompatWindow(version, WINDOW)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('channelVersionProblem', () => {
|
||||
it.each([
|
||||
['1.2.3', 'stable'],
|
||||
['1.2.3+build', 'stable'],
|
||||
['1.2.3-alpha', 'alpha'],
|
||||
['1.2.3-alpha.4', 'alpha'],
|
||||
['1.2.3-rc.4', 'rc'],
|
||||
['1.2.3-edge.2fd7b82', 'edge'],
|
||||
])('accepts %s on the %s channel', (version, channel) => {
|
||||
expect(channelVersionProblem(version, channel)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1.2.3-rc.1', 'edge'],
|
||||
['1.2.3-alpha', 'stable'],
|
||||
['1.2.3', 'rc'],
|
||||
])('rejects %s on the %s channel', (version, channel) => {
|
||||
expect(channelVersionProblem(version, channel)).toMatch(/does not match the .* channel form/)
|
||||
})
|
||||
|
||||
it('rejects an unknown channel', () => {
|
||||
expect(channelVersionProblem('1.2.3', 'nightly')).toMatch(/unknown channel/)
|
||||
})
|
||||
|
||||
it('rejects an empty version', () => {
|
||||
expect(channelVersionProblem('', 'stable')).toMatch(/non-empty string/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge version derivation', () => {
|
||||
it('derives <core>-edge.<sha>, dropping the prerelease', () => {
|
||||
expect(edgeVersionProblem('3.4.5-alpha', '2fd7b82')).toBeNull()
|
||||
expect(edgeVersionFrom('3.4.5-alpha', '2fd7b82')).toBe('3.4.5-edge.2fd7b82')
|
||||
})
|
||||
|
||||
it('accepts a 40-char sha', () => {
|
||||
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
|
||||
expect(edgeVersionProblem('3.4.5-alpha', sha)).toBeNull()
|
||||
expect(edgeVersionFrom('3.4.5-alpha', sha)).toBe(`3.4.5-edge.${sha}`)
|
||||
})
|
||||
|
||||
it.each([['nothex!'], [''], ['abc'], [undefined]])('rejects sha %s', (sha) => {
|
||||
expect(edgeVersionProblem('3.4.5-alpha', sha)).toMatch(/git short sha/)
|
||||
})
|
||||
|
||||
it('rejects a version with no derivable core', () => {
|
||||
expect(edgeVersionProblem('not-a-version', '2fd7b82')).toMatch(/cannot derive edge base/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('artifact names', () => {
|
||||
it('builds the tag and checksums names from the prefix', () => {
|
||||
expect(tagName(RELEASE, '9.9.9')).toBe('testctl-v9.9.9')
|
||||
expect(checksumsName(RELEASE, '9.9.9')).toBe('testctl-v9.9.9.sums')
|
||||
})
|
||||
|
||||
it('appends .exe only for targets flagged exe', () => {
|
||||
expect(assetNameFor(RELEASE, '9.9.9', 'linux-x64')).toBe('testctl-v9.9.9-linux-x64')
|
||||
expect(assetNameFor(RELEASE, '9.9.9', 'windows-x64')).toBe('testctl-v9.9.9-windows-x64.exe')
|
||||
})
|
||||
|
||||
it('returns null for an unknown target id', () => {
|
||||
expect(assetNameFor(RELEASE, '9.9.9', 'solaris-sparc')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('compatProblems', () => {
|
||||
it('accepts a well-formed window', () => {
|
||||
expect(compatProblems(WINDOW)).toEqual([])
|
||||
expect(compatProblems({ minDify: '2.0.0', maxDify: '2.0.0' })).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, /minDify must be a non-empty string/],
|
||||
[{ maxDify: '2.5.0' }, /minDify must be a non-empty string/],
|
||||
[{ minDify: '2.0.0' }, /maxDify must be a non-empty string/],
|
||||
[{ minDify: '', maxDify: '' }, /minDify must be a non-empty string/],
|
||||
])('flags a missing bound', (compat, expected) => {
|
||||
expect(compatProblems(compat).join('\n')).toMatch(expected)
|
||||
})
|
||||
|
||||
it('compares bounds by core, so suffixes do not make a window inverted', () => {
|
||||
expect(compatProblems({ minDify: '2.0.0-rc.1', maxDify: '2.0.0' })).toEqual([])
|
||||
})
|
||||
|
||||
it('flags an inverted window', () => {
|
||||
expect(compatProblems({ minDify: '2.5.0', maxDify: '2.0.0' }).join('\n')).toMatch(
|
||||
/must not exceed/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('releaseConfigProblems', () => {
|
||||
it('accepts a well-formed config', () => {
|
||||
expect(releaseConfigProblems(RELEASE)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...RELEASE, tagPrefix: '' }, /tagPrefix must be a non-empty string/],
|
||||
[{ ...RELEASE, binName: '' }, /binName must be a non-empty string/],
|
||||
[{ ...RELEASE, checksumsSuffix: '' }, /checksumsSuffix must be a non-empty string/],
|
||||
[{ ...RELEASE, targets: [] }, /targets must be a non-empty array/],
|
||||
[{ ...RELEASE, targets: undefined }, /targets must be a non-empty array/],
|
||||
])('flags a malformed field', (release, expected) => {
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(expected)
|
||||
})
|
||||
|
||||
it('flags a duplicate target id', () => {
|
||||
const release = { ...RELEASE, targets: [RELEASE.targets[0], { ...RELEASE.targets[0] }] }
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(/duplicate target id: linux-x64/)
|
||||
})
|
||||
|
||||
it('flags a bunTarget that is not a bun triple', () => {
|
||||
const release = { ...RELEASE, targets: [{ id: 'a', bunTarget: 'linux-x64', exe: false }] }
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(/bunTarget must match/)
|
||||
})
|
||||
|
||||
it('flags exe disagreeing with a windows bunTarget', () => {
|
||||
const release = {
|
||||
...RELEASE,
|
||||
targets: [{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: false }],
|
||||
}
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(/exe must be true iff/)
|
||||
})
|
||||
|
||||
it('flags a non-boolean exe', () => {
|
||||
const release = {
|
||||
...RELEASE,
|
||||
targets: [{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: 'no' }],
|
||||
}
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(/exe must be a boolean/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { main } from './release-naming.mjs'
|
||||
|
||||
const env = Object.fromEntries(
|
||||
main(['github-env'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
|
||||
)
|
||||
|
||||
// Install scripts run standalone on an end user's machine — no repo, no node —
|
||||
// so they hardcode artifact names instead of asking release-naming.mjs. These
|
||||
// checks are what keeps the two copies from drifting: change the naming config
|
||||
// and they fail until the installers are updated to match.
|
||||
const INSTALLERS = ['install-cli.sh', 'install-r2.sh', 'install.ps1', 'install-r2.ps1']
|
||||
const installerText = (name: string) =>
|
||||
readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), 'utf8')
|
||||
|
||||
const CHECKSUMS_SUFFIX = main(['checksums', '1.2.3']).trim().replace(`${env.tagPrefix}1.2.3`, '')
|
||||
|
||||
const EXE_TARGET_ID = main(['targets'])
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line: string) => line.split('\t'))
|
||||
.find(([, , exe]: string[]) => exe === '1')?.[1]
|
||||
|
||||
describe('install scripts stay aligned with the release naming config', () => {
|
||||
it.each(INSTALLERS)('%s uses the configured tag prefix', (name) => {
|
||||
expect(installerText(name)).toContain(env.tagPrefix)
|
||||
})
|
||||
|
||||
it.each(INSTALLERS)('%s uses the configured checksums suffix', (name) => {
|
||||
expect(installerText(name)).toContain(CHECKSUMS_SUFFIX)
|
||||
})
|
||||
|
||||
it.each(['install.ps1', 'install-r2.ps1'])('%s targets the declared windows build', (name) => {
|
||||
expect(EXE_TARGET_ID).toBeTruthy()
|
||||
expect(installerText(name)).toContain(EXE_TARGET_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the shipped difyctl release config', () => {
|
||||
it('passes the release gate (`release-naming.mjs validate`)', () => {
|
||||
expect(main(['validate'])).toMatch(/^difyctl release valid:/)
|
||||
})
|
||||
|
||||
it('declares a usable, correctly ordered compat window', () => {
|
||||
expect(main(['compat-check', env.minDify])).toContain('compatible')
|
||||
expect(main(['compat-check', env.maxDify])).toContain('compatible')
|
||||
})
|
||||
|
||||
it('gates a Dify version outside the declared window', () => {
|
||||
const aboveMax = `${Number(env.maxDify.split('.')[0]) + 1}.0.0`
|
||||
expect(() => main(['compat-check', aboveMax])).toThrow('outside difyctl compatibility window')
|
||||
expect(() => main(['compat-check', '0.0.1'])).toThrow('outside difyctl compatibility window')
|
||||
})
|
||||
|
||||
it('declares a version valid for the channel it ships on', () => {
|
||||
expect(main(['validate-version', env.version, env.channel])).toContain('valid')
|
||||
})
|
||||
|
||||
it('can name an artifact for every target it declares', () => {
|
||||
const ids = main(['targets'])
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line: string) => line.split('\t')[1])
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
for (const id of ids) expect(main(['asset', env.version, id])).toContain(id)
|
||||
})
|
||||
})
|
||||
+63
-172
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
// release-naming.mjs — single source of truth for difyctl release artifact
|
||||
// names and version/channel rules. Reads DATA from cli/package.json
|
||||
// `difyctl.release` (plus `version` and `difyctl.channel`) and owns the name
|
||||
// FORMAT and the per-channel version form. Producer scripts call this;
|
||||
// `validate` is the release gate.
|
||||
// `difyctl.release` (plus `version` and `difyctl.channel`); the name FORMAT and
|
||||
// the per-channel version form live in lib/release-rules.mjs. Producer scripts
|
||||
// call this; `validate` is the release gate.
|
||||
//
|
||||
// Subcommands:
|
||||
// tag <version> -> <tagPrefix><version>
|
||||
@@ -19,109 +18,31 @@
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
assetNameFor,
|
||||
channelByName,
|
||||
channelNames,
|
||||
CHANNELS,
|
||||
channelVersionProblem,
|
||||
checksumsName,
|
||||
compatProblems,
|
||||
edgeVersionFrom,
|
||||
edgeVersionProblem,
|
||||
isWithinCompatWindow,
|
||||
releaseConfigProblems,
|
||||
tagName,
|
||||
} from './lib/release-rules.mjs'
|
||||
|
||||
const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
|
||||
const SEMVER_CORE_LEN = 3
|
||||
const PKG_URL = new URL('../package.json', import.meta.url)
|
||||
|
||||
// Add channels here: { name, prerelease, versionForm }.
|
||||
const CHANNELS = [
|
||||
{ name: 'stable', prerelease: false, versionForm: /^\d+\.\d+\.\d+(\+[0-9A-Z.-]+)?$/i },
|
||||
{ name: 'alpha', prerelease: true, versionForm: /^\d+\.\d+\.\d+-alpha(\.\d+)?$/ },
|
||||
{ name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ },
|
||||
{ name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ },
|
||||
]
|
||||
class UsageError extends Error {}
|
||||
|
||||
const channelByName = (name) => CHANNELS.find((c) => c.name === name)
|
||||
const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
|
||||
|
||||
function parsePrecedence(v) {
|
||||
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
|
||||
const i = s.indexOf('-')
|
||||
const core = i === -1 ? s : s.slice(0, i)
|
||||
const pre = i === -1 ? '' : s.slice(i + 1)
|
||||
return { nums: core.split('.').map(Number), pre }
|
||||
// Arrow so TS infers `never`, keeping expression positions like `x ?? die(...)` typed.
|
||||
const die = (msg) => {
|
||||
throw new UsageError(msg)
|
||||
}
|
||||
|
||||
function versionCore(v) {
|
||||
return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0]
|
||||
}
|
||||
|
||||
function edgeVersion(sha) {
|
||||
if (!/^[0-9a-f]{7,40}$/.test(sha ?? ''))
|
||||
die('edge-version requires a git short sha (7-40 hex chars)')
|
||||
const { version } = loadPkg()
|
||||
const core = versionCore(version)
|
||||
if (!/^\d+\.\d+\.\d+$/.test(core)) die(`cannot derive edge base from version: ${version}`)
|
||||
return `${core}-edge.${sha}`
|
||||
}
|
||||
|
||||
// Returns a problem string if `version` cannot be resolved under `channel`, else
|
||||
// null. Shared by validateVersionForChannel (die-now) and validateVersionChannel
|
||||
// (collect for the `validate` gate).
|
||||
function channelVersionProblem(version, channel) {
|
||||
if (typeof version !== 'string' || version.length === 0)
|
||||
return 'version must be a non-empty string'
|
||||
const ch = channelByName(channel)
|
||||
if (!ch) return `unknown channel: ${channel} (expected one of: ${channelNames()})`
|
||||
if (!ch.versionForm.test(version))
|
||||
return `version ${version} does not match the ${channel} channel form`
|
||||
return null
|
||||
}
|
||||
|
||||
function validateVersionForChannel(version, channelName) {
|
||||
const problem = channelVersionProblem(version, channelName)
|
||||
if (problem) die(problem)
|
||||
return `valid: ${version} is a ${channelName} version`
|
||||
}
|
||||
|
||||
function comparePre(a, b) {
|
||||
const aparts = a.split('.')
|
||||
const bparts = b.split('.')
|
||||
const len = Math.max(aparts.length, bparts.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (aparts[i] === undefined) return -1
|
||||
if (bparts[i] === undefined) return 1
|
||||
const an = /^\d+$/.test(aparts[i])
|
||||
const bn = /^\d+$/.test(bparts[i])
|
||||
if (an && bn) {
|
||||
const d = Number(aparts[i]) - Number(bparts[i])
|
||||
if (d !== 0) return d < 0 ? -1 : 1
|
||||
} else if (an !== bn) {
|
||||
return an ? -1 : 1
|
||||
} else if (aparts[i] !== bparts[i]) {
|
||||
return aparts[i] < bparts[i] ? -1 : 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function comparePrecedence(a, b) {
|
||||
const A = parsePrecedence(a)
|
||||
const B = parsePrecedence(b)
|
||||
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
|
||||
const x = A.nums[i] ?? 0
|
||||
const y = B.nums[i] ?? 0
|
||||
if (x !== y) return x < y ? -1 : 1
|
||||
}
|
||||
if (A.pre === B.pre) return 0
|
||||
if (A.pre === '') return 1
|
||||
if (B.pre === '') return -1
|
||||
return comparePre(A.pre, B.pre)
|
||||
}
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write(`release-naming: ${msg}\n`)
|
||||
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 pkgPath = process.env[PKG_PATH_ENV] || new URL('../package.json', import.meta.url)
|
||||
function loadPkg(pkgPath = PKG_URL) {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
|
||||
return {
|
||||
@@ -142,7 +63,7 @@ function githubEnv() {
|
||||
minDify: compat.minDify,
|
||||
maxDify: compat.maxDify,
|
||||
tagPrefix: release.tagPrefix,
|
||||
difyctlTag: `${release.tagPrefix}${version}`,
|
||||
difyctlTag: tagName(release, version),
|
||||
}
|
||||
return Object.entries(fields)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
@@ -154,58 +75,19 @@ function requireVersion(version) {
|
||||
return version
|
||||
}
|
||||
|
||||
function assetName(release, version, id) {
|
||||
const target = release.targets.find((t) => t.id === id)
|
||||
if (!target) die(`unknown target id: ${id}`)
|
||||
const suffix = target.exe ? '.exe' : ''
|
||||
return `${release.tagPrefix}${version}-${id}${suffix}`
|
||||
}
|
||||
|
||||
function validateRelease(release) {
|
||||
const problems = []
|
||||
const str = (v) => typeof v === 'string' && v.length > 0
|
||||
if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string')
|
||||
if (!str(release.binName)) problems.push('binName must be a non-empty string')
|
||||
if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string')
|
||||
if (!Array.isArray(release.targets) || release.targets.length === 0) {
|
||||
problems.push('targets must be a non-empty array')
|
||||
return problems
|
||||
}
|
||||
const seen = new Set()
|
||||
for (const t of release.targets) {
|
||||
const label = t?.id ?? JSON.stringify(t)
|
||||
if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`)
|
||||
else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`)
|
||||
else seen.add(t.id)
|
||||
if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget))
|
||||
problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`)
|
||||
if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`)
|
||||
else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-'))
|
||||
problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
function validateVersionChannel(version, channel) {
|
||||
const problem = channelVersionProblem(version, channel)
|
||||
return problem ? [problem] : []
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const [cmd, ...rest] = argv
|
||||
switch (cmd) {
|
||||
case 'tag':
|
||||
return `${loadPkg().release.tagPrefix}${requireVersion(rest[0])}`
|
||||
case 'asset':
|
||||
return assetName(
|
||||
loadPkg().release,
|
||||
requireVersion(rest[0]),
|
||||
rest[1] ?? die('target id is required'),
|
||||
)
|
||||
case 'checksums': {
|
||||
return tagName(loadPkg().release, requireVersion(rest[0]))
|
||||
case 'asset': {
|
||||
const { release } = loadPkg()
|
||||
return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}`
|
||||
const version = requireVersion(rest[0])
|
||||
const id = rest[1] ?? die('target id is required')
|
||||
return assetNameFor(release, version, id) ?? die(`unknown target id: ${id}`)
|
||||
}
|
||||
case 'checksums':
|
||||
return checksumsName(loadPkg().release, requireVersion(rest[0]))
|
||||
case 'tag-prefix':
|
||||
return loadPkg().release.tagPrefix
|
||||
case 'targets':
|
||||
@@ -221,10 +103,7 @@ function main(argv) {
|
||||
const difyVersion = requireVersion(rest[0])
|
||||
if (!compat.minDify || !compat.maxDify)
|
||||
die('cli/package.json missing difyctl.compat.minDify/maxDify')
|
||||
if (
|
||||
comparePrecedence(difyVersion, compat.minDify) < 0 ||
|
||||
comparePrecedence(difyVersion, compat.maxDify) > 0
|
||||
)
|
||||
if (!isWithinCompatWindow(difyVersion, compat))
|
||||
die(
|
||||
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
|
||||
)
|
||||
@@ -236,19 +115,31 @@ function main(argv) {
|
||||
return String(ch.prerelease)
|
||||
}
|
||||
case 'validate': {
|
||||
const { version, channel, release } = loadPkg()
|
||||
const problems = [...validateRelease(release), ...validateVersionChannel(version, channel)]
|
||||
const { version, channel, compat, release } = loadPkg()
|
||||
const versionProblem = channelVersionProblem(version, channel)
|
||||
const problems = [
|
||||
...releaseConfigProblems(release),
|
||||
...compatProblems(compat),
|
||||
...(versionProblem ? [versionProblem] : []),
|
||||
]
|
||||
if (problems.length > 0)
|
||||
die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`)
|
||||
return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}`
|
||||
}
|
||||
case 'edge-version':
|
||||
return edgeVersion(rest[0])
|
||||
case 'validate-version':
|
||||
return validateVersionForChannel(
|
||||
requireVersion(rest[0]),
|
||||
rest[1] ?? die('channel argument is required'),
|
||||
)
|
||||
case 'edge-version': {
|
||||
const { version } = loadPkg()
|
||||
const sha = rest[0]
|
||||
const problem = edgeVersionProblem(version, sha)
|
||||
if (problem) die(problem)
|
||||
return edgeVersionFrom(version, sha)
|
||||
}
|
||||
case 'validate-version': {
|
||||
const version = requireVersion(rest[0])
|
||||
const channel = rest[1] ?? die('channel argument is required')
|
||||
const problem = channelVersionProblem(version, channel)
|
||||
if (problem) die(problem)
|
||||
return `valid: ${version} is a ${channel} version`
|
||||
}
|
||||
default:
|
||||
die(`unknown subcommand: ${cmd ?? '(none)'}`)
|
||||
}
|
||||
@@ -256,14 +147,14 @@ function main(argv) {
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
if (invokedDirectly) process.stdout.write(`${main(process.argv.slice(2))}\n`)
|
||||
|
||||
export {
|
||||
assetName,
|
||||
channelByName,
|
||||
CHANNELS,
|
||||
edgeVersion,
|
||||
loadPkg,
|
||||
validateVersionForChannel,
|
||||
versionCore,
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
process.stdout.write(`${main(process.argv.slice(2))}\n`)
|
||||
} catch (e) {
|
||||
if (!(e instanceof UsageError)) throw e
|
||||
process.stderr.write(`release-naming: ${e.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export { loadPkg, main }
|
||||
|
||||
@@ -1,122 +1,77 @@
|
||||
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'
|
||||
import { main } from './release-naming.mjs'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
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 }
|
||||
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
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(compatCheck('2.3.0')).toBe(0)
|
||||
describe('release-naming argument handling', () => {
|
||||
it.each([
|
||||
[['tag'], 'version argument is required'],
|
||||
[['asset'], 'version argument is required'],
|
||||
[['checksums'], 'version argument is required'],
|
||||
[['compat-check'], 'version argument is required'],
|
||||
[['validate-version'], 'version argument is required'],
|
||||
[['prerelease'], 'channel argument is required'],
|
||||
[['edge-version'], 'git short sha'],
|
||||
[['bogus'], 'unknown subcommand'],
|
||||
[[], 'unknown subcommand'],
|
||||
])('rejects %j', (args, message) => {
|
||||
expect(() => main(args)).toThrow(message)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(compatCheck(minDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
expect(compatCheck(maxDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(compatCheck('v2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(compatCheck('1.9.9')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(compatCheck('2.5.1')).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', () => {
|
||||
expect(compatCheck(`${maxDify}+build123`)).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(compatCheck()).not.toBe(0)
|
||||
it('rejects an unknown target id', () => {
|
||||
expect(() => main(['asset', '9.9.9', 'solaris-sparc'])).toThrow('unknown target id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming github-env', () => {
|
||||
it('emits difyctlTag = tagPrefix + version', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.2\.0-alpha$/m)
|
||||
describe('release-naming output shape', () => {
|
||||
it('emits every key CI reads from github-env', () => {
|
||||
const out = main(['github-env'])
|
||||
for (const key of [
|
||||
'version',
|
||||
'channel',
|
||||
'prerelease',
|
||||
'minDify',
|
||||
'maxDify',
|
||||
'tagPrefix',
|
||||
'difyctlTag',
|
||||
])
|
||||
expect(out).toMatch(new RegExp(`^${key}=.+$`, 'm'))
|
||||
expect(out).not.toMatch(/=undefined$/m)
|
||||
})
|
||||
|
||||
it('still emits the existing trace fields', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
for (const key of ['version', 'channel', 'prerelease', 'minDify', 'maxDify', 'tagPrefix'])
|
||||
expect(stdout).toMatch(new RegExp(`^${key}=`, 'm'))
|
||||
it('emits difyctlTag as tagPrefix immediately followed by version', () => {
|
||||
const env = Object.fromEntries(
|
||||
main(['github-env'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
|
||||
)
|
||||
expect(env.difyctlTag).toBe(`${env.tagPrefix}${env.version}`)
|
||||
})
|
||||
|
||||
// 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)
|
||||
it('lists one channel per line, including edge', () => {
|
||||
const out = main(['channels'])
|
||||
expect(out.trim().split('\n').length).toBeGreaterThan(1)
|
||||
expect(out).toMatch(/^edge$/m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming edge channel', () => {
|
||||
it('lists edge among channels', () => {
|
||||
expect(run(['channels']).stdout).toMatch(/^edge$/m)
|
||||
it('emits targets as bunTarget<TAB>id<TAB>0|1 (release-build.sh parses this)', () => {
|
||||
for (const line of main(['targets']).trim().split('\n'))
|
||||
expect(line).toMatch(/^\S+\t\S+\t[01]$/)
|
||||
})
|
||||
|
||||
it('edge-version derives <pkgcore>-edge.<sha> from the package version', () => {
|
||||
// package.json version is 0.2.0-alpha -> core 0.2.0
|
||||
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.2.0-edge.2fd7b82')
|
||||
it('reports prerelease as a boolean per channel', () => {
|
||||
expect(main(['prerelease', 'stable']).trim()).toBe('false')
|
||||
expect(main(['prerelease', 'alpha']).trim()).toBe('true')
|
||||
expect(() => main(['prerelease', 'nightly'])).toThrow('unknown channel')
|
||||
})
|
||||
|
||||
it('edge-version accepts a 40-char sha', () => {
|
||||
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
|
||||
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.2.0-edge.${sha}`)
|
||||
it('derives an edge version from the packaged version and a sha', () => {
|
||||
expect(main(['edge-version', '2fd7b82']).trim()).toMatch(/-edge\.2fd7b82$/)
|
||||
})
|
||||
|
||||
it('edge-version rejects a non-hex sha', () => {
|
||||
expect(run(['edge-version', 'nothex!']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('edge-version requires a sha argument', () => {
|
||||
expect(run(['edge-version']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('the edge version form matches a computed edge version', () => {
|
||||
expect(run(['validate-version', '0.1.0-edge.2fd7b82', 'edge']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('validate-version rejects an rc string under the edge channel', () => {
|
||||
expect(run(['validate-version', '0.1.0-rc.1', 'edge']).code).not.toBe(0)
|
||||
it('validates a version against a channel form', () => {
|
||||
expect(main(['validate-version', '0.1.0-edge.2fd7b82', 'edge'])).toContain('valid')
|
||||
expect(() => main(['validate-version', '0.1.0-rc.1', 'edge'])).toThrow(
|
||||
'does not match the edge channel form',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
// release-r2-edge.mjs — edge/R2 release metadata generator. Two subcommands:
|
||||
// manifest -> the per-channel pointer manifest.json (the installer reads this)
|
||||
// index -> the per-channel build-history ledger index.json
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { assetName, loadPkg, validateVersionForChannel } from './release-naming.mjs'
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
buildIndex,
|
||||
parseChecksums,
|
||||
parseDirList,
|
||||
renderManifest,
|
||||
resolveTargets,
|
||||
} from './lib/edge-manifest.mjs'
|
||||
import { channelVersionProblem } from './lib/release-rules.mjs'
|
||||
import { loadPkg } from './release-naming.mjs'
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write(`release-r2-edge: ${msg}\n`)
|
||||
process.exit(1)
|
||||
class UsageError extends Error {}
|
||||
|
||||
// Arrow so TS infers `never`, keeping expression positions like `return die(...)` typed.
|
||||
const die = (msg) => {
|
||||
throw new UsageError(msg)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -28,104 +38,83 @@ function requireArgs(args, keys) {
|
||||
}
|
||||
}
|
||||
|
||||
// checksums lines are "<sha256> <assetName>"
|
||||
function shaMap(checksumsPath) {
|
||||
const map = new Map()
|
||||
for (const line of readFileSync(checksumsPath, 'utf8').split('\n')) {
|
||||
const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i)
|
||||
if (m) map.set(m[2], m[1])
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function emitManifest(args) {
|
||||
requireArgs(args, ['channel', 'version', 'commit', 'build-date', 'base-url', 'checksums'])
|
||||
validateVersionForChannel(args.version, args.channel)
|
||||
const versionProblem = channelVersionProblem(args.version, args.channel)
|
||||
if (versionProblem) die(versionProblem)
|
||||
|
||||
const { release, compat } = loadPkg()
|
||||
const shas = shaMap(args.checksums)
|
||||
const shas = parseChecksums(readFileSync(args.checksums, 'utf8'))
|
||||
const { targets, missing } = resolveTargets(release, args.version, shas)
|
||||
if (missing.length > 0) die(`no sha256 for ${missing[0]} in ${args.checksums}`)
|
||||
|
||||
const targetLines = release.targets
|
||||
.map((t) => {
|
||||
const asset = assetName(release, args.version, t.id)
|
||||
const sha = shas.get(asset)
|
||||
if (!sha) die(`no sha256 for ${asset} in ${args.checksums}`)
|
||||
// one target per line: install-r2.sh grep/sed depends on this layout
|
||||
return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }`
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
const head = {
|
||||
schema: 1,
|
||||
name: release.binName,
|
||||
return renderManifest({
|
||||
binName: release.binName,
|
||||
channel: args.channel,
|
||||
version: args.version,
|
||||
commit: args.commit,
|
||||
buildDate: args['build-date'],
|
||||
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
|
||||
compat,
|
||||
baseUrl: args['base-url'],
|
||||
}
|
||||
const headLines = Object.entries(head)
|
||||
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
||||
.join(',\n')
|
||||
process.stdout.write(`{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`)
|
||||
targets,
|
||||
})
|
||||
}
|
||||
|
||||
// Newline-delimited dir names of binaries that still exist in R2. Absent file =
|
||||
// no reconciliation (caller could not list); empty file = no survivors.
|
||||
// empty / "-" / missing = no ledger yet (first publish)
|
||||
function loadCurrentIndex(path) {
|
||||
if (path === '-' || !existsSync(path)) return null
|
||||
const raw = readFileSync(path, 'utf8').trim()
|
||||
if (!raw || raw === '-') return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return die(`current index at ${path} is not valid JSON`)
|
||||
}
|
||||
}
|
||||
|
||||
// Absent file = no reconciliation (caller could not list); empty file = no survivors.
|
||||
function loadExistingDirs(path) {
|
||||
if (!path || !existsSync(path)) return null
|
||||
const set = new Set()
|
||||
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
||||
const d = line.trim()
|
||||
if (d) set.add(d)
|
||||
}
|
||||
return set
|
||||
return parseDirList(readFileSync(path, 'utf8'))
|
||||
}
|
||||
|
||||
function emitIndex(args) {
|
||||
requireArgs(args, ['current', 'channel', 'version', 'commit', 'build-date'])
|
||||
|
||||
// empty / "-" / missing = no ledger yet (first publish)
|
||||
let current = { schema: 1, channel: args.channel, builds: [] }
|
||||
if (args.current !== '-' && existsSync(args.current)) {
|
||||
const raw = readFileSync(args.current, 'utf8').trim()
|
||||
if (raw && raw !== '-') {
|
||||
try {
|
||||
current = JSON.parse(raw)
|
||||
} catch {
|
||||
die(`current index at ${args.current} is not valid JSON`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entry = {
|
||||
const index = buildIndex({
|
||||
channel: args.channel,
|
||||
version: args.version,
|
||||
commit: args.commit,
|
||||
buildDate: args['build-date'],
|
||||
dir: args.version,
|
||||
current: loadCurrentIndex(args.current),
|
||||
existingDirs: loadExistingDirs(args['existing-dirs']),
|
||||
})
|
||||
return `${JSON.stringify(index, null, 2)}\n`
|
||||
}
|
||||
|
||||
// Returns the exact bytes to write to stdout.
|
||||
function main(argv) {
|
||||
const [cmd, ...rest] = argv
|
||||
const args = parseArgs(rest)
|
||||
switch (cmd) {
|
||||
case 'manifest':
|
||||
return emitManifest(args)
|
||||
case 'index':
|
||||
return emitIndex(args)
|
||||
default:
|
||||
return die(`unknown subcommand: ${cmd ?? '(none)'} (expected: manifest | index)`)
|
||||
}
|
||||
const kept = (current.builds ?? []).filter((b) => b.version !== entry.version)
|
||||
let builds = [entry, ...kept]
|
||||
|
||||
// Reconcile to binaries that still exist in R2: lifecycle/TTL on the bin prefix
|
||||
// is the only deletion mechanism, so the ledger never advertises a build whose
|
||||
// binary is gone. The new build is always kept (just uploaded). No count cap.
|
||||
const existing = loadExistingDirs(args['existing-dirs'])
|
||||
if (existing) builds = builds.filter((b) => b.dir === entry.dir || existing.has(b.dir))
|
||||
|
||||
const index = { schema: 1, channel: args.channel, updated: args['build-date'], builds }
|
||||
process.stdout.write(`${JSON.stringify(index, null, 2)}\n`)
|
||||
}
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2)
|
||||
const args = parseArgs(rest)
|
||||
switch (cmd) {
|
||||
case 'manifest':
|
||||
emitManifest(args)
|
||||
break
|
||||
case 'index':
|
||||
emitIndex(args)
|
||||
break
|
||||
default:
|
||||
die(`unknown subcommand: ${cmd ?? '(none)'} (expected: manifest | index)`)
|
||||
const invokedDirectly =
|
||||
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
process.stdout.write(main(process.argv.slice(2)))
|
||||
} catch (e) {
|
||||
if (!(e instanceof UsageError)) throw e
|
||||
process.stderr.write(`release-r2-edge: ${e.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export { main }
|
||||
|
||||
+108
-241
@@ -1,49 +1,28 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
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',
|
||||
env: { ...process.env, ...PKG_ENV },
|
||||
}),
|
||||
stderr: '',
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: string; stderr?: string }
|
||||
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- manifest ----
|
||||
|
||||
function writeChecksums(version: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
|
||||
const ids = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
const lines = ids.map((id, i) => {
|
||||
const exe = id === 'windows-x64' ? '.exe' : ''
|
||||
const sha = String(i).repeat(64)
|
||||
return `${sha} difyctl-v${version}-${id}${exe}`
|
||||
})
|
||||
const file = join(dir, `difyctl-v${version}-checksums.txt`)
|
||||
writeFileSync(file, `${lines.join('\n')}\n`)
|
||||
return file
|
||||
}
|
||||
import { main as naming } from './release-naming.mjs'
|
||||
import { main } from './release-r2-edge.mjs'
|
||||
|
||||
const VERSION = '0.1.0-edge.2fd7b82'
|
||||
const BASE_URL = 'https://example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82'
|
||||
|
||||
const TARGET_IDS = naming(['targets'])
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line: string) => line.split('\t')[1])
|
||||
|
||||
const ASSETS = new Map(TARGET_IDS.map((id: string) => [id, naming(['asset', VERSION, id]).trim()]))
|
||||
const assetFor = (id: string) => ASSETS.get(id) ?? ''
|
||||
|
||||
const PKG_ENV = Object.fromEntries(
|
||||
naming(['github-env'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
|
||||
)
|
||||
|
||||
type ManifestJson = {
|
||||
schema: number
|
||||
name: string
|
||||
@@ -56,28 +35,23 @@ type ManifestJson = {
|
||||
targets: Record<string, { asset: string; sha256: string }>
|
||||
}
|
||||
|
||||
type IndexBuild = {
|
||||
version: string
|
||||
commit: string
|
||||
buildDate: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
type IndexJson = {
|
||||
schema: number
|
||||
channel: string
|
||||
updated: string
|
||||
builds: IndexBuild[]
|
||||
builds: { version: string; commit: string; buildDate: string; dir: string }[]
|
||||
}
|
||||
|
||||
function buildManifest(version = VERSION): {
|
||||
code: number
|
||||
json: ManifestJson
|
||||
stdout: string
|
||||
stderr: string
|
||||
} {
|
||||
const checksums = writeChecksums(version)
|
||||
const r = run([
|
||||
function writeChecksums(ids: string[]): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
|
||||
const lines = ids.map((id, i) => `${String(i).repeat(64)} ${assetFor(id)}`)
|
||||
const file = join(dir, 'checksums.txt')
|
||||
writeFileSync(file, `${lines.join('\n')}\n`)
|
||||
return file
|
||||
}
|
||||
|
||||
function manifestArgs(version = VERSION, ids = TARGET_IDS): string[] {
|
||||
return [
|
||||
'manifest',
|
||||
'--channel',
|
||||
'edge',
|
||||
@@ -90,229 +64,122 @@ function buildManifest(version = VERSION): {
|
||||
'--base-url',
|
||||
BASE_URL,
|
||||
'--checksums',
|
||||
checksums,
|
||||
])
|
||||
return {
|
||||
code: r.code,
|
||||
json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson,
|
||||
stdout: r.stdout,
|
||||
stderr: r.stderr,
|
||||
}
|
||||
writeChecksums(ids),
|
||||
]
|
||||
}
|
||||
|
||||
const buildManifest = () => JSON.parse(main(manifestArgs())) as ManifestJson
|
||||
|
||||
describe('release-r2-edge manifest', () => {
|
||||
it('emits the core pointer fields', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.schema).toBe(1)
|
||||
expect(json.name).toBe('difyctl')
|
||||
expect(json.channel).toBe('edge')
|
||||
expect(json.version).toBe(VERSION)
|
||||
expect(json.commit).toBe('abc1234')
|
||||
expect(json.buildDate).toBe('2026-06-14T12:00:00Z')
|
||||
expect(json.baseUrl).toBe(BASE_URL)
|
||||
it('passes the CLI arguments straight through to the manifest', () => {
|
||||
expect(buildManifest()).toMatchObject({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
version: VERSION,
|
||||
commit: 'abc1234',
|
||||
buildDate: '2026-06-14T12:00:00Z',
|
||||
baseUrl: BASE_URL,
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the compat window from package.json', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.compat).toEqual(FIXTURE_COMPAT)
|
||||
it('carries the compat window through from cli/package.json', () => {
|
||||
expect(buildManifest().compat).toEqual({ minDify: PKG_ENV.minDify, maxDify: PKG_ENV.maxDify })
|
||||
})
|
||||
|
||||
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(Object.keys(json.targets).sort()).toEqual([
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
'linux-arm64',
|
||||
'linux-x64',
|
||||
'windows-x64',
|
||||
])
|
||||
expect(json.targets['linux-x64'].asset).toBe(`difyctl-v${VERSION}-linux-x64`)
|
||||
expect(json.targets['windows-x64'].asset).toBe(`difyctl-v${VERSION}-windows-x64.exe`)
|
||||
expect(json.targets['linux-x64'].sha256).toMatch(/^\d{64}$/)
|
||||
})
|
||||
|
||||
it('renders each target on a single line (installer greps it)', () => {
|
||||
const { stdout } = buildManifest()
|
||||
expect(stdout).toMatch(/^ {4}"linux-x64": \{ "asset": ".*", "sha256": ".*" \}/m)
|
||||
it('includes every target the release config declares', () => {
|
||||
expect(Object.keys(buildManifest().targets).sort()).toEqual([...TARGET_IDS].sort())
|
||||
})
|
||||
|
||||
it('rejects a version that does not match the channel form', () => {
|
||||
const { code } = buildManifest('0.1.0-rc.1')
|
||||
expect(code).not.toBe(0)
|
||||
expect(() => main(manifestArgs('0.1.0-rc.1'))).toThrow('does not match the edge channel form')
|
||||
})
|
||||
|
||||
it('dies when a target sha is missing from the checksums file', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
|
||||
const file = join(dir, `difyctl-v${VERSION}-checksums.txt`)
|
||||
writeFileSync(file, `${'0'.repeat(64)} difyctl-v${VERSION}-linux-x64\n`) // only 1 of 5
|
||||
const r = run([
|
||||
'manifest',
|
||||
'--channel',
|
||||
'edge',
|
||||
'--version',
|
||||
VERSION,
|
||||
'--commit',
|
||||
'abc1234',
|
||||
'--build-date',
|
||||
'2026-06-14T12:00:00Z',
|
||||
'--base-url',
|
||||
BASE_URL,
|
||||
'--checksums',
|
||||
file,
|
||||
])
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(() => main(manifestArgs(VERSION, TARGET_IDS.slice(0, 1)))).toThrow('no sha256 for')
|
||||
})
|
||||
|
||||
it('rejects a malformed dropped-value argument (no silent misparse)', () => {
|
||||
// --version has no value; --commit must NOT be swallowed as the version
|
||||
const r = run([
|
||||
'manifest',
|
||||
'--channel',
|
||||
'edge',
|
||||
'--version',
|
||||
'--commit',
|
||||
'abc1234',
|
||||
'--build-date',
|
||||
'2026-06-14T12:00:00Z',
|
||||
'--base-url',
|
||||
'https://x',
|
||||
'--checksums',
|
||||
'/nonexistent',
|
||||
])
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(() =>
|
||||
main([
|
||||
'manifest',
|
||||
'--channel',
|
||||
'edge',
|
||||
'--version',
|
||||
'--commit',
|
||||
'abc1234',
|
||||
'--build-date',
|
||||
'2026-06-14T12:00:00Z',
|
||||
'--base-url',
|
||||
'https://x',
|
||||
'--checksums',
|
||||
'/nonexistent',
|
||||
]),
|
||||
).toThrow('malformed argument')
|
||||
})
|
||||
|
||||
it('rejects an unknown subcommand', () => {
|
||||
expect(() => main(['bogus'])).toThrow('unknown subcommand')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- index ----
|
||||
describe('release-r2-edge index', () => {
|
||||
const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
|
||||
|
||||
function runIndex(
|
||||
currentContent: string | null,
|
||||
build: Record<string, string>,
|
||||
existingDirs?: string[],
|
||||
) {
|
||||
let currentArg = '-'
|
||||
if (currentContent !== null) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-index-'))
|
||||
currentArg = join(dir, 'index.json')
|
||||
writeFileSync(currentArg, currentContent)
|
||||
}
|
||||
const extra: string[] = []
|
||||
if (existingDirs !== undefined) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-existing-'))
|
||||
const f = join(dir, 'existing.txt')
|
||||
writeFileSync(f, `${existingDirs.join('\n')}\n`)
|
||||
extra.push('--existing-dirs', f)
|
||||
}
|
||||
const r = spawnSync(
|
||||
'node',
|
||||
[
|
||||
SCRIPT,
|
||||
function indexArgs(currentContent: string | null, existingDirs?: string[]): string[] {
|
||||
let currentArg = '-'
|
||||
if (currentContent !== null) {
|
||||
currentArg = join(mkdtempSync(join(tmpdir(), 'difyctl-index-')), 'index.json')
|
||||
writeFileSync(currentArg, currentContent)
|
||||
}
|
||||
const extra: string[] = []
|
||||
if (existingDirs !== undefined) {
|
||||
const f = join(mkdtempSync(join(tmpdir(), 'difyctl-existing-')), 'existing.txt')
|
||||
writeFileSync(f, `${existingDirs.join('\n')}\n`)
|
||||
extra.push('--existing-dirs', f)
|
||||
}
|
||||
return [
|
||||
'index',
|
||||
'--current',
|
||||
currentArg,
|
||||
'--channel',
|
||||
'edge',
|
||||
'--version',
|
||||
build.version,
|
||||
B1.version,
|
||||
'--commit',
|
||||
build.commit,
|
||||
B1.commit,
|
||||
'--build-date',
|
||||
build.buildDate,
|
||||
B1.buildDate,
|
||||
...extra,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
return {
|
||||
code: r.status ?? 1,
|
||||
index: (r.status === 0 ? JSON.parse(r.stdout) : null) as IndexJson,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
|
||||
const B2 = { version: '0.1.0-edge.bbbbbbb', commit: 'bbbbbbb', buildDate: '2026-06-14T10:00:00Z' }
|
||||
const buildIndex = (currentContent: string | null, existingDirs?: string[]) =>
|
||||
JSON.parse(main(indexArgs(currentContent, existingDirs))) as IndexJson
|
||||
|
||||
describe('release-r2-edge index', () => {
|
||||
it('creates a fresh index from a missing current (arg "-")', () => {
|
||||
const { index } = runIndex(null, B1)
|
||||
expect(index.schema).toBe(1)
|
||||
expect(index.channel).toBe('edge')
|
||||
expect(index.builds).toHaveLength(1)
|
||||
expect(index.builds[0]).toMatchObject({
|
||||
version: B1.version,
|
||||
commit: B1.commit,
|
||||
dir: B1.version,
|
||||
})
|
||||
it.each([
|
||||
['a missing current file (arg "-")', null],
|
||||
['an empty current file (first publish, curl wrote nothing)', ''],
|
||||
['a "-"-content current file (curl 404 fallback)', '-\n'],
|
||||
])('treats %s as a fresh ledger', (_label, content) => {
|
||||
const index = buildIndex(content)
|
||||
expect(index).toMatchObject({ schema: 1, channel: 'edge' })
|
||||
expect(index.builds).toEqual([{ ...B1, dir: B1.version }])
|
||||
})
|
||||
|
||||
it('treats an empty current file as fresh (first publish, curl wrote nothing)', () => {
|
||||
const { code, index } = runIndex('', B1)
|
||||
expect(code).toBe(0)
|
||||
expect(index.builds).toHaveLength(1)
|
||||
it('dies on a current index that is not valid JSON', () => {
|
||||
expect(() => main(indexArgs('{not json'))).toThrow('not valid JSON')
|
||||
})
|
||||
|
||||
it('treats a "-"-content current file as fresh (curl 404 fallback)', () => {
|
||||
const { code, index } = runIndex('-\n', B1)
|
||||
expect(code).toBe(0)
|
||||
expect(index.builds).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prepends the new build (publish order; newest at [0])', () => {
|
||||
const current = JSON.stringify({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
builds: [
|
||||
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
|
||||
],
|
||||
})
|
||||
const { index } = runIndex(current, B2)
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
|
||||
})
|
||||
|
||||
it('dedups a re-cut of the same version (no duplicate, moves to top)', () => {
|
||||
const current = JSON.stringify({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
builds: [
|
||||
{ version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version },
|
||||
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
|
||||
],
|
||||
})
|
||||
const { index } = runIndex(current, B1) // re-cut B1
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B1.version, B2.version])
|
||||
})
|
||||
|
||||
it('reconciles to surviving binary dirs (drops a build whose binary expired)', () => {
|
||||
const current = JSON.stringify({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
builds: [
|
||||
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
|
||||
],
|
||||
})
|
||||
// B1's binary is gone (not in existing); the new B2 is always kept.
|
||||
const { index } = runIndex(current, B2, [B2.version])
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B2.version])
|
||||
})
|
||||
|
||||
it('keeps the new build even when it is absent from the existing-dirs list', () => {
|
||||
const { index } = runIndex(null, B1, []) // empty survivors, fresh ledger
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B1.version])
|
||||
})
|
||||
|
||||
it('does not reconcile when no --existing-dirs is given (list unavailable)', () => {
|
||||
const current = JSON.stringify({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
builds: [
|
||||
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
|
||||
],
|
||||
})
|
||||
const { index } = runIndex(current, B2) // no existing-dirs → keep all
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
|
||||
})
|
||||
|
||||
it('dies on a non-empty current file that is not valid JSON', () => {
|
||||
const { code } = runIndex('{not json', B1)
|
||||
expect(code).not.toBe(0)
|
||||
it('reads the existing-dirs file to reconcile the ledger', () => {
|
||||
const older = {
|
||||
version: '0.1.0-edge.bbbbbbb',
|
||||
commit: 'bbbbbbb',
|
||||
buildDate: '2026-06-14T08:00:00Z',
|
||||
dir: '0.1.0-edge.bbbbbbb',
|
||||
}
|
||||
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [older] })
|
||||
expect(buildIndex(current, []).builds).toEqual([{ ...B1, dir: B1.version }])
|
||||
expect(buildIndex(current, [older.dir]).builds).toEqual([{ ...B1, dir: B1.version }, older])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-r2-publish.sh', import.meta.url))
|
||||
|
||||
// Stub `aws` + `curl` + `node` as shell functions that just log action verbs to
|
||||
// $ORDER_LOG, then run the publish `main` and assert the order of operations.
|
||||
function runPublish(): { code: number; order: string[]; stderr: string } {
|
||||
const stub = [
|
||||
'ORDER_LOG="$(mktemp)"',
|
||||
'aws() {',
|
||||
' case "$*" in',
|
||||
' *"list-objects-v2"*) echo list-survivors >>"$ORDER_LOG" ;;',
|
||||
' *" cp "*"/index.json"*) echo put-index >>"$ORDER_LOG" ;;',
|
||||
' *" cp "*"/manifest.json"*) echo put-manifest >>"$ORDER_LOG" ;;',
|
||||
' *" sync "*) echo sync-binaries >>"$ORDER_LOG" ;;',
|
||||
' *"head-object"*) echo head-verify >>"$ORDER_LOG" ;;',
|
||||
' *) : ;;',
|
||||
' esac',
|
||||
'}',
|
||||
'curl() { echo "{}"; }',
|
||||
'node() {',
|
||||
' case "$*" in',
|
||||
' *release-naming.mjs*targets*)',
|
||||
" printf 'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n' ;;",
|
||||
" *release-naming.mjs*' asset '*) printf 'difyctl-vX\\n' ;;",
|
||||
" *release-r2-edge.mjs*' index '*) echo '{}' ;;",
|
||||
" *release-r2-edge.mjs*' manifest '*) echo '{}' ;;",
|
||||
' *) : ;;',
|
||||
' esac',
|
||||
'}',
|
||||
].join('\n')
|
||||
const program = [
|
||||
stub,
|
||||
`. "${SCRIPT}"`,
|
||||
'publish_main edge 0.1.0-edge.2fd7b82',
|
||||
'cat "$ORDER_LOG"',
|
||||
].join('\n')
|
||||
// bash, NOT sh: the script uses BASH_SOURCE + process substitution.
|
||||
const r = spawnSync('bash', ['-c', program], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
RELEASE_PUBLISH_LIB: '1',
|
||||
DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example',
|
||||
DIFYCTL_R2_BUCKET: 'cli-dev',
|
||||
DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev',
|
||||
DIST_DIR: '/tmp',
|
||||
},
|
||||
})
|
||||
return {
|
||||
code: r.status ?? 1,
|
||||
order: (r.stdout ?? '').trim().split('\n').filter(Boolean),
|
||||
stderr: r.stderr ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
describe('release-r2-publish order', () => {
|
||||
it('uploads binaries, verifies, lists survivors, then index, then manifest', () => {
|
||||
const { code, order } = runPublish()
|
||||
expect(code).toBe(0)
|
||||
expect(order.indexOf('sync-binaries')).toBeLessThan(order.indexOf('head-verify'))
|
||||
expect(order.indexOf('head-verify')).toBeLessThan(order.indexOf('list-survivors'))
|
||||
expect(order.indexOf('list-survivors')).toBeLessThan(order.indexOf('put-index'))
|
||||
expect(order.indexOf('put-index')).toBeLessThan(order.indexOf('put-manifest'))
|
||||
// pointer is never pruned here — deletion is owned by the R2 lifecycle rule
|
||||
expect(order).not.toContain('prune')
|
||||
})
|
||||
|
||||
it('exits non-zero when no targets resolve (head-verify safety gate)', () => {
|
||||
const stub = [
|
||||
'aws() { :; }',
|
||||
'curl() { echo "{}"; }',
|
||||
'node() { case "$*" in *release-naming.mjs*targets*) : ;; *) echo "{}" ;; esac; }',
|
||||
].join('\n')
|
||||
const program = [stub, `. "${SCRIPT}"`, 'publish_main edge 0.1.0-edge.2fd7b82'].join('\n')
|
||||
const r = spawnSync('bash', ['-c', program], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
RELEASE_PUBLISH_LIB: '1',
|
||||
DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example',
|
||||
DIFYCTL_R2_BUCKET: 'cli-dev',
|
||||
DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev',
|
||||
DIST_DIR: '/tmp',
|
||||
},
|
||||
})
|
||||
expect(r.status).not.toBe(0)
|
||||
})
|
||||
})
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-agent"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -581,7 +581,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -157,6 +157,9 @@ 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.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -380,7 +380,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.1
|
||||
image: langgenius/dify-web:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -386,7 +386,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.1
|
||||
image: langgenius/dify-web:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
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.1
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
|
||||
@@ -188,7 +188,7 @@ export const get = oc
|
||||
path: '/agent/invite-options',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetAgentInviteOptionsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetAgentInviteOptionsQuery.optional() }))
|
||||
.output(zGetAgentInviteOptionsResponse)
|
||||
|
||||
export const inviteOptions = {
|
||||
@@ -1491,7 +1491,7 @@ export const get36 = oc
|
||||
path: '/agent',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetAgentQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetAgentQuery.optional() }))
|
||||
.output(zGetAgentResponse)
|
||||
|
||||
export const post21 = oc
|
||||
|
||||
@@ -2573,10 +2573,10 @@ export const zSelectInputConfig = z.object({
|
||||
})
|
||||
|
||||
export const zFormInputConfig = z.discriminatedUnion('type', [
|
||||
zParagraphInputConfig,
|
||||
zSelectInputConfig,
|
||||
zFileInputConfig,
|
||||
zFileListInputConfig,
|
||||
zParagraphInputConfig.extend({ type: z.literal('paragraph') }),
|
||||
zSelectInputConfig.extend({ type: z.literal('select') }),
|
||||
zFileInputConfig.extend({ type: z.literal('file') }),
|
||||
zFileListInputConfig.extend({ type: z.literal('file-list') }),
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -2893,10 +2893,7 @@ export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void()
|
||||
|
||||
export const zPostAgentByAgentIdAudioToTextBody = z.object({
|
||||
draft_type: z.enum(['debug_build', 'draft']).optional().default('draft'),
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
export const zPostAgentByAgentIdAudioToTextPath = z.object({
|
||||
@@ -3146,10 +3143,7 @@ export const zGetAgentByAgentIdConfigSkillsQuery = z.object({
|
||||
export const zGetAgentByAgentIdConfigSkillsResponse = zAgentConfigSkillListResponse
|
||||
|
||||
export const zPostAgentByAgentIdConfigSkillsUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
export const zPostAgentByAgentIdConfigSkillsUploadPath = z.object({
|
||||
@@ -3525,10 +3519,7 @@ export const zPostAgentByAgentIdSandboxFilesUploadPath = z.object({
|
||||
export const zPostAgentByAgentIdSandboxFilesUploadResponse = zSandboxUploadResponse
|
||||
|
||||
export const zPostAgentByAgentIdSkillsUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
export const zPostAgentByAgentIdSkillsUploadPath = z.object({
|
||||
|
||||
@@ -12,7 +12,7 @@ export const get = oc
|
||||
path: '/all-workspaces',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetAllWorkspacesQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetAllWorkspacesQuery.optional() }))
|
||||
.output(zGetAllWorkspacesResponse)
|
||||
|
||||
export const allWorkspaces = {
|
||||
|
||||
@@ -567,7 +567,7 @@ export const get2 = oc
|
||||
summary: 'Return the lightweight app cards needed by the Explore home page',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetAppsRecentQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetAppsRecentQuery.optional() }))
|
||||
.output(zGetAppsRecentResponse)
|
||||
|
||||
export const recent = {
|
||||
@@ -586,7 +586,7 @@ export const get3 = oc
|
||||
path: '/apps/starred',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetAppsStarredQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetAppsStarredQuery.optional() }))
|
||||
.output(zGetAppsStarredResponse)
|
||||
|
||||
export const starred = {
|
||||
@@ -4951,7 +4951,7 @@ export const get94 = oc
|
||||
summary: 'Get app list',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetAppsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetAppsQuery.optional() }))
|
||||
.output(zGetAppsResponse)
|
||||
|
||||
/**
|
||||
|
||||
@@ -3964,10 +3964,10 @@ export const zSelectInputConfig = z.object({
|
||||
})
|
||||
|
||||
export const zFormInputConfig = z.discriminatedUnion('type', [
|
||||
zParagraphInputConfig,
|
||||
zSelectInputConfig,
|
||||
zFileInputConfig,
|
||||
zFileListInputConfig,
|
||||
zParagraphInputConfig.extend({ type: z.literal('paragraph') }),
|
||||
zSelectInputConfig.extend({ type: z.literal('select') }),
|
||||
zFileInputConfig.extend({ type: z.literal('file') }),
|
||||
zFileListInputConfig.extend({ type: z.literal('file-list') }),
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -4777,10 +4777,7 @@ export const zGetAppsByAppIdAgentConfigSkillsQuery = z.object({
|
||||
export const zGetAppsByAppIdAgentConfigSkillsResponse = zAgentConfigSkillListResponse
|
||||
|
||||
export const zPostAppsByAppIdAgentConfigSkillsUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
export const zPostAppsByAppIdAgentConfigSkillsUploadPath = z.object({
|
||||
@@ -5011,10 +5008,7 @@ export const zGetAppsByAppIdAgentLogsQuery = z.object({
|
||||
export const zGetAppsByAppIdAgentLogsResponse = zAgentLogResponse
|
||||
|
||||
export const zPostAppsByAppIdAgentSkillsUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({
|
||||
@@ -5225,10 +5219,7 @@ export const zPostAppsByAppIdApiEnablePath = z.object({
|
||||
export const zPostAppsByAppIdApiEnableResponse = zAppDetail
|
||||
|
||||
export const zPostAppsByAppIdAudioToTextBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
export const zPostAppsByAppIdAudioToTextPath = z.object({
|
||||
|
||||
@@ -397,7 +397,7 @@ export const get6 = oc
|
||||
path: '/datasets/external-knowledge-api',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetDatasetsExternalKnowledgeApiQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetDatasetsExternalKnowledgeApiQuery.optional() }))
|
||||
.output(zGetDatasetsExternalKnowledgeApiResponse)
|
||||
|
||||
/**
|
||||
@@ -505,7 +505,7 @@ export const get8 = oc
|
||||
path: '/datasets/process-rule',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetDatasetsProcessRuleQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetDatasetsProcessRuleQuery.optional() }))
|
||||
.output(zGetDatasetsProcessRuleResponse)
|
||||
|
||||
export const processRule = {
|
||||
@@ -1186,11 +1186,11 @@ export const segments = {
|
||||
* Returns:
|
||||
* - total_segments: Total number of segments in the document
|
||||
* - summary_status: Dictionary with status counts
|
||||
* - completed: Number of summaries completed
|
||||
* - generating: Number of summaries being generated
|
||||
* - error: Number of summaries with errors
|
||||
* - not_started: Number of segments without summary records
|
||||
* - timeout: Number of summaries that timed out
|
||||
* - completed: Number of summaries completed
|
||||
* - generating: Number of summaries being generated
|
||||
* - error: Number of summaries with errors
|
||||
* - not_started: Number of segments without summary records
|
||||
* - timeout: Number of summaries that timed out
|
||||
* - summaries: List of summary records with status and content preview
|
||||
*/
|
||||
export const get22 = oc
|
||||
@@ -1777,7 +1777,7 @@ export const get36 = oc
|
||||
path: '/datasets',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetDatasetsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetDatasetsQuery.optional() }))
|
||||
.output(zGetDatasetsResponse)
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ export const get = oc
|
||||
path: '/explore/apps/learn-dify',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetExploreAppsLearnDifyQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetExploreAppsLearnDifyQuery.optional() }))
|
||||
.output(zGetExploreAppsLearnDifyResponse)
|
||||
|
||||
export const learnDify = {
|
||||
@@ -51,7 +51,7 @@ export const get3 = oc
|
||||
path: '/explore/apps',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetExploreAppsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetExploreAppsQuery.optional() }))
|
||||
.output(zGetExploreAppsResponse)
|
||||
|
||||
export const apps = {
|
||||
@@ -72,7 +72,7 @@ export const get4 = oc
|
||||
summary: 'Get banner list',
|
||||
tags: ['default'],
|
||||
})
|
||||
.input(z.object({ query: zGetExploreBannersQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetExploreBannersQuery.optional() }))
|
||||
.output(zGetExploreBannersResponse)
|
||||
|
||||
export const banners = {
|
||||
|
||||
@@ -64,10 +64,7 @@ export const zGetFilesSupportTypeResponse = zAllowedExtensionsResponse
|
||||
export const zGetFilesUploadResponse = zUploadConfig
|
||||
|
||||
export const zPostFilesUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
source: z.enum(['datasets']).optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -35,10 +35,10 @@ export const get = oc
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* "inputs": {
|
||||
* "content": "User input content"
|
||||
* },
|
||||
* "action": "Approve"
|
||||
* "inputs": {
|
||||
* "content": "User input content"
|
||||
* },
|
||||
* "action": "Approve"
|
||||
* }
|
||||
*/
|
||||
export const post = oc
|
||||
|
||||
@@ -559,7 +559,7 @@ export const get8 = oc
|
||||
path: '/installed-apps',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetInstalledAppsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetInstalledAppsQuery.optional() }))
|
||||
.output(zGetInstalledAppsResponse)
|
||||
|
||||
export const post12 = oc
|
||||
|
||||
@@ -145,8 +145,7 @@ export const zTextToAudioPayload = z.object({
|
||||
* AudioBinaryResponse
|
||||
*/
|
||||
export const zAudioBinaryResponse = z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
(value) => value instanceof Blob || value instanceof File,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -476,10 +475,10 @@ export const zSelectInputConfig = z.object({
|
||||
})
|
||||
|
||||
export const zFormInputConfig = z.discriminatedUnion('type', [
|
||||
zParagraphInputConfig,
|
||||
zSelectInputConfig,
|
||||
zFileInputConfig,
|
||||
zFileListInputConfig,
|
||||
zParagraphInputConfig.extend({ type: z.literal('paragraph') }),
|
||||
zSelectInputConfig.extend({ type: z.literal('select') }),
|
||||
zFileInputConfig.extend({ type: z.literal('file') }),
|
||||
zFileListInputConfig.extend({ type: z.literal('file-list') }),
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -246,7 +246,7 @@ export const get2 = oc
|
||||
path: '/rag/pipeline/templates',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetRagPipelineTemplatesQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetRagPipelineTemplatesQuery.optional() }))
|
||||
.output(zGetRagPipelineTemplatesResponse)
|
||||
|
||||
export const templates2 = {
|
||||
@@ -338,7 +338,7 @@ export const get5 = oc
|
||||
path: '/rag/pipelines/recommended-plugins',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetRagPipelinesRecommendedPluginsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetRagPipelinesRecommendedPluginsQuery.optional() }))
|
||||
.output(zGetRagPipelinesRecommendedPluginsResponse)
|
||||
|
||||
export const recommendedPlugins = {
|
||||
|
||||
@@ -7,12 +7,12 @@ import { zGetSetupResponse, zPostSetupBody, zPostSetupResponse } from './zod.gen
|
||||
/**
|
||||
* Get system setup status.
|
||||
*
|
||||
* NOTE: This endpoint is unauthenticated by design.
|
||||
* NOTE: This endpoint is unauthenticated by design.
|
||||
*
|
||||
* During first-time bootstrap there is no admin account yet, so frontend initialization must be
|
||||
* able to query setup progress before any login flow exists.
|
||||
* During first-time bootstrap there is no admin account yet, so frontend initialization must be
|
||||
* able to query setup progress before any login flow exists.
|
||||
*
|
||||
* Only bootstrap-safe status information should be returned by this endpoint.
|
||||
* Only bootstrap-safe status information should be returned by this endpoint.
|
||||
*
|
||||
*/
|
||||
export const get = oc
|
||||
@@ -30,9 +30,9 @@ export const get = oc
|
||||
/**
|
||||
* Initialize system setup with admin account.
|
||||
*
|
||||
* NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
|
||||
* Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
|
||||
* and init-password validation rather than user session authentication.
|
||||
* NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
|
||||
* Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
|
||||
* and init-password validation rather than user session authentication.
|
||||
*
|
||||
*/
|
||||
export const post = oc
|
||||
|
||||
@@ -50,7 +50,7 @@ export const get = oc
|
||||
path: '/tags',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetTagsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetTagsQuery.optional() }))
|
||||
.output(zGetTagsResponse)
|
||||
|
||||
export const post = oc
|
||||
|
||||
@@ -116,8 +116,7 @@ export const zTextToSpeechRequest = z.object({
|
||||
* AudioBinaryResponse
|
||||
*/
|
||||
export const zAudioBinaryResponse = z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
(value) => value instanceof Blob || value instanceof File,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -460,10 +459,7 @@ export const zGetTrialAppsByAppIdDatasetsQuery = z.object({
|
||||
export const zGetTrialAppsByAppIdDatasetsResponse = zTrialDatasetListResponse
|
||||
|
||||
export const zPostTrialAppsByAppIdFilesUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
source: z.enum(['datasets']).optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -665,7 +665,7 @@ export const get6 = oc
|
||||
summary: 'List customized snippets with pagination and search',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetWorkspacesCurrentCustomizedSnippetsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetWorkspacesCurrentCustomizedSnippetsQuery.optional() }))
|
||||
.output(zGetWorkspacesCurrentCustomizedSnippetsResponse)
|
||||
|
||||
/**
|
||||
@@ -1517,7 +1517,7 @@ export const get17 = oc
|
||||
path: '/workspaces/current/model-providers',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetWorkspacesCurrentModelProvidersQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetWorkspacesCurrentModelProvidersQuery.optional() }))
|
||||
.output(zGetWorkspacesCurrentModelProvidersResponse)
|
||||
|
||||
export const modelProviders = {
|
||||
@@ -1773,7 +1773,7 @@ export const get25 = oc
|
||||
path: '/workspaces/current/plugin/list',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetWorkspacesCurrentPluginListQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetWorkspacesCurrentPluginListQuery.optional() }))
|
||||
.output(zGetWorkspacesCurrentPluginListResponse)
|
||||
|
||||
export const list2 = {
|
||||
@@ -1960,7 +1960,7 @@ export const get31 = oc
|
||||
path: '/workspaces/current/plugin/tasks',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetWorkspacesCurrentPluginTasksQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetWorkspacesCurrentPluginTasksQuery.optional() }))
|
||||
.output(zGetWorkspacesCurrentPluginTasksResponse)
|
||||
|
||||
export const tasks = {
|
||||
@@ -3554,9 +3554,7 @@ export const get74 = oc
|
||||
path: '/workspaces/current/tool-provider/workflow/get',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() }).optional(),
|
||||
)
|
||||
.input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() }))
|
||||
.output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse)
|
||||
|
||||
export const get75 = {
|
||||
@@ -3616,7 +3614,7 @@ export const get77 = oc
|
||||
path: '/workspaces/current/tool-providers',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetWorkspacesCurrentToolProvidersQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetWorkspacesCurrentToolProvidersQuery.optional() }))
|
||||
.output(zGetWorkspacesCurrentToolProvidersResponse)
|
||||
|
||||
export const toolProviders = {
|
||||
|
||||
@@ -170,7 +170,15 @@ export type MemberInvitePayload = {
|
||||
|
||||
export type MemberInviteResponse = {
|
||||
invitation_results: Array<
|
||||
MemberInviteSuccessResponse | MemberInviteAlreadyMemberResponse | MemberInviteFailedResponse
|
||||
| ({
|
||||
status: 'success'
|
||||
} & MemberInviteSuccessResponse)
|
||||
| ({
|
||||
status: 'already_member'
|
||||
} & MemberInviteAlreadyMemberResponse)
|
||||
| ({
|
||||
status: 'failed'
|
||||
} & MemberInviteFailedResponse)
|
||||
>
|
||||
result: 'success'
|
||||
tenant_id: string
|
||||
|
||||
@@ -246,8 +246,7 @@ export const zWorkspacePermissionResponse = z.object({
|
||||
* BinaryFileResponse
|
||||
*/
|
||||
export const zBinaryFileResponse = z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
(value) => value instanceof Blob || value instanceof File,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -874,9 +873,21 @@ export const zMemberInviteFailedResponse = z.object({
|
||||
export const zMemberInviteResponse = z.object({
|
||||
invitation_results: z.array(
|
||||
z.union([
|
||||
zMemberInviteSuccessResponse,
|
||||
zMemberInviteAlreadyMemberResponse,
|
||||
zMemberInviteFailedResponse,
|
||||
z
|
||||
.object({
|
||||
status: z.literal('success'),
|
||||
})
|
||||
.and(zMemberInviteSuccessResponse),
|
||||
z
|
||||
.object({
|
||||
status: z.literal('already_member'),
|
||||
})
|
||||
.and(zMemberInviteAlreadyMemberResponse),
|
||||
z
|
||||
.object({
|
||||
status: z.literal('failed'),
|
||||
})
|
||||
.and(zMemberInviteFailedResponse),
|
||||
]),
|
||||
),
|
||||
result: z.literal('success'),
|
||||
@@ -3082,7 +3093,7 @@ export const zToolParameter = z.object({
|
||||
* ApiToolBundle
|
||||
*
|
||||
* This class is used to store the schema information of an api based tool.
|
||||
* such as the url, the method, the parameters, etc.
|
||||
* such as the url, the method, the parameters, etc.
|
||||
*/
|
||||
export const zApiToolBundle = z.object({
|
||||
author: z.string(),
|
||||
@@ -4216,10 +4227,7 @@ export const zPostWorkspacesCurrentPluginUploadGithubBody = zParserGithubUpload
|
||||
export const zPostWorkspacesCurrentPluginUploadGithubResponse = zPluginDecodeResponse
|
||||
|
||||
export const zPostWorkspacesCurrentPluginUploadPkgBody = z.object({
|
||||
pkg: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
pkg: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -5247,10 +5255,7 @@ export const zPostWorkspacesCustomConfigBody = zWorkspaceCustomConfigPayload
|
||||
export const zPostWorkspacesCustomConfigResponse = zWorkspaceTenantResultResponse
|
||||
|
||||
export const zPostWorkspacesCustomConfigWebappLogoUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,7 +139,7 @@ export const get3 = oc
|
||||
path: '/account/sessions',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ query: zGetAccountSessionsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetAccountSessionsQuery.optional() }))
|
||||
.output(zGetAccountSessionsResponse)
|
||||
|
||||
export const sessions = {
|
||||
@@ -468,7 +468,7 @@ export const get13 = oc
|
||||
path: '/permitted-external-apps',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ query: zGetPermittedExternalAppsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetPermittedExternalAppsQuery.optional() }))
|
||||
.output(zGetPermittedExternalAppsResponse)
|
||||
|
||||
export const permittedExternalApps = {
|
||||
|
||||
@@ -248,7 +248,7 @@ export const get2 = oc
|
||||
summary: 'List App Feedbacks',
|
||||
tags: ['Feedback'],
|
||||
})
|
||||
.input(z.object({ query: zGetAppFeedbacksQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetAppFeedbacksQuery.optional() }))
|
||||
.output(zGetAppFeedbacksResponse)
|
||||
|
||||
export const feedbacks = {
|
||||
@@ -382,7 +382,7 @@ export const get4 = oc
|
||||
summary: 'List Annotations',
|
||||
tags: ['Annotations'],
|
||||
})
|
||||
.input(z.object({ query: zGetAppsAnnotationsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetAppsAnnotationsQuery.optional() }))
|
||||
.output(zGetAppsAnnotationsResponse)
|
||||
|
||||
/**
|
||||
@@ -669,7 +669,7 @@ export const get6 = oc
|
||||
summary: 'List Conversations',
|
||||
tags: ['Conversations'],
|
||||
})
|
||||
.input(z.object({ query: zGetConversationsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetConversationsQuery.optional() }))
|
||||
.output(zGetConversationsResponse)
|
||||
|
||||
export const conversations = {
|
||||
@@ -1951,7 +1951,7 @@ export const get20 = oc
|
||||
summary: 'List Knowledge Bases',
|
||||
tags: ['Knowledge Bases'],
|
||||
})
|
||||
.input(z.object({ query: zGetDatasetsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetDatasetsQuery.optional() }))
|
||||
.output(zGetDatasetsResponse)
|
||||
|
||||
/**
|
||||
@@ -2367,7 +2367,7 @@ export const get31 = oc
|
||||
summary: 'List Workflow Logs',
|
||||
tags: ['Chatflows', 'Workflows'],
|
||||
})
|
||||
.input(z.object({ query: zGetWorkflowsLogsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetWorkflowsLogsQuery.optional() }))
|
||||
.output(zGetWorkflowsLogsResponse)
|
||||
|
||||
export const logs = {
|
||||
|
||||
@@ -113,8 +113,7 @@ export const zAppMetaResponse = z.object({
|
||||
* AudioBinaryResponse
|
||||
*/
|
||||
export const zAudioBinaryResponse = z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
(value) => value instanceof Blob || value instanceof File,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -128,8 +127,7 @@ export const zAudioTranscriptResponse = z.object({
|
||||
* BinaryFileResponse
|
||||
*/
|
||||
export const zBinaryFileResponse = z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
(value) => value instanceof Blob || value instanceof File,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1941,10 +1939,10 @@ export const zParagraphInputConfig = z.object({
|
||||
})
|
||||
|
||||
export const zFormInputConfig = z.discriminatedUnion('type', [
|
||||
zParagraphInputConfig,
|
||||
zSelectInputConfig,
|
||||
zFileInputConfig,
|
||||
zFileListInputConfig,
|
||||
zParagraphInputConfig.extend({ type: z.literal('paragraph') }),
|
||||
zSelectInputConfig.extend({ type: z.literal('select') }),
|
||||
zFileInputConfig.extend({ type: z.literal('file') }),
|
||||
zFileListInputConfig.extend({ type: z.literal('file-list') }),
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -2458,10 +2456,7 @@ export const zPutAppsAnnotationsByAnnotationIdPath = z.object({
|
||||
export const zPutAppsAnnotationsByAnnotationIdResponse = zAnnotation
|
||||
|
||||
export const zPostAudioToTextBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
user: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -2600,10 +2595,7 @@ export const zPostDatasetsBody = zDatasetCreatePayload
|
||||
export const zPostDatasetsResponse = zDatasetDetailResponse
|
||||
|
||||
export const zPostDatasetsPipelineFileUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -2682,10 +2674,7 @@ export const zPatchDatasetsByDatasetIdResponse = zDatasetDetailWithPartialMember
|
||||
|
||||
export const zPostDatasetsByDatasetIdDocumentCreateByFileBody = z.object({
|
||||
data: z.string().optional(),
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
export const zPostDatasetsByDatasetIdDocumentCreateByFilePath = z.object({
|
||||
@@ -2710,10 +2699,7 @@ export const zPostDatasetsByDatasetIdDocumentCreateByTextResponse = zDocumentAnd
|
||||
|
||||
export const zPostDatasetsByDatasetIdDocumentCreateByFile2Body = z.object({
|
||||
data: z.string().optional(),
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
export const zPostDatasetsByDatasetIdDocumentCreateByFile2Path = z.object({
|
||||
@@ -2764,8 +2750,7 @@ export const zPostDatasetsByDatasetIdDocumentsDownloadZipPath = z.object({
|
||||
* ZIP archive containing the requested documents.
|
||||
*/
|
||||
export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
(value) => value instanceof Blob || value instanceof File,
|
||||
)
|
||||
|
||||
export const zPostDatasetsByDatasetIdDocumentsMetadataBody = zMetadataOperationData
|
||||
@@ -2828,12 +2813,7 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = zDocumentDet
|
||||
|
||||
export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdBody = z.object({
|
||||
data: z.string().optional(),
|
||||
file: z
|
||||
.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
)
|
||||
.optional(),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File).optional(),
|
||||
})
|
||||
|
||||
export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({
|
||||
@@ -2993,12 +2973,7 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh
|
||||
|
||||
export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileBody = z.object({
|
||||
data: z.string().optional(),
|
||||
file: z
|
||||
.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
)
|
||||
.optional(),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File).optional(),
|
||||
})
|
||||
|
||||
export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFilePath = z.object({
|
||||
@@ -3027,12 +3002,7 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponse =
|
||||
|
||||
export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Body = z.object({
|
||||
data: z.string().optional(),
|
||||
file: z
|
||||
.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
)
|
||||
.optional(),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File).optional(),
|
||||
})
|
||||
|
||||
export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Path = z.object({
|
||||
@@ -3203,10 +3173,7 @@ export const zGetEndUsersByEndUserIdPath = z.object({
|
||||
export const zGetEndUsersByEndUserIdResponse = zEndUserDetail
|
||||
|
||||
export const zPostFilesUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
),
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
user: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -3228,8 +3195,7 @@ export const zGetFilesByFileIdPreviewQuery = z.object({
|
||||
* Returns the raw file content. The `Content-Type` header is set to the file's MIME type. If `as_attachment` is `true`, the file is returned as a download with `Content-Disposition: attachment`.
|
||||
*/
|
||||
export const zGetFilesByFileIdPreviewResponse = z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
(value) => value instanceof Blob || value instanceof File,
|
||||
)
|
||||
|
||||
export const zGetFormHumanInputByFormTokenPath = z.object({
|
||||
@@ -3314,8 +3280,7 @@ export const zPostTextToAudioBody = zTextToAudioPayloadWithUser
|
||||
* Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; otherwise the provider output is returned directly.
|
||||
*/
|
||||
export const zPostTextToAudioResponse = z.custom<Blob | File>(
|
||||
(value) =>
|
||||
Blob.prototype.isPrototypeOf(Object(value)) || File.prototype.isPrototypeOf(Object(value)),
|
||||
(value) => value instanceof Blob || value instanceof File,
|
||||
)
|
||||
|
||||
export const zGetWorkflowByTaskIdEventsPath = z.object({
|
||||
|
||||
@@ -295,7 +295,7 @@ export const get = oc
|
||||
path: '/conversations',
|
||||
tags: ['web'],
|
||||
})
|
||||
.input(z.object({ query: zGetConversationsQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetConversationsQuery.optional() }))
|
||||
.output(zGetConversationsResponse)
|
||||
|
||||
export const conversations = {
|
||||
@@ -350,23 +350,23 @@ export const emailCodeLogin = {
|
||||
* multiple file types with automatic validation and storage.
|
||||
*
|
||||
* Args:
|
||||
* app_model: The associated application model
|
||||
* end_user: The end user uploading the file
|
||||
* app_model: The associated application model
|
||||
* end_user: The end user uploading the file
|
||||
*
|
||||
* Form Parameters:
|
||||
* file: The file to upload (required)
|
||||
* source: Optional source type (datasets or None)
|
||||
* file: The file to upload (required)
|
||||
* source: Optional source type (datasets or None)
|
||||
*
|
||||
* Returns:
|
||||
* dict: File information including ID, URL, and metadata
|
||||
* int: HTTP status code 201 for success
|
||||
* dict: File information including ID, URL, and metadata
|
||||
* int: HTTP status code 201 for success
|
||||
*
|
||||
* Raises:
|
||||
* NoFileUploadedError: No file provided in request
|
||||
* TooManyFilesError: Multiple files provided (only one allowed)
|
||||
* FilenameNotExistsError: File has no filename
|
||||
* FileTooLargeError: File exceeds size limit
|
||||
* UnsupportedFileTypeError: File type not supported
|
||||
* NoFileUploadedError: No file provided in request
|
||||
* TooManyFilesError: Multiple files provided (only one allowed)
|
||||
* FilenameNotExistsError: File has no filename
|
||||
* FileTooLargeError: File exceeds size limit
|
||||
* UnsupportedFileTypeError: File type not supported
|
||||
*/
|
||||
export const post9 = oc
|
||||
.route({
|
||||
@@ -501,10 +501,10 @@ export const get2 = oc
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* "inputs": {
|
||||
* "content": "User input content"
|
||||
* },
|
||||
* "action": "Approve"
|
||||
* "inputs": {
|
||||
* "content": "User input content"
|
||||
* },
|
||||
* "action": "Approve"
|
||||
* }
|
||||
*/
|
||||
export const post14 = oc
|
||||
@@ -575,7 +575,7 @@ export const get3 = oc
|
||||
path: '/login/status',
|
||||
tags: ['web'],
|
||||
})
|
||||
.input(z.object({ query: zGetLoginStatusQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetLoginStatusQuery.optional() }))
|
||||
.output(zGetLoginStatusResponse)
|
||||
|
||||
export const status = {
|
||||
@@ -771,7 +771,7 @@ export const get9 = oc
|
||||
path: '/passport',
|
||||
tags: ['web'],
|
||||
})
|
||||
.input(z.object({ query: zGetPassportQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetPassportQuery.optional() }))
|
||||
.output(zGetPassportResponse)
|
||||
|
||||
export const passport = {
|
||||
@@ -786,20 +786,20 @@ export const passport = {
|
||||
* to the platform storage for use in web applications.
|
||||
*
|
||||
* Args:
|
||||
* app_model: The associated application model
|
||||
* end_user: The end user making the request
|
||||
* app_model: The associated application model
|
||||
* end_user: The end user making the request
|
||||
*
|
||||
* JSON Parameters:
|
||||
* url: The remote URL to download the file from (required)
|
||||
* url: The remote URL to download the file from (required)
|
||||
*
|
||||
* Returns:
|
||||
* dict: File information including ID, signed URL, and metadata
|
||||
* int: HTTP status code 201 for success
|
||||
* dict: File information including ID, signed URL, and metadata
|
||||
* int: HTTP status code 201 for success
|
||||
*
|
||||
* Raises:
|
||||
* RemoteFileUploadError: Failed to fetch file from remote URL
|
||||
* FileTooLargeError: File exceeds size limit
|
||||
* UnsupportedFileTypeError: File type not supported
|
||||
* RemoteFileUploadError: Failed to fetch file from remote URL
|
||||
* FileTooLargeError: File exceeds size limit
|
||||
* UnsupportedFileTypeError: File type not supported
|
||||
*/
|
||||
export const post19 = oc
|
||||
.route({
|
||||
@@ -828,15 +828,15 @@ export const upload2 = {
|
||||
* including content type and content length.
|
||||
*
|
||||
* Args:
|
||||
* app_model: The associated application model
|
||||
* end_user: The end user making the request
|
||||
* url: URL-encoded path to the remote file
|
||||
* app_model: The associated application model
|
||||
* end_user: The end user making the request
|
||||
* url: URL-encoded path to the remote file
|
||||
*
|
||||
* Returns:
|
||||
* dict: Remote file information including type and length
|
||||
* dict: Remote file information including type and length
|
||||
*
|
||||
* Raises:
|
||||
* HTTPException: If the remote file cannot be accessed
|
||||
* HTTPException: If the remote file cannot be accessed
|
||||
*/
|
||||
export const get10 = oc
|
||||
.route({
|
||||
@@ -893,7 +893,7 @@ export const get11 = oc
|
||||
path: '/saved-messages',
|
||||
tags: ['web'],
|
||||
})
|
||||
.input(z.object({ query: zGetSavedMessagesQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetSavedMessagesQuery.optional() }))
|
||||
.output(zGetSavedMessagesResponse)
|
||||
|
||||
/**
|
||||
@@ -998,7 +998,7 @@ export const get14 = oc
|
||||
path: '/webapp/access-mode',
|
||||
tags: ['web'],
|
||||
})
|
||||
.input(z.object({ query: zGetWebappAccessModeQuery.optional() }).optional())
|
||||
.input(z.object({ query: zGetWebappAccessModeQuery.optional() }))
|
||||
.output(zGetWebappAccessModeResponse)
|
||||
|
||||
export const accessMode = {
|
||||
|
||||
@@ -700,10 +700,10 @@ export const zParagraphInputConfig = z.object({
|
||||
})
|
||||
|
||||
export const zFormInputConfig = z.discriminatedUnion('type', [
|
||||
zParagraphInputConfig,
|
||||
zSelectInputConfig,
|
||||
zFileInputConfig,
|
||||
zFileListInputConfig,
|
||||
zParagraphInputConfig.extend({ type: z.literal('paragraph') }),
|
||||
zSelectInputConfig.extend({ type: z.literal('select') }),
|
||||
zFileInputConfig.extend({ type: z.literal('file') }),
|
||||
zFileListInputConfig.extend({ type: z.literal('file-list') }),
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { $, defineConfig } from '@hey-api/openapi-ts'
|
||||
import ts from 'typescript'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
@@ -39,8 +40,16 @@ type ApiSpec = {
|
||||
}
|
||||
|
||||
type ApiJob = {
|
||||
clean?: boolean
|
||||
document: SwaggerDocument
|
||||
outputPath: string
|
||||
plugins?: UserConfig['plugins']
|
||||
source?: {
|
||||
callback: () => void
|
||||
enabled: true
|
||||
path: null
|
||||
serialize: () => string
|
||||
}
|
||||
}
|
||||
|
||||
type ApiContractOperation = {
|
||||
@@ -405,6 +414,24 @@ const writeConsoleRouterContract = (segments: string[]) => {
|
||||
fs.writeFileSync(routerPath, consoleRouterContractContent(segments))
|
||||
}
|
||||
|
||||
const createConsoleContractEntryJob = (document: SwaggerDocument, segments: string[]): ApiJob => {
|
||||
return {
|
||||
clean: false,
|
||||
document,
|
||||
outputPath: 'generated/api/console',
|
||||
plugins: [],
|
||||
source: {
|
||||
callback: () => {
|
||||
writeConsoleContractEntry(segments)
|
||||
writeConsoleRouterContract(segments)
|
||||
},
|
||||
enabled: true,
|
||||
path: null,
|
||||
serialize: () => '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const splitConsoleDocument = (document: SwaggerDocument) => {
|
||||
const pathsBySegment = new Map<string, Record<string, Record<string, unknown>>>()
|
||||
|
||||
@@ -423,12 +450,7 @@ const splitConsoleDocument = (document: SwaggerDocument) => {
|
||||
}),
|
||||
)
|
||||
|
||||
// An empty plugin list falls back to the generator defaults, so write the root entries
|
||||
// directly instead of creating a no-op job that would also emit a client and SDK.
|
||||
writeConsoleContractEntry(segments)
|
||||
writeConsoleRouterContract(segments)
|
||||
|
||||
return jobs
|
||||
return [...jobs, createConsoleContractEntryJob(document, segments)]
|
||||
}
|
||||
|
||||
const createApiJobs = (spec: ApiSpec): ApiJob[] => {
|
||||
@@ -456,13 +478,15 @@ const createApiConfig = (job: ApiJob): UserConfig => ({
|
||||
file: false,
|
||||
},
|
||||
output: {
|
||||
...(job.clean === undefined ? {} : { clean: job.clean }),
|
||||
entryFile: false,
|
||||
fileName: {
|
||||
suffix: '.gen',
|
||||
},
|
||||
path: job.outputPath,
|
||||
...(job.source ? { source: job.source } : {}),
|
||||
},
|
||||
plugins: [
|
||||
plugins: job.plugins ?? [
|
||||
{
|
||||
comments: false,
|
||||
name: '@hey-api/typescript',
|
||||
@@ -477,10 +501,8 @@ const createApiConfig = (job: ApiJob): UserConfig => ({
|
||||
.call(
|
||||
$.func((predicate) => {
|
||||
const value = $.id('value')
|
||||
const objectValue = $('Object').call(value)
|
||||
// `instanceof` is not exposed by the generator's AST DSL.
|
||||
const isBlob = $('Blob').attr('prototype').attr('isPrototypeOf').call(objectValue)
|
||||
const isFile = $('File').attr('prototype').attr('isPrototypeOf').call(objectValue)
|
||||
const isBlob = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('Blob'))
|
||||
const isFile = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('File'))
|
||||
predicate.param('value')
|
||||
predicate.do($.return($.binary(isBlob, '||', isFile)))
|
||||
}),
|
||||
|
||||
Generated
+258
-50
@@ -46,8 +46,8 @@ catalogs:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
'@hey-api/openapi-ts':
|
||||
specifier: 0.0.0-next-20260724070045
|
||||
version: 0.0.0-next-20260724070045
|
||||
specifier: 0.98.2
|
||||
version: 0.98.2
|
||||
'@hono/node-server':
|
||||
specifier: 2.0.12
|
||||
version: 2.0.12
|
||||
@@ -903,7 +903,7 @@ importers:
|
||||
version: link:../tsconfig
|
||||
'@hey-api/openapi-ts':
|
||||
specifier: 'catalog:'
|
||||
version: 0.0.0-next-20260724070045(magicast@0.5.3)
|
||||
version: 0.98.2(@typescript/typescript6@6.0.2)(magicast@0.5.3)
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 25.9.5
|
||||
@@ -1960,12 +1960,18 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '*'
|
||||
|
||||
'@emnapi/core@1.11.0':
|
||||
resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==}
|
||||
|
||||
'@emnapi/core@1.11.2':
|
||||
resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
|
||||
'@emnapi/runtime@1.11.0':
|
||||
resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==}
|
||||
|
||||
'@emnapi/runtime@1.11.1':
|
||||
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
|
||||
|
||||
@@ -2285,29 +2291,27 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 16 || ^19.0.0-rc'
|
||||
|
||||
'@hey-api/codegen-cli@0.0.0-next-20260724070045':
|
||||
resolution: {integrity: sha512-bn0ncYXv9PhqsA3u7WJuxTO8uCF/JNUn2SjmNhZkvXf/8E0iAXGkEiLZX2Ed+3cWwiEIQjrP+knrud2b16MBCQ==}
|
||||
'@hey-api/codegen-core@0.9.0':
|
||||
resolution: {integrity: sha512-OK9/R8WuujwgvnrDIPnEiIf6WnfUOi3GaEr6kIngqoI5FUQwYbeDKHE/frTVUl2A76ZQPCrMknHtPx6Gqtwf8Q==}
|
||||
engines: {node: '>=22.18.0'}
|
||||
|
||||
'@hey-api/codegen-core@0.0.0-next-20260724070045':
|
||||
resolution: {integrity: sha512-nzNZVgqrAY86CmjslqKMZM1C3Ai/zaB3lzBvOv5U3NJUO5ZOqU0LDPwMoJGQK/Dpw4TaKPWJ/arjIUS03281iw==}
|
||||
'@hey-api/json-schema-ref-parser@1.4.3':
|
||||
resolution: {integrity: sha512-UzGSDzh3QUhrnwl4atnHc2YqDO6KemYVEOwl1Ynowm/tcr0XlpdHOpyWr5UaWIJfiXTXdYRIC9k2Yxm19pcPzQ==}
|
||||
engines: {node: '>=22.18.0'}
|
||||
|
||||
'@hey-api/json-schema-ref-parser@0.0.0-next-20260724070045':
|
||||
resolution: {integrity: sha512-Od4kqGNUUhh2ZvyGcbijvH/1FNttvFvbwybuGc8mXSTdRVHz8GNMoIoXb3mkugcvuL+myqVFqBpXl/yGNtuF9A==}
|
||||
engines: {node: '>=22.18.0'}
|
||||
|
||||
'@hey-api/openapi-ts@0.0.0-next-20260724070045':
|
||||
resolution: {integrity: sha512-EZFQ2Q20sB5f39mSur1lacSDZdzhhpV/ZWyDzSLLY0fK9GhVbkltW5/y26w+sUA5aC1C2fa78jy+fgLGMra8JA==}
|
||||
'@hey-api/openapi-ts@0.98.2':
|
||||
resolution: {integrity: sha512-2nVJXH8tpFPGTBOhxyjEd1Jw0hsRqJqeTQW3kltAjVdSU4YWxeu97x5sgNOmsbsfeg6Dqz7Wfzs26walBOuswA==}
|
||||
engines: {node: '>=22.18.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc'
|
||||
|
||||
'@hey-api/shared@0.0.0-next-20260724070045':
|
||||
resolution: {integrity: sha512-1l5mWzZ09PLDXtdYWcHweefWqq+6VtXVZjeA3m+YFs+zw9lWcdYIs0LPFMKSqv2sXgnj2c1m5vK1urnDvmmqNQ==}
|
||||
'@hey-api/shared@0.4.8':
|
||||
resolution: {integrity: sha512-29Pg2FB0UW20pplYgcfiQn1hQYpbZ9D2gdDJc7nDK3xh3pvHOTGP0v3R2ueFpFnw9GN1SRhIdhiVuAYWMDimjA==}
|
||||
engines: {node: '>=22.18.0'}
|
||||
|
||||
'@hey-api/spec-types@0.0.0-next-20260724070045':
|
||||
resolution: {integrity: sha512-HkYEwGQlj9X8+KYVFJYAEo4cKsMaZ8z/rF95v8ppIIleZJA+hRaFDZNZPMwCNPXtraTELcc1BUZ4f4rJaBvpLw==}
|
||||
'@hey-api/spec-types@0.2.0':
|
||||
resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==}
|
||||
|
||||
'@hey-api/types@0.1.4':
|
||||
resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==}
|
||||
@@ -3421,104 +3425,207 @@ packages:
|
||||
'@oxc-project/types@0.141.0':
|
||||
resolution: {integrity: sha512-S4as7z0j0xQkXcJlyY5ehntwK8/wRkQb9Cyqw+J/N2rkWGQGK0SxD6X6DhQTc7qsxVTBxXbxZtBJh3mr3PtIzQ==}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.21.3':
|
||||
resolution: {integrity: sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
|
||||
resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.21.3':
|
||||
resolution: {integrity: sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.24.2':
|
||||
resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.21.3':
|
||||
resolution: {integrity: sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.24.2':
|
||||
resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.21.3':
|
||||
resolution: {integrity: sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.24.2':
|
||||
resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.21.3':
|
||||
resolution: {integrity: sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.24.2':
|
||||
resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3':
|
||||
resolution: {integrity: sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
|
||||
resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.21.3':
|
||||
resolution: {integrity: sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
|
||||
resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.21.3':
|
||||
resolution: {integrity: sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.21.3':
|
||||
resolution: {integrity: sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.24.2':
|
||||
resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.21.3':
|
||||
resolution: {integrity: sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.21.3':
|
||||
resolution: {integrity: sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.21.3':
|
||||
resolution: {integrity: sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
|
||||
resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.21.3':
|
||||
resolution: {integrity: sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.21.3':
|
||||
resolution: {integrity: sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.21.3':
|
||||
resolution: {integrity: sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.24.2':
|
||||
resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.21.3':
|
||||
resolution: {integrity: sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.24.2':
|
||||
resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.21.3':
|
||||
resolution: {integrity: sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.24.2':
|
||||
resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.21.3':
|
||||
resolution: {integrity: sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
|
||||
resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.21.3':
|
||||
resolution: {integrity: sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
|
||||
resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==}
|
||||
cpu: [x64]
|
||||
@@ -5546,9 +5653,6 @@ packages:
|
||||
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
citty@0.2.2:
|
||||
resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
@@ -5596,7 +5700,6 @@ packages:
|
||||
|
||||
color-support@1.1.3:
|
||||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||
hasBin: true
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
@@ -6533,7 +6636,6 @@ packages:
|
||||
|
||||
giget@3.3.0:
|
||||
resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==}
|
||||
hasBin: true
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
@@ -6914,8 +7016,8 @@ packages:
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
js-yaml@5.2.0:
|
||||
resolution: {integrity: sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==}
|
||||
js-yaml@4.3.0:
|
||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@5.2.2:
|
||||
@@ -7812,6 +7914,9 @@ packages:
|
||||
resolution: {integrity: sha512-h6QFWd6lBMfjESqgQ27GjzrSDb0qbznp7VDQqp2zvgsrWut4vcchyMIzOVXvGQ2GMZgKw9RWrFNWv9WqGL0p7Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
oxc-resolver@11.21.3:
|
||||
resolution: {integrity: sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==}
|
||||
|
||||
oxc-resolver@11.24.2:
|
||||
resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==}
|
||||
|
||||
@@ -8372,6 +8477,11 @@ packages:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.2:
|
||||
resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -9392,7 +9502,7 @@ snapshots:
|
||||
|
||||
'@amplitude/rrweb-snapshot@2.1.0':
|
||||
dependencies:
|
||||
postcss: 8.5.23
|
||||
postcss: 8.5.19
|
||||
|
||||
'@amplitude/rrweb-types@2.0.0-alpha.40': {}
|
||||
|
||||
@@ -9768,6 +9878,12 @@ snapshots:
|
||||
'@iconify/utils': 3.1.3
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@emnapi/core@1.11.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.2
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/core@1.11.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.2
|
||||
@@ -9780,6 +9896,11 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -10099,11 +10220,7 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
'@hey-api/codegen-cli@0.0.0-next-20260724070045':
|
||||
dependencies:
|
||||
citty: 0.2.2
|
||||
|
||||
'@hey-api/codegen-core@0.0.0-next-20260724070045(magicast@0.5.3)':
|
||||
'@hey-api/codegen-core@0.9.0(magicast@0.5.3)':
|
||||
dependencies:
|
||||
'@hey-api/types': 0.1.4
|
||||
ansi-colors: 4.1.3
|
||||
@@ -10112,41 +10229,42 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
'@hey-api/json-schema-ref-parser@0.0.0-next-20260724070045':
|
||||
'@hey-api/json-schema-ref-parser@1.4.3':
|
||||
dependencies:
|
||||
'@jsdevtools/ono': 7.1.3
|
||||
'@types/json-schema': 7.0.15
|
||||
js-yaml: 5.2.0
|
||||
js-yaml: 4.3.0
|
||||
|
||||
'@hey-api/openapi-ts@0.0.0-next-20260724070045(magicast@0.5.3)':
|
||||
'@hey-api/openapi-ts@0.98.2(@typescript/typescript6@6.0.2)(magicast@0.5.3)':
|
||||
dependencies:
|
||||
'@hey-api/codegen-cli': 0.0.0-next-20260724070045
|
||||
'@hey-api/codegen-core': 0.0.0-next-20260724070045(magicast@0.5.3)
|
||||
'@hey-api/json-schema-ref-parser': 0.0.0-next-20260724070045
|
||||
'@hey-api/shared': 0.0.0-next-20260724070045(magicast@0.5.3)
|
||||
'@hey-api/spec-types': 0.0.0-next-20260724070045
|
||||
'@hey-api/codegen-core': 0.9.0(magicast@0.5.3)
|
||||
'@hey-api/json-schema-ref-parser': 1.4.3
|
||||
'@hey-api/shared': 0.4.8(magicast@0.5.3)
|
||||
'@hey-api/spec-types': 0.2.0
|
||||
'@hey-api/types': 0.1.4
|
||||
'@lukeed/ms': 2.0.2
|
||||
ansi-colors: 4.1.3
|
||||
color-support: 1.1.3
|
||||
commander: 15.0.0
|
||||
get-tsconfig: 4.14.0
|
||||
typescript: '@typescript/typescript6@6.0.2'
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
'@hey-api/shared@0.0.0-next-20260724070045(magicast@0.5.3)':
|
||||
'@hey-api/shared@0.4.8(magicast@0.5.3)':
|
||||
dependencies:
|
||||
'@hey-api/codegen-core': 0.0.0-next-20260724070045(magicast@0.5.3)
|
||||
'@hey-api/json-schema-ref-parser': 0.0.0-next-20260724070045
|
||||
'@hey-api/spec-types': 0.0.0-next-20260724070045
|
||||
'@hey-api/codegen-core': 0.9.0(magicast@0.5.3)
|
||||
'@hey-api/json-schema-ref-parser': 1.4.3
|
||||
'@hey-api/spec-types': 0.2.0
|
||||
'@hey-api/types': 0.1.4
|
||||
ansi-colors: 4.1.3
|
||||
cross-spawn: 7.0.6
|
||||
open: 11.0.0
|
||||
semver: 7.8.5
|
||||
semver: 7.8.2
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
'@hey-api/spec-types@0.0.0-next-20260724070045':
|
||||
'@hey-api/spec-types@0.2.0':
|
||||
dependencies:
|
||||
'@hey-api/types': 0.1.4
|
||||
|
||||
@@ -10807,6 +10925,13 @@ snapshots:
|
||||
'@napi-rs/keyring-win32-ia32-msvc': 1.3.0
|
||||
'@napi-rs/keyring-win32-x64-msvc': 1.3.0
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.0
|
||||
'@emnapi/runtime': 1.11.0
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.2
|
||||
@@ -11074,54 +11199,109 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.141.0': {}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.21.3':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.0
|
||||
'@emnapi/runtime': 1.11.0
|
||||
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.24.2':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.2
|
||||
@@ -11129,9 +11309,15 @@ snapshots:
|
||||
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.21.3':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
|
||||
optional: true
|
||||
|
||||
@@ -13198,8 +13384,6 @@ snapshots:
|
||||
|
||||
ci-info@4.4.0: {}
|
||||
|
||||
citty@0.2.2: {}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
@@ -14744,7 +14928,7 @@ snapshots:
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
js-yaml@5.2.0:
|
||||
js-yaml@4.3.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
@@ -15808,6 +15992,28 @@ snapshots:
|
||||
'@oxc-parser/binding-win32-ia32-msvc': 0.140.0
|
||||
'@oxc-parser/binding-win32-x64-msvc': 0.140.0
|
||||
|
||||
oxc-resolver@11.21.3:
|
||||
optionalDependencies:
|
||||
'@oxc-resolver/binding-android-arm-eabi': 11.21.3
|
||||
'@oxc-resolver/binding-android-arm64': 11.21.3
|
||||
'@oxc-resolver/binding-darwin-arm64': 11.21.3
|
||||
'@oxc-resolver/binding-darwin-x64': 11.21.3
|
||||
'@oxc-resolver/binding-freebsd-x64': 11.21.3
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.3
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf': 11.21.3
|
||||
'@oxc-resolver/binding-linux-arm64-gnu': 11.21.3
|
||||
'@oxc-resolver/binding-linux-arm64-musl': 11.21.3
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu': 11.21.3
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu': 11.21.3
|
||||
'@oxc-resolver/binding-linux-riscv64-musl': 11.21.3
|
||||
'@oxc-resolver/binding-linux-s390x-gnu': 11.21.3
|
||||
'@oxc-resolver/binding-linux-x64-gnu': 11.21.3
|
||||
'@oxc-resolver/binding-linux-x64-musl': 11.21.3
|
||||
'@oxc-resolver/binding-openharmony-arm64': 11.21.3
|
||||
'@oxc-resolver/binding-wasm32-wasi': 11.21.3
|
||||
'@oxc-resolver/binding-win32-arm64-msvc': 11.21.3
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.21.3
|
||||
|
||||
oxc-resolver@11.24.2:
|
||||
optionalDependencies:
|
||||
'@oxc-resolver/binding-android-arm-eabi': 11.24.2
|
||||
@@ -16627,6 +16833,8 @@ snapshots:
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.2: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
server-only@0.0.1: {}
|
||||
@@ -16821,7 +17029,7 @@ snapshots:
|
||||
jsonc-parser: 3.3.1
|
||||
open: 10.2.0
|
||||
oxc-parser: 0.127.0
|
||||
oxc-resolver: 11.24.2
|
||||
oxc-resolver: 11.21.3
|
||||
recast: 0.23.12
|
||||
semver: 7.8.5
|
||||
use-sync-external-store: 1.6.0(react@19.2.8)
|
||||
@@ -16848,7 +17056,7 @@ snapshots:
|
||||
jsonc-parser: 3.3.1
|
||||
open: 10.2.0
|
||||
oxc-parser: 0.127.0
|
||||
oxc-resolver: 11.24.2
|
||||
oxc-resolver: 11.21.3
|
||||
recast: 0.23.12
|
||||
semver: 7.8.5
|
||||
use-sync-external-store: 1.6.0(react@19.2.8)
|
||||
@@ -17835,7 +18043,7 @@ time:
|
||||
'@floating-ui/react@0.27.20': '2026-07-11T08:41:33.223Z'
|
||||
'@formatjs/intl-localematcher@0.8.13': '2026-07-16T04:15:53.223Z'
|
||||
'@heroicons/react@2.2.0': '2024-11-18T15:33:27.317Z'
|
||||
'@hey-api/openapi-ts@0.0.0-next-20260724070045': '2026-07-24T07:02:37.380Z'
|
||||
'@hey-api/openapi-ts@0.98.2': '2026-06-08T05:37:17.524Z'
|
||||
'@hono/node-server@2.0.12': '2026-07-26T05:02:01.126Z'
|
||||
'@iconify-json/heroicons@1.2.3': '2025-09-20T05:33:02.364Z'
|
||||
'@iconify-json/ri@1.2.10': '2026-02-10T08:41:46.666Z'
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ catalog:
|
||||
'@floating-ui/react': 0.27.20
|
||||
'@formatjs/intl-localematcher': 0.8.13
|
||||
'@heroicons/react': 2.2.0
|
||||
'@hey-api/openapi-ts': 0.0.0-next-20260724070045
|
||||
'@hey-api/openapi-ts': 0.98.2
|
||||
'@hono/node-server': 2.0.12
|
||||
'@iconify-json/heroicons': 1.2.3
|
||||
'@iconify-json/ri': 1.2.10
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
@@ -68,16 +67,11 @@ describe('ArchivedLogsNotice', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show an accessible notice for paid workspace managers', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('should show notice for paid workspace managers', () => {
|
||||
renderNotice()
|
||||
|
||||
const notice = screen.getByRole('status')
|
||||
expect(notice).toHaveAttribute('aria-live', 'polite')
|
||||
expect(notice).toHaveAttribute('aria-atomic', 'true')
|
||||
expect(within(notice).getByText('appLog.archives.notice.description')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(notice).getByRole('button', { name: 'appLog.archives.notice.action' }))
|
||||
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
|
||||
expect(setShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
})
|
||||
|
||||
@@ -1,37 +1,9 @@
|
||||
import type { QueryParam } from '../index'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from '../filter'
|
||||
|
||||
let mockAnnotationsCountLoading = false
|
||||
let mockAnnotationsCountData: { count: number } | null = { count: 10 }
|
||||
const mockRuntime = vi.hoisted(() => ({
|
||||
deploymentEdition: 'CLOUD',
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
isFetchedPlanInfo: true,
|
||||
planType: 'professional',
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => ({
|
||||
enableBilling: mockRuntime.enableBilling,
|
||||
isFetchedPlan: mockRuntime.isFetchedPlan,
|
||||
isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo,
|
||||
plan: { type: mockRuntime.planType },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useAnnotationsCount: () => ({
|
||||
@@ -40,43 +12,28 @@ vi.mock('@/service/use-log', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chip', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
default: function MockChip({
|
||||
items,
|
||||
value,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
onSelect: (item: { value: string; name: string }) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const currentItem = items.find((item) => item.value === value) ?? items[0]
|
||||
return (
|
||||
<div>
|
||||
<div>{currentItem?.name}</div>
|
||||
<button aria-label={`open-options-${items[0]?.value}`} onClick={() => setIsOpen(true)}>
|
||||
open-chip
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul aria-label={`options-${items[0]?.value}`}>
|
||||
{items.map((item) => (
|
||||
<li key={item.value}>{item.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
|
||||
<button onClick={onClear}>clear-chip</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/components/base/chip', () => ({
|
||||
default: ({
|
||||
items,
|
||||
value,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
onSelect: (item: { value: string; name: string }) => void
|
||||
onClear: () => void
|
||||
}) => {
|
||||
const currentItem = items.find((item) => item.value === value) ?? items[0]
|
||||
return (
|
||||
<div>
|
||||
<div>{currentItem?.name}</div>
|
||||
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
|
||||
<button onClick={onClear}>clear-chip</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/sort', () => ({
|
||||
default: ({ onSelect }: { onSelect: (value: string) => void }) => (
|
||||
@@ -102,11 +59,6 @@ describe('Filter', () => {
|
||||
vi.clearAllMocks()
|
||||
mockAnnotationsCountLoading = false
|
||||
mockAnnotationsCountData = { count: 10 }
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.enableBilling = true
|
||||
mockRuntime.isFetchedPlan = true
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
mockRuntime.planType = 'professional'
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -172,77 +124,6 @@ describe('Filter', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should only show supported periods for Cloud sandbox workspaces', () => {
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/),
|
||||
])
|
||||
})
|
||||
|
||||
it('should only show supported periods while the Cloud plan is pending', () => {
|
||||
mockRuntime.isFetchedPlan = false
|
||||
mockRuntime.isFetchedPlanInfo = false
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/),
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep all periods when Cloud billing is known to be disabled', () => {
|
||||
mockRuntime.enableBilling = false
|
||||
mockRuntime.isFetchedPlan = false
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('should keep all periods for sandbox workspaces outside Cloud', () => {
|
||||
mockRuntime.deploymentEdition = 'COMMUNITY'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('should reset the Cloud sandbox period to today when cleared', () => {
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getAllByText('clear-chip')[0]!)
|
||||
|
||||
expect(mockSetQueryParams).toHaveBeenCalledWith({
|
||||
...defaultQueryParams,
|
||||
period: '1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update keyword when typing in search input', () => {
|
||||
render(<Filter {...defaultProps} />)
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { CloudSandboxPlanState } from '../cloud-sandbox-retention'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Logs from '../index'
|
||||
@@ -14,27 +11,11 @@ vi.mock('@/context/i18n', () => ({
|
||||
const mockReplace = vi.fn()
|
||||
const mockUseChatConversations = vi.fn()
|
||||
const mockUseCompletionConversations = vi.fn()
|
||||
const mockPlanState = vi.hoisted(() => ({
|
||||
value: 'unrestricted' as CloudSandboxPlanState,
|
||||
}))
|
||||
const mockDebouncedPeriod = vi.hoisted(() => ({
|
||||
value: null as string | null,
|
||||
}))
|
||||
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
vi.mock('ahooks', async () => {
|
||||
return {
|
||||
useDebounce: <T,>(value: T) => {
|
||||
if (
|
||||
mockDebouncedPeriod.value === null ||
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('period' in value)
|
||||
)
|
||||
return value
|
||||
|
||||
return { ...value, period: mockDebouncedPeriod.value }
|
||||
},
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -52,19 +33,28 @@ vi.mock('@/next/navigation', () => ({
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useChatConversations: (...args: unknown[]) => mockUseChatConversations(...args),
|
||||
useCompletionConversations: (...args: unknown[]) => mockUseCompletionConversations(...args),
|
||||
useAnnotationsCount: () => ({
|
||||
data: { count: 0 },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../cloud-sandbox-retention', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../cloud-sandbox-retention')>()
|
||||
return {
|
||||
...actual,
|
||||
useCloudSandboxPlanStatus: () => mockPlanState.value,
|
||||
}
|
||||
})
|
||||
vi.mock('../filter', () => ({
|
||||
TIME_PERIOD_MAPPING: {
|
||||
2: { value: 7 },
|
||||
9: { value: 0 },
|
||||
},
|
||||
default: ({ setQueryParams }: { setQueryParams: (next: Record<string, string>) => void }) => (
|
||||
<button
|
||||
onClick={() =>
|
||||
setQueryParams({
|
||||
period: '9',
|
||||
annotation_status: 'all',
|
||||
sort_by: '-created_at',
|
||||
keyword: 'hello',
|
||||
})
|
||||
}
|
||||
>
|
||||
filter-controls
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../list', () => ({
|
||||
default: ({ logs }: { logs: { total?: number } }) => (
|
||||
@@ -79,10 +69,6 @@ vi.mock('../empty-element', () => ({
|
||||
default: () => <div>empty-logs</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../retention-upgrade-notice', () => ({
|
||||
RetentionUpgradeNotice: () => <div>retention-upgrade-notice</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div>loading-logs</div>,
|
||||
}))
|
||||
@@ -99,8 +85,6 @@ describe('Logs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
mockPlanState.value = 'unrestricted'
|
||||
mockDebouncedPeriod.value = null
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
refetch: vi.fn(),
|
||||
@@ -133,7 +117,6 @@ describe('Logs', () => {
|
||||
expect(
|
||||
screen.getByRole('link', { name: /(?:^|\.)operation\.learnMore(?=$|:)/ }),
|
||||
).toHaveAttribute('href', 'https://docs.example.com/use-dify/monitor/logs')
|
||||
expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument()
|
||||
expect(screen.getByText('loading-logs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -183,101 +166,4 @@ describe('Logs', () => {
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false })
|
||||
})
|
||||
|
||||
it('should query the last 30 days when a Sandbox user selects the longest period', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPlanState.value = 'sandbox'
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: { total: 0 },
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<Logs
|
||||
appDetail={
|
||||
{
|
||||
id: 'app-sandbox-last-30-days',
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }))
|
||||
await user.click(await screen.findByText(/appLog\.filter\.period\.last30days/))
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /appLog\.filter\.period\.last30days/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use a valid period for the real Chip and request when a cached period settles to Sandbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
const appDetail = {
|
||||
id: 'app-period-transition',
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as any
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: { total: 0 },
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
const unrestrictedRender = render(<Logs appDetail={appDetail} />)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }))
|
||||
await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/))
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.not.objectContaining({
|
||||
start: expect.anything(),
|
||||
end: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
unrestrictedRender.unmount()
|
||||
|
||||
mockPlanState.value = 'pending'
|
||||
mockDebouncedPeriod.value = '9'
|
||||
const pendingRender = render(<Logs appDetail={appDetail} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /common\.operation\.clear appLog\.filter\.period\.today/,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
mockPlanState.value = 'sandbox'
|
||||
pendingRender.rerender(<Logs appDetail={appDetail} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { RetentionUpgradeNotice } from '../retention-upgrade-notice'
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/modal-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useModalContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContext = vi.mocked(useModalContext)
|
||||
|
||||
describe('RetentionUpgradeNotice', () => {
|
||||
const setShowPricingModal = vi.fn()
|
||||
|
||||
function mockProvider({
|
||||
enableBilling = true,
|
||||
isFetchedPlan = true,
|
||||
isFetchedPlanInfo = true,
|
||||
planType = Plan.sandbox,
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
isFetchedPlan?: boolean
|
||||
isFetchedPlanInfo?: boolean
|
||||
planType?: Plan
|
||||
} = {}) {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling,
|
||||
isFetchedPlan,
|
||||
isFetchedPlanInfo,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function renderNotice(deploymentEdition: DeploymentEdition = 'CLOUD') {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
})
|
||||
return render(<RetentionUpgradeNotice />, { wrapper })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProvider()
|
||||
mockUseModalContext.mockReturnValue({
|
||||
setShowPricingModal,
|
||||
} as unknown as ReturnType<typeof useModalContext>)
|
||||
})
|
||||
|
||||
it('should show accessible upgrade guidance for Cloud sandbox workspaces', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderNotice()
|
||||
|
||||
const notice = screen.getByRole('status')
|
||||
expect(notice).toHaveAttribute('aria-live', 'polite')
|
||||
expect(notice).toHaveAttribute('aria-atomic', 'true')
|
||||
expect(within(notice).getByText('appLog.retention.upgradeTip.description')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
within(notice).getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }),
|
||||
)
|
||||
expect(setShowPricingModal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'paid Cloud workspaces',
|
||||
provider: { planType: Plan.professional },
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
{
|
||||
name: 'self-hosted sandbox workspaces',
|
||||
provider: { planType: Plan.sandbox },
|
||||
deploymentEdition: 'COMMUNITY',
|
||||
},
|
||||
{
|
||||
name: 'workspaces without billing',
|
||||
provider: { enableBilling: false },
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
{
|
||||
name: 'workspaces before plan loading completes',
|
||||
provider: { isFetchedPlan: false, isFetchedPlanInfo: false },
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
] as const)('should not show guidance for $name', ({ provider, deploymentEdition }) => {
|
||||
mockProvider(provider)
|
||||
|
||||
renderNotice(deploymentEdition)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -32,27 +31,16 @@ export function ArchivedLogsNotice() {
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="relative mb-3 shrink-0 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-px bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-40"
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 i-ri-information-line size-4 shrink-0 text-util-colors-warning-warning-600"
|
||||
/>
|
||||
<div className="relative flex items-center gap-3 px-3 py-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-information-2-fill size-5 shrink-0 text-text-accent"
|
||||
/>
|
||||
<p className="min-w-0 flex-1 system-sm-semibold wrap-break-word text-text-primary">
|
||||
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="shrink-0"
|
||||
<div className="min-w-0 flex-1 system-xs-regular text-util-colors-warning-warning-700">
|
||||
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 system-xs-semibold text-util-colors-warning-warning-700 underline underline-offset-2 hover:text-text-primary"
|
||||
onClick={() =>
|
||||
setShowAccountSettingModal({
|
||||
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
@@ -60,7 +48,7 @@ export function ArchivedLogsNotice() {
|
||||
}
|
||||
>
|
||||
{t(($) => $['archives.notice.action'], { ns: 'appLog' })}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
|
||||
export const CLOUD_SANDBOX_TIME_PERIOD_KEYS = new Set(['1', '2', '3'])
|
||||
export const CLOUD_SANDBOX_CLEARED_TIME_PERIOD = '1'
|
||||
|
||||
const CLOUD_SANDBOX_LONGEST_TIME_PERIOD = '3'
|
||||
const CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION = {
|
||||
value: 30,
|
||||
name: 'last30days',
|
||||
} as const
|
||||
|
||||
export type CloudSandboxPlanState = 'pending' | 'sandbox' | 'unrestricted'
|
||||
|
||||
export function isLogTimePeriodRestricted(planState: CloudSandboxPlanState) {
|
||||
return planState !== 'unrestricted'
|
||||
}
|
||||
|
||||
export function resolveLogTimePeriod(period: string, planState: CloudSandboxPlanState) {
|
||||
if (!isLogTimePeriodRestricted(planState) || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(period))
|
||||
return period
|
||||
|
||||
return CLOUD_SANDBOX_CLEARED_TIME_PERIOD
|
||||
}
|
||||
|
||||
export function resolveLogTimePeriodOption<T extends { value: number; name: string }>(
|
||||
period: string,
|
||||
option: T,
|
||||
planState: CloudSandboxPlanState,
|
||||
) {
|
||||
if (isLogTimePeriodRestricted(planState) && period === CLOUD_SANDBOX_LONGEST_TIME_PERIOD)
|
||||
return CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION
|
||||
|
||||
return option
|
||||
}
|
||||
|
||||
export function useCloudSandboxPlanStatus(): CloudSandboxPlanState {
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { enableBilling, isFetchedPlan, isFetchedPlanInfo, plan } = useProviderContext()
|
||||
|
||||
if (deploymentEdition !== 'CLOUD') return 'unrestricted'
|
||||
if (!isFetchedPlanInfo) return 'pending'
|
||||
if (!enableBilling) return 'unrestricted'
|
||||
if (!isFetchedPlan) return 'pending'
|
||||
|
||||
return plan.type === Plan.sandbox ? 'sandbox' : 'unrestricted'
|
||||
}
|
||||
@@ -11,13 +11,6 @@ import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Sort from '@/app/components/base/sort'
|
||||
import { useAnnotationsCount } from '@/service/use-log'
|
||||
import {
|
||||
CLOUD_SANDBOX_CLEARED_TIME_PERIOD,
|
||||
CLOUD_SANDBOX_TIME_PERIOD_KEYS,
|
||||
isLogTimePeriodRestricted,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from './cloud-sandbox-retention'
|
||||
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
@@ -52,12 +45,6 @@ const Filter: FC<IFilterProps> = ({
|
||||
}: IFilterProps) => {
|
||||
const { data, isLoading } = useAnnotationsCount(appId)
|
||||
const { t } = useTranslation()
|
||||
const planState = useCloudSandboxPlanStatus()
|
||||
const isTimePeriodRestricted = isLogTimePeriodRestricted(planState)
|
||||
const timePeriodEntries = Object.entries(TIME_PERIOD_MAPPING)
|
||||
.filter(([key]) => !isTimePeriodRestricted || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(key))
|
||||
.map(([key, option]) => [key, resolveLogTimePeriodOption(key, option, planState)] as const)
|
||||
|
||||
if (isLoading || !data) return null
|
||||
return (
|
||||
<div className="mb-2 flex flex-row flex-wrap items-center gap-2">
|
||||
@@ -69,13 +56,8 @@ const Filter: FC<IFilterProps> = ({
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, period: item.value })
|
||||
}}
|
||||
onClear={() =>
|
||||
setQueryParams({
|
||||
...queryParams,
|
||||
period: isTimePeriodRestricted ? CLOUD_SANDBOX_CLEARED_TIME_PERIOD : '9',
|
||||
})
|
||||
}
|
||||
items={timePeriodEntries.map(([k, v]) => ({
|
||||
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
|
||||
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({
|
||||
value: k,
|
||||
name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }),
|
||||
}))}
|
||||
|
||||
@@ -15,15 +15,9 @@ import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import PageTitle from '../log-annotation/page-title'
|
||||
import {
|
||||
resolveLogTimePeriod,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from './cloud-sandbox-retention'
|
||||
import EmptyElement from './empty-element'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||
import List from './list'
|
||||
import { RetentionUpgradeNotice } from './retention-upgrade-notice'
|
||||
|
||||
type ILogsProps = {
|
||||
appDetail: App
|
||||
@@ -63,7 +57,6 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
return pageParam - 1
|
||||
}, [searchParams])
|
||||
const cachedState = logsStateCache.get(appDetail.id)
|
||||
const cloudSandboxPlanState = useCloudSandboxPlanStatus()
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>(
|
||||
cachedState?.queryParams ?? defaultQueryParams,
|
||||
)
|
||||
@@ -71,15 +64,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
() => cachedState?.currPage ?? getPageFromParams(),
|
||||
)
|
||||
const [limit, setLimit] = React.useState<number>(cachedState?.limit ?? APP_PAGE_LIMIT)
|
||||
const effectivePeriod = resolveLogTimePeriod(queryParams.period, cloudSandboxPlanState)
|
||||
const effectiveQueryParams = { ...queryParams, period: effectivePeriod }
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
const requestQueryParams = { ...debouncedQueryParams, period: effectivePeriod }
|
||||
const requestTimePeriod = resolveLogTimePeriodOption(
|
||||
requestQueryParams.period,
|
||||
TIME_PERIOD_MAPPING[requestQueryParams.period]!,
|
||||
cloudSandboxPlanState,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const pageFromParams = getPageFromParams()
|
||||
@@ -100,17 +85,17 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const query = {
|
||||
page: currPage + 1,
|
||||
limit,
|
||||
...(requestQueryParams.period !== '9'
|
||||
...(debouncedQueryParams.period !== '9'
|
||||
? {
|
||||
start: dayjs()
|
||||
.subtract(requestTimePeriod.value, 'day')
|
||||
.subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day')
|
||||
.startOf('day')
|
||||
.format('YYYY-MM-DD HH:mm'),
|
||||
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
}
|
||||
: {}),
|
||||
...(isChatMode ? { sort_by: requestQueryParams.sort_by } : {}),
|
||||
...omit(requestQueryParams, ['period']),
|
||||
...(isChatMode ? { sort_by: debouncedQueryParams.sort_by } : {}),
|
||||
...omit(debouncedQueryParams, ['period']),
|
||||
}
|
||||
|
||||
// When the details are obtained, proceed to the next request
|
||||
@@ -158,10 +143,9 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
<Filter
|
||||
isChatMode={isChatMode}
|
||||
appId={appDetail.id}
|
||||
queryParams={effectiveQueryParams}
|
||||
queryParams={queryParams}
|
||||
setQueryParams={handleQueryParamsChange}
|
||||
/>
|
||||
<RetentionUpgradeNotice />
|
||||
{total === undefined ? (
|
||||
<Loading type="app" />
|
||||
) : total > 0 ? (
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { useCloudSandboxPlanStatus } from './cloud-sandbox-retention'
|
||||
|
||||
export function RetentionUpgradeNotice() {
|
||||
const { t } = useTranslation()
|
||||
const planState = useCloudSandboxPlanStatus()
|
||||
|
||||
if (planState !== 'sandbox') return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="relative mb-3 shrink-0 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-px bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-40"
|
||||
/>
|
||||
<div className="relative flex items-center gap-3 px-3 py-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-components-button-primary-bg"
|
||||
>
|
||||
<span className="i-ri-file-list-3-fill size-4 text-components-button-primary-text" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 system-sm-medium wrap-break-word text-text-primary">
|
||||
{t(($) => $['retention.upgradeTip.description'], { ns: 'appLog' })}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
size="custom"
|
||||
className="h-8! shrink-0 rounded-lg! px-2"
|
||||
loc="logs-retention"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,35 +17,6 @@ import Filter, { TIME_PERIOD_MAPPING } from '../filter'
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
const mockRuntime = vi.hoisted(() => ({
|
||||
deploymentEdition: 'CLOUD',
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
isFetchedPlanInfo: true,
|
||||
planType: 'professional',
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => ({
|
||||
enableBilling: mockRuntime.enableBilling,
|
||||
isFetchedPlan: mockRuntime.isFetchedPlan,
|
||||
isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo,
|
||||
plan: { type: mockRuntime.planType },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const mockTrackEvent = vi.fn()
|
||||
vi.mock('@/app/components/base/amplitude/utils', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
@@ -70,11 +41,6 @@ describe('Filter', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.enableBilling = true
|
||||
mockRuntime.isFetchedPlan = true
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
mockRuntime.planType = 'professional'
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -210,69 +176,6 @@ describe('Filter', () => {
|
||||
// Time Period Filter Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Time Period Filter', () => {
|
||||
it('should only show supported periods for Cloud sandbox workspaces', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(
|
||||
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' }))
|
||||
|
||||
const listbox = await screen.findByRole('listbox')
|
||||
expect(
|
||||
within(listbox)
|
||||
.getAllByRole('option')
|
||||
.map((option) => option.textContent),
|
||||
).toEqual([
|
||||
'appLog.filter.period.today',
|
||||
'appLog.filter.period.last7days',
|
||||
'appLog.filter.period.last30days',
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep all periods for sandbox workspaces outside Cloud', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockRuntime.deploymentEdition = 'COMMUNITY'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(
|
||||
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' }))
|
||||
|
||||
const listbox = await screen.findByRole('listbox')
|
||||
expect(within(listbox).getAllByRole('option')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('should reset the Cloud sandbox period to today when cleared', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = vi.fn()
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ period: '3' })}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /common\.operation\.clear appLog\.filter\.period\.last30days/,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
period: '1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should display current period value', () => {
|
||||
render(
|
||||
<Filter
|
||||
|
||||
@@ -15,13 +15,11 @@ import type { UseQueryResult } from '@tanstack/react-query'
|
||||
* - trigger-by-display.spec.tsx
|
||||
*/
|
||||
import type { MockedFunction } from 'vitest'
|
||||
import type { CloudSandboxPlanState } from '../../log/cloud-sandbox-retention'
|
||||
import type { ILogsProps } from '../index'
|
||||
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
|
||||
import type { App, AppIconType, AppModeEnum } from '@/types/app'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { WorkflowRunTriggeredFrom } from '@/models/log'
|
||||
import * as useLogModule from '@/service/use-log'
|
||||
@@ -33,35 +31,10 @@ import Logs from '../index'
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
const mockPlanState = vi.hoisted(() => ({
|
||||
value: 'unrestricted' as CloudSandboxPlanState,
|
||||
}))
|
||||
const mockDebouncedPeriod = vi.hoisted(() => ({
|
||||
value: null as string | null,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-log')
|
||||
|
||||
vi.mock('../../log/cloud-sandbox-retention', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../log/cloud-sandbox-retention')>()
|
||||
return {
|
||||
...actual,
|
||||
useCloudSandboxPlanStatus: () => mockPlanState.value,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: <T,>(value: T) => {
|
||||
if (
|
||||
mockDebouncedPeriod.value === null ||
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('period' in value)
|
||||
)
|
||||
return value
|
||||
|
||||
return { ...value, period: mockDebouncedPeriod.value }
|
||||
},
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
useDebounceFn: (fn: (value: string) => void) => ({ run: fn }),
|
||||
useBoolean: (initial: boolean) => {
|
||||
const setters = {
|
||||
@@ -85,10 +58,6 @@ vi.mock('@/next/link', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../log/retention-upgrade-notice', () => ({
|
||||
RetentionUpgradeNotice: () => <div>retention-upgrade-notice</div>,
|
||||
}))
|
||||
|
||||
// Mock the Run component to avoid complex dependencies
|
||||
vi.mock('@/app/components/workflow/run', () => ({
|
||||
default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => (
|
||||
@@ -268,8 +237,6 @@ describe('Logs Container', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPlanState.value = 'unrestricted'
|
||||
mockDebouncedPeriod.value = null
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -305,7 +272,6 @@ describe('Logs Container', () => {
|
||||
|
||||
// Assert
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -478,76 +444,6 @@ describe('Logs Container', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should query the last 30 days when a Sandbox user selects the longest period', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPlanState.value = 'sandbox'
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
|
||||
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('appLog.filter.period.last7days'))
|
||||
await user.click(await screen.findByText('appLog.filter.period.last30days'))
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'appLog.filter.period.last30days' }),
|
||||
).toBeInTheDocument()
|
||||
const params = getMockCallParams()?.params
|
||||
expect(
|
||||
dayjs(String(params?.created_at__before)).diff(String(params?.created_at__after), 'day'),
|
||||
).toBe(30)
|
||||
})
|
||||
|
||||
it('should use a valid period for the real Chip and request when plan state settles to Sandbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
const rendered = renderWithQueryClient(<Logs {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('appLog.filter.period.last7days'))
|
||||
await user.click(await screen.findByText('appLog.filter.period.allTime'))
|
||||
expect(getMockCallParams()?.params).not.toHaveProperty('created_at__after')
|
||||
expect(getMockCallParams()?.params).not.toHaveProperty('created_at__before')
|
||||
|
||||
mockPlanState.value = 'pending'
|
||||
mockDebouncedPeriod.value = '9'
|
||||
rendered.rerender(<Logs {...defaultProps} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'appLog.filter.period.today' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /common\.operation\.clear appLog\.filter\.period\.today/,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(getMockCallParams()?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
created_at__after: expect.any(String),
|
||||
created_at__before: expect.any(String),
|
||||
}),
|
||||
)
|
||||
|
||||
mockPlanState.value = 'sandbox'
|
||||
rendered.rerender(<Logs {...defaultProps} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'appLog.filter.period.today' }),
|
||||
).toBeInTheDocument()
|
||||
expect(getMockCallParams()?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
created_at__after: expect.any(String),
|
||||
created_at__before: expect.any(String),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should update query when typing keyword', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -10,13 +10,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude/utils'
|
||||
import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
CLOUD_SANDBOX_CLEARED_TIME_PERIOD,
|
||||
CLOUD_SANDBOX_TIME_PERIOD_KEYS,
|
||||
isLogTimePeriodRestricted,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from '../log/cloud-sandbox-retention'
|
||||
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
@@ -43,12 +36,6 @@ type IFilterProps = {
|
||||
|
||||
const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const planState = useCloudSandboxPlanStatus()
|
||||
const isTimePeriodRestricted = isLogTimePeriodRestricted(planState)
|
||||
const timePeriodEntries = Object.entries(TIME_PERIOD_MAPPING)
|
||||
.filter(([key]) => !isTimePeriodRestricted || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(key))
|
||||
.map(([key, option]) => [key, resolveLogTimePeriodOption(key, option, planState)] as const)
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-row flex-wrap gap-2">
|
||||
<Chip
|
||||
@@ -76,13 +63,8 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, period: item.value })
|
||||
}}
|
||||
onClear={() =>
|
||||
setQueryParams({
|
||||
...queryParams,
|
||||
period: isTimePeriodRestricted ? CLOUD_SANDBOX_CLEARED_TIME_PERIOD : '9',
|
||||
})
|
||||
}
|
||||
items={timePeriodEntries.map(([k, v]) => ({
|
||||
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
|
||||
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({
|
||||
value: k,
|
||||
name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }),
|
||||
}))}
|
||||
|
||||
@@ -19,12 +19,6 @@ import { useWorkflowLogs } from '@/service/use-log'
|
||||
import PageTitle from '../log-annotation/page-title'
|
||||
import { ArchivedLogsNotice } from '../log/archived-logs-notice'
|
||||
import { shouldShowArchivedLogsNotice } from '../log/archived-logs-notice-utils'
|
||||
import {
|
||||
resolveLogTimePeriod,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from '../log/cloud-sandbox-retention'
|
||||
import { RetentionUpgradeNotice } from '../log/retention-upgrade-notice'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||
import List from './list'
|
||||
|
||||
@@ -49,35 +43,26 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
})
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all', period: '2' })
|
||||
const [currPage, setCurrPage] = React.useState<number>(0)
|
||||
const cloudSandboxPlanState = useCloudSandboxPlanStatus()
|
||||
const effectivePeriod = resolveLogTimePeriod(queryParams.period, cloudSandboxPlanState)
|
||||
const effectiveQueryParams = { ...queryParams, period: effectivePeriod }
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
const requestQueryParams = { ...debouncedQueryParams, period: effectivePeriod }
|
||||
const requestTimePeriod = resolveLogTimePeriodOption(
|
||||
requestQueryParams.period,
|
||||
TIME_PERIOD_MAPPING[requestQueryParams.period]!,
|
||||
cloudSandboxPlanState,
|
||||
)
|
||||
const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT)
|
||||
|
||||
const query = {
|
||||
page: currPage + 1,
|
||||
detail: true,
|
||||
limit,
|
||||
...(requestQueryParams.status !== 'all' ? { status: requestQueryParams.status } : {}),
|
||||
...(requestQueryParams.keyword ? { keyword: requestQueryParams.keyword } : {}),
|
||||
...(requestQueryParams.period !== '9'
|
||||
...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}),
|
||||
...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}),
|
||||
...(debouncedQueryParams.period !== '9'
|
||||
? {
|
||||
created_at__after: dayjs()
|
||||
.subtract(requestTimePeriod.value, 'day')
|
||||
.subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day')
|
||||
.startOf('day')
|
||||
.tz(timezone)
|
||||
.format('YYYY-MM-DDTHH:mm:ssZ'),
|
||||
created_at__before: dayjs().endOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'),
|
||||
}
|
||||
: {}),
|
||||
...omit(requestQueryParams, ['period', 'status']),
|
||||
...omit(debouncedQueryParams, ['period', 'status']),
|
||||
}
|
||||
|
||||
const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({
|
||||
@@ -87,7 +72,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const total = workflowLogs?.total
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
const showArchivedLogsNotice = shouldShowArchivedLogsNotice(
|
||||
effectiveQueryParams.period,
|
||||
queryParams.period,
|
||||
TIME_PERIOD_MAPPING,
|
||||
)
|
||||
|
||||
@@ -98,8 +83,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
description={t(($) => $.workflowSubtitle, { ns: 'appLog' })}
|
||||
/>
|
||||
<div className="flex max-h-[calc(100%-16px)] flex-1 flex-col py-4">
|
||||
<Filter queryParams={effectiveQueryParams} setQueryParams={setQueryParams} />
|
||||
<RetentionUpgradeNotice />
|
||||
<Filter queryParams={queryParams} setQueryParams={setQueryParams} />
|
||||
{showArchivedLogsNotice && <ArchivedLogsNotice />}
|
||||
{/* workflow log */}
|
||||
{total === undefined ? (
|
||||
|
||||
@@ -211,18 +211,6 @@ export default function AccountSetting({
|
||||
|
||||
return (
|
||||
<MenuDialog show onClose={handleClose}>
|
||||
<div className="fixed top-6 right-6 z-20 flex shrink-0 flex-col items-center">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="large"
|
||||
className="px-2"
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<span className="i-ri-close-line size-5" />
|
||||
</Button>
|
||||
<div className="mt-1 system-2xs-medium-uppercase text-text-tertiary">ESC</div>
|
||||
</div>
|
||||
<div className="flex h-screen w-full max-w-full pl-0 sm:pl-[232px]">
|
||||
<div className="flex w-[44px] shrink-0 flex-col pr-6 pl-4 sm:w-[224px]">
|
||||
<div className="mt-6 mb-8 flex h-[38px] items-center px-3 title-2xl-semi-bold whitespace-nowrap text-text-primary">
|
||||
@@ -287,6 +275,18 @@ export default function AccountSetting({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="fixed top-6 right-6 flex shrink-0 flex-col items-center">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="large"
|
||||
className="px-2"
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<span className="i-ri-close-line size-5" />
|
||||
</Button>
|
||||
<div className="mt-1 system-2xs-medium-uppercase text-text-tertiary">ESC</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-full min-w-0 px-4 pt-6 sm:px-8">
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.PROVIDER && (
|
||||
|
||||
+1
-3
@@ -272,10 +272,8 @@ function Popup({
|
||||
return (
|
||||
<ModelSelectorPopupFrame>
|
||||
<ModelSelectorSearchHeader inputValue={inputValue} onInputValueChange={onInputValueChange} />
|
||||
{showCreditsExhaustedAlert && <CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />}
|
||||
<ModelSelectorScrollBody label={t(($) => $['modelProvider.models'], { ns: 'common' })}>
|
||||
{showCreditsExhaustedAlert && (
|
||||
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
|
||||
)}
|
||||
<ComboboxList className="max-h-none overflow-visible p-0">
|
||||
<div className="pb-1">
|
||||
{filteredModelList.map((model) => (
|
||||
|
||||
+109
-85
@@ -4,101 +4,125 @@ import { InstallationScope } from '@/features/system-features/constants'
|
||||
import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data'
|
||||
import { pluginInstallLimit } from '../use-install-plugin-limit'
|
||||
|
||||
type PluginInstallCandidate = Parameters<typeof pluginInstallLimit>[0]
|
||||
type SystemFeatures = Parameters<typeof pluginInstallLimit>[1]
|
||||
|
||||
const basePlugin = {
|
||||
from: 'marketplace' as const,
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
} satisfies PluginInstallCandidate
|
||||
|
||||
function makeSystemFeatures(
|
||||
scope: PluginInstallationScope,
|
||||
restrictToMarketplaceOnly = false,
|
||||
): SystemFeatures {
|
||||
return {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: restrictToMarketplaceOnly,
|
||||
plugin_installation_scope: scope,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('pluginInstallLimit', () => {
|
||||
it('should allow all plugins when scope is ALL', () => {
|
||||
const features = makeSystemFeatures(InstallationScope.ALL)
|
||||
|
||||
expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny all plugins when scope is NONE', () => {
|
||||
const features = makeSystemFeatures(InstallationScope.NONE)
|
||||
|
||||
expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow langgenius plugins when scope is OFFICIAL_ONLY', () => {
|
||||
const features = makeSystemFeatures(InstallationScope.OFFICIAL_ONLY)
|
||||
|
||||
expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny non-official plugins when scope is OFFICIAL_ONLY', () => {
|
||||
const features = makeSystemFeatures(InstallationScope.OFFICIAL_ONLY)
|
||||
const plugin = {
|
||||
...basePlugin,
|
||||
verification: { authorized_category: 'community' as const },
|
||||
} satisfies PluginInstallCandidate
|
||||
|
||||
expect(pluginInstallLimit(plugin, features).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow partner plugins when scope is OFFICIAL_AND_PARTNER', () => {
|
||||
const features = makeSystemFeatures(InstallationScope.OFFICIAL_AND_PARTNER)
|
||||
const plugin = {
|
||||
...basePlugin,
|
||||
verification: { authorized_category: 'partner' as const },
|
||||
} satisfies PluginInstallCandidate
|
||||
|
||||
expect(pluginInstallLimit(plugin, features).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny github plugins when restrict_to_marketplace_only is true', () => {
|
||||
const features = makeSystemFeatures(InstallationScope.ALL, true)
|
||||
const plugin = { ...basePlugin, from: 'github' as const } satisfies PluginInstallCandidate
|
||||
|
||||
expect(pluginInstallLimit(plugin, features).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should deny package plugins when restrict_to_marketplace_only is true', () => {
|
||||
const features = makeSystemFeatures(InstallationScope.ALL, true)
|
||||
const plugin = { ...basePlugin, from: 'package' as const } satisfies PluginInstallCandidate
|
||||
|
||||
expect(pluginInstallLimit(plugin, features).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow marketplace plugins even when restrict_to_marketplace_only is true', () => {
|
||||
const features = makeSystemFeatures(InstallationScope.ALL, true)
|
||||
|
||||
expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should default to langgenius when no verification info', () => {
|
||||
const features = makeSystemFeatures(InstallationScope.OFFICIAL_ONLY)
|
||||
const plugin = { from: 'marketplace' as const } satisfies PluginInstallCandidate
|
||||
|
||||
expect(pluginInstallLimit(plugin, features).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny installation for an unrecognized runtime scope', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: 'unknown-scope',
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
} as unknown as SystemFeatures
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(false)
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny all plugins when scope is NONE', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.NONE,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow langgenius plugins when scope is OFFICIAL_ONLY', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny non-official plugins when scope is OFFICIAL_ONLY', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, verification: { authorized_category: 'community' } }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow partner plugins when scope is OFFICIAL_AND_PARTNER', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_AND_PARTNER,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, verification: { authorized_category: 'partner' } }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny github plugins when restrict_to_marketplace_only is true', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, from: 'github' as const }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should deny package plugins when restrict_to_marketplace_only is true', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, from: 'package' as const }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow marketplace plugins even when restrict_to_marketplace_only is true', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should default to langgenius when no verification info', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
const plugin = { from: 'marketplace' as const }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should fallback to canInstall true for unrecognized scope', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: 'unknown-scope' as unknown as PluginInstallationScope,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -108,9 +132,9 @@ describe('usePluginInstallLimit', () => {
|
||||
const plugin = {
|
||||
from: 'marketplace' as const,
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
} satisfies PluginInstallCandidate
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => usePluginInstallLimit(plugin))
|
||||
const { result } = renderHook(() => usePluginInstallLimit(plugin as never))
|
||||
|
||||
expect(result.current.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,55 +1,68 @@
|
||||
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type {
|
||||
PluginBundleDependencyType,
|
||||
PluginVerification,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { Plugin, PluginManifestInMarket } from '../../types'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { InstallationScope } from '@/features/system-features/constants'
|
||||
|
||||
type PluginInstallCandidate = {
|
||||
from: PluginBundleDependencyType
|
||||
verification?: PluginVerification | null
|
||||
type PluginProps = (Plugin | PluginManifestInMarket) & {
|
||||
from: 'github' | 'marketplace' | 'package'
|
||||
}
|
||||
type PluginInstallLimitResult = {
|
||||
canInstall: boolean
|
||||
}
|
||||
|
||||
function denyUnsupportedInstallationScope(_scope: never): PluginInstallLimitResult {
|
||||
return { canInstall: false }
|
||||
}
|
||||
|
||||
export function pluginInstallLimit(
|
||||
plugin: PluginInstallCandidate,
|
||||
plugin: PluginProps,
|
||||
systemFeatures: Pick<GetSystemFeaturesResponse, 'plugin_installation_permission'>,
|
||||
) {
|
||||
const permission = systemFeatures.plugin_installation_permission
|
||||
if (permission.restrict_to_marketplace_only) {
|
||||
if (systemFeatures.plugin_installation_permission.restrict_to_marketplace_only) {
|
||||
if (plugin.from === 'github' || plugin.from === 'package') return { canInstall: false }
|
||||
}
|
||||
|
||||
const authorizedCategory = plugin.verification?.authorized_category ?? 'langgenius'
|
||||
const scope = permission.plugin_installation_scope
|
||||
if (
|
||||
systemFeatures.plugin_installation_permission.plugin_installation_scope ===
|
||||
InstallationScope.ALL
|
||||
) {
|
||||
return {
|
||||
canInstall: true,
|
||||
}
|
||||
}
|
||||
if (
|
||||
systemFeatures.plugin_installation_permission.plugin_installation_scope ===
|
||||
InstallationScope.NONE
|
||||
) {
|
||||
return {
|
||||
canInstall: false,
|
||||
}
|
||||
}
|
||||
const verification = plugin.verification || {}
|
||||
if (!plugin.verification || !plugin.verification.authorized_category)
|
||||
verification.authorized_category = 'langgenius'
|
||||
|
||||
switch (scope) {
|
||||
case InstallationScope.ALL:
|
||||
return { canInstall: true }
|
||||
case InstallationScope.NONE:
|
||||
return { canInstall: false }
|
||||
case InstallationScope.OFFICIAL_ONLY:
|
||||
return { canInstall: authorizedCategory === 'langgenius' }
|
||||
case InstallationScope.OFFICIAL_AND_PARTNER:
|
||||
return {
|
||||
canInstall: authorizedCategory === 'langgenius' || authorizedCategory === 'partner',
|
||||
}
|
||||
default:
|
||||
return denyUnsupportedInstallationScope(scope)
|
||||
if (
|
||||
systemFeatures.plugin_installation_permission.plugin_installation_scope ===
|
||||
InstallationScope.OFFICIAL_ONLY
|
||||
) {
|
||||
return {
|
||||
canInstall: verification.authorized_category === 'langgenius',
|
||||
}
|
||||
}
|
||||
if (
|
||||
systemFeatures.plugin_installation_permission.plugin_installation_scope ===
|
||||
InstallationScope.OFFICIAL_AND_PARTNER
|
||||
) {
|
||||
return {
|
||||
canInstall:
|
||||
verification.authorized_category === 'langgenius' ||
|
||||
verification.authorized_category === 'partner',
|
||||
}
|
||||
}
|
||||
return {
|
||||
canInstall: true,
|
||||
}
|
||||
}
|
||||
|
||||
export default function usePluginInstallLimit(
|
||||
plugin: PluginInstallCandidate,
|
||||
): PluginInstallLimitResult {
|
||||
export default function usePluginInstallLimit(plugin: PluginProps): PluginInstallLimitResult {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
return pluginInstallLimit(plugin, systemFeatures)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user