refactor: move trial app capability out of system features (#39562)

This commit is contained in:
yyh 2026-07-26 20:02:30 +08:00 committed by GitHub
parent 99c1cb7781
commit c61d33f91e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 220 additions and 189 deletions

View File

@ -11,7 +11,7 @@ from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, with_current_user
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import build_icon_url
from libs.helper import build_icon_url, dump_response
from libs.login import login_required
from models import Account
from services.recommended_app_service import RecommendedAppService
@ -58,7 +58,7 @@ class RecommendedAppResponse(ResponseModel):
categories: list[str] = Field(default_factory=list)
position: int | None = None
is_listed: bool | None = None
can_trial: bool | None = None
can_trial: bool
class RecommendedAppListResponse(ResponseModel):
@ -77,7 +77,7 @@ class RecommendedAppDetailResponse(ResponseModel):
icon_background: str | None = None
mode: str
export_data: str
can_trial: bool | None = None
can_trial: bool
class RecommendedAppDetailNullableResponse(RootModel[RecommendedAppDetailResponse | None]):
@ -119,10 +119,10 @@ class RecommendedAppListApi(Resource):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
return RecommendedAppListResponse.model_validate(
return dump_response(
RecommendedAppListResponse,
RecommendedAppService.get_recommended_apps_and_categories(language_prefix, session=db.session()),
from_attributes=True,
).model_dump(mode="json")
)
@console_ns.route("/explore/apps/learn-dify")
@ -136,10 +136,10 @@ class LearnDifyAppListApi(Resource):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
return LearnDifyAppListResponse.model_validate(
return dump_response(
LearnDifyAppListResponse,
RecommendedAppService.get_learn_dify_apps(language_prefix, session=db.session()),
from_attributes=True,
).model_dump(mode="json")
)
@console_ns.route("/explore/apps/<uuid:app_id>")
@ -148,4 +148,5 @@ class RecommendedAppApi(Resource):
@login_required
@account_initialization_required
def get(self, app_id: UUID):
return RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session())
result = RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session())
return RecommendedAppDetailNullableResponse.model_validate(result).model_dump(mode="json")

View File

