mirror of
https://github.com/langgenius/dify.git
synced 2026-08-02 02:40:43 +08:00
refactor: move trial app capability out of system features (#39562)
This commit is contained in:
parent
99c1cb7781
commit
c61d33f91e
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
]
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
]
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 />)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()} />)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user