@ -46,7 +46,7 @@ from controllers.console.explore.error import (
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.wraps import TrialAppResource, trial_feature_enable
from controllers.console.explore.wraps import TrialAppResource
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file_from_request
from controllers.console.wraps import cloud_edition_billing_resource_check, with_current_user
@ -432,7 +432,6 @@ simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
class TrialAppFileUploadApi(TrialAppResource):
@trial_feature_enable
@cloud_edition_billing_resource_check("documents")
@console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS)
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__])
@ -447,7 +446,6 @@ class TrialAppFileUploadApi(TrialAppResource):
class TrialAppRemoteFileUploadApi(TrialAppResource):
@trial_feature_enable
@cloud_edition_billing_resource_check("documents")
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__])
@ -462,7 +460,6 @@ class TrialAppRemoteFileUploadApi(TrialAppResource):
class TrialAppWorkflowRunApi(TrialAppResource):
@trial_feature_enable
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success")
@with_current_user
@ -513,7 +510,6 @@ class TrialAppWorkflowRunApi(TrialAppResource):
class TrialAppWorkflowTaskStopApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@trial_feature_enable
def post(self, trial_app, task_id: str):
"""
Stop workflow task
@ -538,7 +534,6 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
class TrialChatApi(TrialAppResource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success")
@trial_feature_enable
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):
@ -640,7 +635,6 @@ class TrialMessageSuggestedQuestionApi(TrialAppResource):
class TrialChatAudioApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@trial_feature_enable
@with_current_user
def post(self, current_user: Account, trial_app):
app_model = trial_app
@ -691,7 +685,6 @@ class TrialChatAudioApi(TrialAppResource):
class TrialChatTextApi(TrialAppResource):
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
@trial_feature_enable
@with_current_user
def post(self, current_user: Account, trial_app):
app_model = trial_app
@ -752,7 +745,6 @@ class TrialChatTextApi(TrialAppResource):
class TrialCompletionApi(TrialAppResource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success")
@trial_feature_enable
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):

View File

@ -14,6 +14,7 @@ from libs.login import current_account_with_tenant, login_required
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.recommended_app_service import RecommendedAppService
def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
@ -106,8 +107,7 @@ def trial_app_required[**P, R](view: Callable[Concatenate[App, P], R] | None = N
def trial_feature_enable[**P, R](view: Callable[P, R]):
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
features = FeatureService.get_system_features()
if not features.enable_trial_app:
if not RecommendedAppService.is_trial_app_enabled():
abort(403, "Trial app feature is not enabled.")
return view(*args, **kwargs)
@ -141,6 +141,7 @@ class TrialAppResource(Resource):
method_decorators = [
trial_app_required,
trial_feature_enable,
account_initialization_required,
login_required,
]

View File

@ -21028,7 +21028,7 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| can_trial | boolean | | No |
| can_trial | boolean | | Yes |
| export_data | string | | Yes |
| icon | string | | No |
| icon_background | string | | No |
@ -21061,7 +21061,7 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| ---- | ---- | ----------- | -------- |
| app | [RecommendedAppInfoResponse](#recommendedappinforesponse) | | No |
| app_id | string | | Yes |
| can_trial | boolean | | No |
| can_trial | boolean | | Yes |
| categories | [ string ] | | No |
| copyright | string | | No |
| custom_disclaimer | string | | No |
@ -22058,7 +22058,6 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_step_by_step_tour | boolean | | Yes |
| enable_trial_app | boolean | | Yes |
| is_allow_register | boolean | | Yes |
| is_email_setup | boolean | | Yes |
| knowledge_fs_enabled | boolean | | Yes |

View File

@ -1557,7 +1557,6 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_step_by_step_tour | boolean | | Yes |
| enable_trial_app | boolean | | Yes |
| is_allow_register | boolean | | Yes |
| is_email_setup | boolean | | Yes |
| knowledge_fs_enabled | boolean | | Yes |

View File

@ -181,7 +181,6 @@ class SystemFeatureModel(FeatureResponseModel):
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
enable_change_email: bool = True
enable_creators_platform: bool = False
enable_trial_app: bool = False
enable_explore_banner: bool = False
enable_learn_app: bool = True
enable_step_by_step_tour: bool = False
@ -310,7 +309,6 @@ class FeatureService:
system_features.is_allow_register = dify_config.ALLOW_REGISTER
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED

View File

@ -4,12 +4,19 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from models.model import AccountTrialAppRecord, App, TrialApp
from services.feature_service import FeatureService
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
class RecommendedAppService:
"""Own recommended app retrieval and Cloud-only trial eligibility."""
@staticmethod
def is_trial_app_enabled() -> bool:
"""Return whether trial execution is enabled for this deployment."""
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
@classmethod
def get_app(cls, app_id: str, *, session: Session) -> App | None:
"""Return a normal app only when it belongs to the recommended catalog."""
@ -38,11 +45,12 @@ class RecommendedAppService:
)
)
if FeatureService.get_system_features().enable_trial_app:
apps = result["recommended_apps"]
for app in apps:
app_id = app["app_id"]
app["can_trial"] = cls._can_trial_app(session, app_id)
apps = result["recommended_apps"]
trial_app_ids = (
cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set()
)
for app in apps:
app["can_trial"] = app["app_id"] in trial_app_ids
return result
@classmethod
@ -56,11 +64,14 @@ class RecommendedAppService:
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
result = retrieval_instance.get_learn_dify_apps(language, session=session)
if FeatureService.get_system_features().enable_trial_app:
for app in result["recommended_apps"]:
app["can_trial"] = cls._can_trial_app(session, app["app_id"])
apps = result["recommended_apps"]
trial_app_ids = (
cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set()
)
for app in apps:
app["can_trial"] = app["app_id"] in trial_app_ids
return {"recommended_apps": result["recommended_apps"]}
return {"recommended_apps": apps}
@classmethod
def get_recommend_app_detail(cls, app_id: str, *, session: Session) -> dict[str, Any] | None:
@ -74,9 +85,7 @@ class RecommendedAppService:
result: dict[str, Any] | None = retrieval_instance.get_recommend_app_detail(app_id, session=session)
if result is None:
return None
if FeatureService.get_system_features().enable_trial_app:
app_id = result["id"]
result["can_trial"] = cls._can_trial_app(session, app_id)
result["can_trial"] = cls.is_trial_app_enabled() and cls._can_trial_app(session, result["id"])
return result
@classmethod
@ -102,3 +111,9 @@ class RecommendedAppService:
def _can_trial_app(session: Session, app_id: str) -> bool:
trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
return trial_app_model is not None
@staticmethod
def _get_trial_app_ids(session: Session, app_ids: list[str]) -> set[str]:
if not app_ids:
return set()
return set(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())

View File

@ -115,6 +115,7 @@ def test_system_features_specs_exclude_backend_only_fields(tmp_path):
written_paths = module.generate_specs(tmp_path)
excluded_fields = {
"enable_trial_app",
"is_allow_create_workspace",
"max_plugin_package_size",
"plugin_manager",
@ -223,7 +224,13 @@ def test_generate_specs_include_console_contract_shapes_for_schema_migration(tmp
app_detail_schema = schemas["RecommendedAppDetailResponse"]
assert app_detail_schema["properties"]["id"]["type"] == "string"
assert app_detail_schema["properties"]["export_data"]["type"] == "string"
assert {"type": "boolean"} in app_detail_schema["properties"]["can_trial"]["anyOf"]
assert app_detail_schema["properties"]["can_trial"]["type"] == "boolean"
assert "anyOf" not in app_detail_schema["properties"]["can_trial"]
assert "can_trial" in app_detail_schema["required"]
app_list_item_schema = schemas["RecommendedAppResponse"]
assert app_list_item_schema["properties"]["can_trial"]["type"] == "boolean"
assert "anyOf" not in app_list_item_schema["properties"]["can_trial"]
assert "can_trial" in app_list_item_schema["required"]
app_detail_nullable_schema = schemas["RecommendedAppDetailNullableResponse"]
assert _response_schema(paths["/explore/apps/{app_id}"]["get"])["$ref"] == (
"#/components/schemas/RecommendedAppDetailNullableResponse"

View File

@ -1,7 +1,9 @@
from inspect import unwrap
from unittest.mock import ANY, patch
import pytest
from flask import Flask
from pydantic import ValidationError
import controllers.console.explore.recommended_app as module
from models import Account
@ -119,7 +121,13 @@ class TestRecommendedAppApi:
api = module.RecommendedAppApi()
method = unwrap(api.get)
result_data = {"id": "app1"}
result_data = {
"id": "app1",
"name": "App",
"mode": "chat",
"export_data": "{}",
"can_trial": False,
}
with (
app.test_request_context("/"),
@ -132,7 +140,7 @@ class TestRecommendedAppApi:
result = method(api, "11111111-1111-1111-1111-111111111111")
service_mock.assert_called_once_with("11111111-1111-1111-1111-111111111111", session=ANY)
assert result == result_data
assert result == {**result_data, "icon": None, "icon_background": None}
class TestRecommendedAppResponseModels:
@ -198,6 +206,7 @@ class TestRecommendedAppResponseModels:
"categories": ["Workflow"],
"position": 1,
"is_listed": True,
"can_trial": False,
}
],
}
@ -205,3 +214,7 @@ class TestRecommendedAppResponseModels:
assert response["recommended_apps"][0]["app_id"] == "app-1"
assert response["recommended_apps"][0]["categories"] == ["Workflow"]
def test_recommended_app_response_requires_can_trial(self):
with pytest.raises(ValidationError):
module.RecommendedAppResponse.model_validate({"app_id": "app-1"})

View File

@ -1109,15 +1109,13 @@ class TestTrialChatTextApi:
class TestTrialAppWorkflowTaskStopApi:
def test_not_workflow_app(self, app: Flask, trial_app_chat: MagicMock) -> None:
api = module.TrialAppWorkflowTaskStopApi()
method = unwrap(api.post)
with app.test_request_context("/"):
with pytest.raises(NotWorkflowAppError):
method(api, trial_app_chat, str(uuid4()))
api.post(trial_app_chat, str(uuid4()))
def test_success(self, app: Flask, trial_app_workflow: MagicMock) -> None:
api = module.TrialAppWorkflowTaskStopApi()
method = unwrap(api.post)
task_id = str(uuid4())
with (
@ -1125,7 +1123,7 @@ class TestTrialAppWorkflowTaskStopApi:
patch.object(module.AppQueueManager, "set_stop_flag_no_user_check") as mock_set_flag,
patch.object(module.GraphEngineManager, "send_stop_command") as mock_send_cmd,
):
result = method(api, trial_app_workflow, task_id)
result = api.post(trial_app_workflow, task_id)
assert result == {"result": "success"}
mock_set_flag.assert_called_once_with(task_id)

View File

@ -255,11 +255,9 @@ def test_trial_feature_enable_disabled():
def view():
return "ok"
features = MagicMock(enable_trial_app=False)
with patch(
"controllers.console.explore.wraps.FeatureService.get_system_features",
return_value=features,
"controllers.console.explore.wraps.RecommendedAppService.is_trial_app_enabled",
return_value=False,
):
with pytest.raises(Forbidden):
view()
@ -270,11 +268,9 @@ def test_trial_feature_enable_enabled():
def view():
return "ok"
features = MagicMock(enable_trial_app=True)
with patch(
"controllers.console.explore.wraps.FeatureService.get_system_features",
return_value=features,
"controllers.console.explore.wraps.RecommendedAppService.is_trial_app_enabled",
return_value=True,
):
assert view() == "ok"
@ -285,5 +281,9 @@ def test_installed_app_resource_decorators():
def test_trial_app_resource_decorators():
decorators = TrialAppResource.method_decorators
assert len(decorators) == 3
assert TrialAppResource.method_decorators == [
trial_app_required,
trial_feature_enable,
wraps_module.account_initialization_required,
wraps_module.login_required,
]

View File

@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
from enums.deployment_edition import DeploymentEdition
from models.model import AccountTrialAppRecord, App, AppMode, TrialApp
from services import recommended_app_service as service_module
from services.feature_service import SystemFeatureModel
from services.recommended_app_service import RecommendedAppService
pytestmark = pytest.mark.parametrize(
@ -49,6 +48,30 @@ class AppDetailKwargs(TypedDict, total=False):
tools: list[str]
@pytest.mark.parametrize(
("edition", "enterprise_enabled", "feature_enabled", "expected"),
[
("CLOUD", False, True, True),
("CLOUD", False, False, False),
("SELF_HOSTED", False, True, False),
("SELF_HOSTED", True, True, False),
],
)
def test_trial_app_policy_is_cloud_only(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
edition: str,
enterprise_enabled: bool,
feature_enabled: bool,
expected: bool,
) -> None:
monkeypatch.setattr(service_module.dify_config, "EDITION", edition)
monkeypatch.setattr(service_module.dify_config, "ENTERPRISE_ENABLED", enterprise_enabled)
monkeypatch.setattr(service_module.dify_config, "ENABLE_TRIAL_APP", feature_enabled)
assert RecommendedAppService.is_trial_app_enabled() is expected
# ── Helpers ────────────────────────────────────────────────────────────
@ -58,8 +81,8 @@ def _apps_response(
) -> AppsResponse:
if recommended_apps is None:
recommended_apps = [
{"id": "app-1", "name": "Test App 1", "description": "d1", "category": "productivity"},
{"id": "app-2", "name": "Test App 2", "description": "d2", "category": "communication"},
{"app_id": "app-1", "name": "Test App 1", "description": "d1", "category": "productivity"},
{"app_id": "app-2", "name": "Test App 2", "description": "d2", "category": "communication"},
]
if categories is None:
categories = ["productivity", "communication", "utilities"]
@ -175,7 +198,7 @@ class TestRecommendedAppServiceGetApps:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
empty_response = AppsResponse(recommended_apps=[], categories=[])
builtin_response = _apps_response(
recommended_apps=[{"id": "builtin-1", "name": "Builtin App", "category": "default"}]
recommended_apps=[{"app_id": "builtin-1", "name": "Builtin App", "category": "default"}]
)
mock_remote_instance = MagicMock()
@ -189,7 +212,7 @@ class TestRecommendedAppServiceGetApps:
result = RecommendedAppService.get_recommended_apps_and_categories("zh-CN", session=sqlite_session)
assert result == builtin_response
assert result["recommended_apps"][0]["id"] == "builtin-1"
assert result["recommended_apps"][0]["app_id"] == "builtin-1"
mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US")
@patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
@ -223,7 +246,7 @@ class TestRecommendedAppServiceGetApps:
for language in ["en-US", "zh-CN", "ja-JP", "fr-FR"]:
lang_response = _apps_response(
recommended_apps=[{"id": f"app-{language}", "name": f"App {language}", "category": "test"}]
recommended_apps=[{"app_id": f"app-{language}", "name": f"App {language}", "category": "test"}]
)
mock_instance = MagicMock()
mock_instance.get_recommended_apps_and_categories.return_value = lang_response
@ -231,7 +254,7 @@ class TestRecommendedAppServiceGetApps:
result = RecommendedAppService.get_recommended_apps_and_categories(language, session=sqlite_session)
assert result["recommended_apps"][0]["id"] == f"app-{language}"
assert result["recommended_apps"][0]["app_id"] == f"app-{language}"
mock_instance.get_recommended_apps_and_categories.assert_called_with(language, session=sqlite_session)
@patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
@ -262,14 +285,14 @@ class TestRecommendedAppServiceGetApp:
monkeypatch,
result=RecommendedAppPayload(id=app.id),
)
feature_lookup = MagicMock(side_effect=AssertionError("get_app must not inspect trial features"))
monkeypatch.setattr(service_module.FeatureService, "get_system_features", feature_lookup)
trial_policy = MagicMock(side_effect=AssertionError("get_app must not inspect trial policy"))
monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", trial_policy)
result = RecommendedAppService.get_app(app.id, session=sqlite_session)
assert result is app
retrieval_instance.get_recommend_app_detail.assert_called_once_with(app.id, session=sqlite_session)
feature_lookup.assert_not_called()
trial_policy.assert_not_called()
def test_returns_none_when_app_is_not_recommended(
self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
@ -288,21 +311,17 @@ class TestRecommendedAppServiceGetApp:
class TestRecommendedAppServiceGetDetail:
@patch("services.recommended_app_service.FeatureService", autospec=True)
@patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
@patch("services.recommended_app_service.dify_config")
def test_returns_retrieval_detail_when_trial_disabled(
self,
mock_config: MagicMock,
mock_factory_class: MagicMock,
mock_feature_service: MagicMock,
sqlite_session: Session,
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.ENABLE_TRIAL_APP = True
cases: list[tuple[str, RecommendedAppPayload]] = [
(
"complex-app",
@ -328,23 +347,20 @@ class TestRecommendedAppServiceGetDetail:
result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session)
assert result == expected
assert result is not None
assert result["can_trial"] is False
mock_instance.get_recommend_app_detail.assert_called_once_with(app_id, session=sqlite_session)
@patch("services.recommended_app_service.FeatureService", autospec=True)
@patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
@patch("services.recommended_app_service.dify_config")
def test_different_modes(
self,
mock_config: MagicMock,
mock_factory_class: MagicMock,
mock_feature_service: MagicMock,
sqlite_session: Session,
) -> None:
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.ENABLE_TRIAL_APP = True
for mode in ["remote", "builtin", "db"]:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode
detail = _app_detail(app_id="test-app", name=f"App from {mode}")
@ -363,21 +379,17 @@ class TestRecommendedAppServiceGetDetail:
class TestRecommendedAppServiceGetLearnDifyApps:
@patch("services.recommended_app_service.FeatureService", autospec=True)
@patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
@patch("services.recommended_app_service.dify_config")
def test_uses_configured_retrieval_source(
self,
mock_config: MagicMock,
mock_factory_class: MagicMock,
mock_feature_service: MagicMock,
sqlite_session: Session,
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.ENABLE_TRIAL_APP = True
expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_instance = MagicMock()
mock_instance.get_learn_dify_apps.return_value = {
@ -388,7 +400,7 @@ class TestRecommendedAppServiceGetLearnDifyApps:
result = RecommendedAppService.get_learn_dify_apps("en-US", session=sqlite_session)
assert result == {"recommended_apps": [expected_app]}
assert result == {"recommended_apps": [{**expected_app, "can_trial": False}]}
mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote")
mock_instance.get_learn_dify_apps.assert_called_once_with("en-US", session=sqlite_session)
@ -409,23 +421,14 @@ class TestRecommendedAppServiceGetLearnDifyApps:
"get_recommend_app_factory",
MagicMock(return_value=mock_retrieval_factory),
)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
)
can_trial_mock = MagicMock(return_value=True)
monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock)
monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=True))
trial_app_ids = MagicMock(return_value={"app-1"})
monkeypatch.setattr(RecommendedAppService, "_get_trial_app_ids", trial_app_ids)
result = RecommendedAppService.get_learn_dify_apps("en-US", session=sqlite_session)
assert result["recommended_apps"][0]["can_trial"] is True
can_trial_mock.assert_called_once_with(sqlite_session, "app-1")
trial_app_ids.assert_called_once_with(sqlite_session, ["app-1"])
# ── Integration tests: trial app features (real DB) ────────────────────
@ -435,22 +438,20 @@ class TestRecommendedAppServiceTrialFeatures:
def test_get_apps_should_not_query_trial_table_when_disabled(
self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
expected = AppsResponse(recommended_apps=[RecommendedAppPayload(app_id="app-1")], categories=["all"])
retrieval_instance, builtin_instance = _mock_factory_for_apps(monkeypatch, mode="remote", result=expected)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
),
upstream_result = AppsResponse(
recommended_apps=[RecommendedAppPayload(app_id="app-1", can_trial=True)], categories=["all"]
)
retrieval_instance, builtin_instance = _mock_factory_for_apps(
monkeypatch, mode="remote", result=upstream_result
)
monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=False))
trial_app_ids = MagicMock(side_effect=AssertionError("disabled trial must not query TrialApp"))
monkeypatch.setattr(RecommendedAppService, "_get_trial_app_ids", trial_app_ids)
result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session)
assert result == expected
assert result["recommended_apps"][0]["can_trial"] is False
trial_app_ids.assert_not_called()
retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US", session=sqlite_session)
builtin_instance.fetch_recommended_apps_from_builtin.assert_not_called()
@ -473,16 +474,7 @@ class TestRecommendedAppServiceTrialFeatures:
_, builtin_instance = _mock_factory_for_apps(
monkeypatch, mode="remote", result=remote_result, fallback_result=fallback_result
)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
)
monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=True))
result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP", session=sqlite_session)
@ -514,16 +506,7 @@ class TestRecommendedAppServiceTrialFeatures:
"get_recommend_app_factory",
MagicMock(return_value=retrieval_factory),
)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
)
monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=True))
result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session)
assert result is not None
@ -532,26 +515,25 @@ class TestRecommendedAppServiceTrialFeatures:
assert detail_result["id"] == app_id
assert detail_result["can_trial"] is has_trial_app
@patch("services.recommended_app_service.FeatureService", autospec=True)
@patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
@patch("services.recommended_app_service.dify_config")
def test_get_detail_returns_none_before_reading_trial_flag(
self,
mock_config: MagicMock,
mock_factory_class: MagicMock,
mock_feature_service: MagicMock,
sqlite_session: Session,
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_instance = MagicMock()
mock_instance.get_recommend_app_detail.return_value = None
mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)
result = RecommendedAppService.get_recommend_app_detail("nonexistent", session=sqlite_session)
trial_policy = MagicMock(side_effect=AssertionError("missing app must not inspect trial policy"))
with patch.object(RecommendedAppService, "is_trial_app_enabled", trial_policy):
result = RecommendedAppService.get_recommend_app_detail("nonexistent", session=sqlite_session)
assert result is None
mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent", session=sqlite_session)
mock_feature_service.get_system_features.assert_not_called()
trial_policy.assert_not_called()
def test_add_trial_app_record_increments_count_for_existing(self, sqlite_session: Session) -> None:
app_id = str(uuid.uuid4())

View File

@ -20,7 +20,7 @@ export type BannerListResponse = Array<BannerResponse>
export type RecommendedAppResponse = {
app?: RecommendedAppInfoResponse | null
app_id: string
can_trial?: boolean | null
can_trial: boolean
categories?: Array<string>
copyright?: string | null
custom_disclaimer?: string | null
@ -31,7 +31,7 @@ export type RecommendedAppResponse = {
}
export type RecommendedAppDetailResponse = {
can_trial?: boolean | null
can_trial: boolean
export_data: string
icon?: string | null
icon_background?: string | null
@ -71,7 +71,7 @@ export type LearnDifyAppListResponseWritable = {
export type RecommendedAppResponseWritable = {
app?: RecommendedAppInfoResponseWritable | null
app_id: string
can_trial?: boolean | null
can_trial: boolean
categories?: Array<string>
copyright?: string | null
custom_disclaimer?: string | null

View File

@ -6,7 +6,7 @@ import * as z from 'zod'
* RecommendedAppDetailResponse
*/
export const zRecommendedAppDetailResponse = z.object({
can_trial: z.boolean().nullish(),
can_trial: z.boolean(),
export_data: z.string(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
@ -56,7 +56,7 @@ export const zRecommendedAppInfoResponse = z.object({
export const zRecommendedAppResponse = z.object({
app: zRecommendedAppInfoResponse.nullish(),
app_id: z.string(),
can_trial: z.boolean().nullish(),
can_trial: z.boolean(),
categories: z.array(z.string()).optional(),
copyright: z.string().nullish(),
custom_disclaimer: z.string().nullish(),
@ -99,7 +99,7 @@ export const zRecommendedAppInfoResponseWritable = z.object({
export const zRecommendedAppResponseWritable = z.object({
app: zRecommendedAppInfoResponseWritable.nullish(),
app_id: z.string(),
can_trial: z.boolean().nullish(),
can_trial: z.boolean(),
categories: z.array(z.string()).optional(),
copyright: z.string().nullish(),
custom_disclaimer: z.string().nullish(),

View File

@ -18,7 +18,6 @@ export type SystemFeatureModel = {
enable_marketplace: boolean
enable_social_oauth_login: boolean
enable_step_by_step_tour: boolean
enable_trial_app: boolean
is_allow_register: boolean
is_email_setup: boolean
knowledge_fs_enabled: boolean

View File

@ -125,7 +125,6 @@ export const zSystemFeatureModel = z.object({
enable_marketplace: z.boolean().default(false),
enable_social_oauth_login: z.boolean().default(false),
enable_step_by_step_tour: z.boolean().default(false),
enable_trial_app: z.boolean().default(false),
is_allow_register: z.boolean().default(false),
is_email_setup: z.boolean().default(false),
knowledge_fs_enabled: z.boolean().default(false),

View File

@ -511,7 +511,6 @@ export type SystemFeatureModel = {
enable_marketplace: boolean
enable_social_oauth_login: boolean
enable_step_by_step_tour: boolean
enable_trial_app: boolean
is_allow_register: boolean
is_email_setup: boolean
knowledge_fs_enabled: boolean

View File

@ -772,7 +772,6 @@ export const zSystemFeatureModel = z.object({
enable_marketplace: z.boolean().default(false),
enable_social_oauth_login: z.boolean().default(false),
enable_step_by_step_tour: z.boolean().default(false),
enable_trial_app: z.boolean().default(false),
is_allow_register: z.boolean().default(false),
is_email_setup: z.boolean().default(false),
knowledge_fs_enabled: z.boolean().default(false),

View File

@ -266,6 +266,7 @@ describe('Apps', () => {
icon_background: '#fff',
mode: AppModeEnum.CHAT,
export_data: 'yaml-content',
can_trial: true,
})
})
@ -305,6 +306,16 @@ describe('Apps', () => {
expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument()
})
it('should close the template preview', async () => {
const user = userEvent.setup()
renderWithClient(<Apps />)
await user.click(screen.getByTestId('open-preview'))
await user.click(await screen.findByTestId('try-app-close'))
expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument()
})
it('should open the create modal from Learn Dify', async () => {
const user = userEvent.setup()
renderWithClient(<Apps />)

View File

@ -206,11 +206,11 @@ const Apps = () => {
onCreateLearnDify={handleCreateLearnDify}
onTryLearnDify={handleTryLearnDify}
/>
{isShowTryAppPanel && (
{isShowTryAppPanel && currentTryAppParams && (
<TryApp
appId={currentTryAppParams?.appId || ''}
app={currentTryAppParams?.app}
categories={currentTryAppParams?.app?.categories}
appId={currentTryAppParams.appId}
app={currentTryAppParams.app}
categories={currentTryAppParams.app.categories}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>

View File

@ -241,7 +241,6 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
const shouldCompleteHomeTourOnCreateRef = useRef(false)
const isSubmittingHomeTourCreateRef = useRef(false)
const wasHomeTryAppCreateGuideActiveRef = useRef(false)
const isShowTryAppPanel = !!currentTryApp
const shouldForceShowLearnDifyForTour =
activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID &&
!completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) &&
@ -550,12 +549,12 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
/>
)}
{isShowTryAppPanel && (
{currentTryApp && (
<TryApp
appId={currentTryApp?.appId || ''}
app={currentTryApp?.app}
appId={currentTryApp.appId}
app={currentTryApp.app}
canCreate={canCreateApp}
categories={currentTryApp?.app?.categories}
categories={currentTryApp.app.categories}
createButtonStepByStepTourTarget={
canCreateApp && isCurrentTryAppFromLearnDifyRef.current && !isShowCreateModal
? STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate

View File

@ -1,12 +1,21 @@
import type { ComponentProps } from 'react'
import type { App as ExploreApp } from '@/models/explore'
import type { TryAppInfo } from '@/service/try-app'
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import TryApp from '../index'
import TryAppComponent from '../index'
import { TypeEnum } from '../types'
const render = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
const defaultApp = { can_trial: true } as ExploreApp
function TryApp({
app = defaultApp,
...props
}: Omit<ComponentProps<typeof TryAppComponent>, 'app'> & {
app?: ExploreApp
}) {
return <TryAppComponent {...props} app={app} />
}
const mockUseGetTryAppInfo = vi.fn()
@ -143,6 +152,26 @@ describe('TryApp (main index.tsx)', () => {
})
describe('content rendering', () => {
it('uses app trial eligibility as the authoritative default tab', async () => {
const app = { can_trial: true } as ExploreApp
render(<TryApp appId="test-app-id" app={app} onClose={vi.fn()} onCreate={vi.fn()} />)
expect(await screen.findByTestId('app-component')).toBeInTheDocument()
})
it('defaults to details and disables trial when the app is ineligible', async () => {
const app = { can_trial: false } as ExploreApp
render(<TryApp appId="test-app-id" app={app} onClose={vi.fn()} onCreate={vi.fn()} />)
expect(await screen.findByTestId('preview-component')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.try' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('renders Tab component', async () => {
render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />)

View File

@ -1,17 +1,14 @@
/* eslint-disable style/multiline-ternary */
'use client'
import type { FC } from 'react'
import type { App as AppType } from '@/models/explore'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useGetTryAppInfo } from '@/service/use-try-app'
import App from './app'
import AppInfo from './app-info'
@ -20,7 +17,7 @@ import { TypeEnum } from './types'
type Props = Readonly<{
appId: string
app?: AppType
app: AppType
canCreate?: boolean
categories?: string[]
createButtonStepByStepTourTarget?: string
@ -28,7 +25,7 @@ type Props = Readonly<{
onCreate: () => void
}>
const TryApp: FC<Props> = ({
function TryApp({
appId,
app,
canCreate = true,
@ -36,11 +33,9 @@ const TryApp: FC<Props> = ({
createButtonStepByStepTourTarget,
onClose,
onCreate,
}) => {
}: Props) {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
const canUseTryTab = systemFeatures.deployment_edition === 'CLOUD' && (app ? isTrialApp : true)
const canUseTryTab = app.can_trial
const [type, setType] = useState<TypeEnum>(() => (canUseTryTab ? TypeEnum.TRY : TypeEnum.DETAIL))
const activeType = canUseTryTab ? type : TypeEnum.DETAIL
const { data: appDetail, isLoading, isError, error } = useGetTryAppInfo(appId)
@ -77,17 +72,15 @@ const TryApp: FC<Props> = ({
>
<div className="flex shrink-0 justify-between pl-4">
<TabsList>
{systemFeatures.deployment_edition === 'CLOUD' && (
<TabsTab
value={TypeEnum.TRY}
disabled={app ? !isTrialApp : false}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
>
<span className="system-md-semibold-uppercase">
{t(($) => $['tryApp.tabHeader.try'], { ns: 'explore' })}
</span>
</TabsTab>
)}
<TabsTab
value={TypeEnum.TRY}
disabled={!canUseTryTab}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
>
<span className="system-md-semibold-uppercase">
{t(($) => $['tryApp.tabHeader.try'], { ns: 'explore' })}
</span>
</TabsTab>
<TabsTab
value={TypeEnum.DETAIL}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
@ -109,11 +102,9 @@ const TryApp: FC<Props> = ({
</div>
{/* Main content */}
<div className="mt-2 flex h-0 grow justify-between space-x-2">
{systemFeatures.deployment_edition === 'CLOUD' && (
<TabsPanel value={TypeEnum.TRY} className="min-w-0 flex-1">
<App appId={appId} appDetail={appDetail} />
</TabsPanel>
)}
<TabsPanel value={TypeEnum.TRY} className="min-w-0 flex-1">
<App appId={appId} appDetail={appDetail} />
</TabsPanel>
<TabsPanel value={TypeEnum.DETAIL} className="min-w-0 flex-1">
<Preview appId={appId} appDetail={appDetail} />
</TabsPanel>

View File

@ -53,6 +53,7 @@ describe('explore service normalizers', () => {
icon_background: '',
mode: 'rag-pipeline',
export_data: 'kind: app',
can_trial: false,
})
await expect(fetchAppList()).resolves.toMatchObject({

View File

@ -40,7 +40,7 @@ type ExploreAppDetailResponse = {
icon_background: string
mode: string
export_data: string
can_trial?: boolean | null
can_trial: boolean
}
type InstalledAppsResponse = {
@ -141,7 +141,7 @@ const normalizeRecommendedApp = (app: RecommendedAppResponse): App => {
installed: false,
editable: false,
is_agent: false,
can_trial: app.can_trial ?? false,
can_trial: app.can_trial,
}
}

View File

@ -50,7 +50,6 @@ const baseSystemFeatures = {
},
rbac_enabled: false,
enable_creators_platform: false,
enable_trial_app: false,
enable_explore_banner: false,
enable_learn_app: true,
enable_step_by_step_tour: false,