diff --git a/api/.importlinter b/api/.importlinter index 6344502ac66..2de7120977d 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -265,7 +265,14 @@ forbidden_modules = models repositories services.feature_service - services.recommend_app - services.recommended_app_query_compat + services.recommended_app_catalog_gateway sqlalchemy werkzeug + +[importlinter:contract:recommended-app-catalog-gateway-boundary] +name = Recommended app catalog gateway does not depend on Flask +type = forbidden +source_modules = + services.recommended_app_catalog_gateway +forbidden_modules = + flask diff --git a/api/constants/recommended_apps.json b/api/constants/recommended_apps.json index b7db9d64d06..6e9b6856542 100644 --- a/api/constants/recommended_apps.json +++ b/api/constants/recommended_apps.json @@ -166,6 +166,7 @@ "categories": ["Workflow"], "copyright": null, "description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.", + "is_learn_dify": true, "is_listed": true, "position": 5, "privacy_policy": null @@ -294,6 +295,7 @@ "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.", + "is_learn_dify": true, "is_listed": true, "position": 4, "privacy_policy": null @@ -326,6 +328,7 @@ "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, A chatbot with a knowledge base. ", + "is_learn_dify": true, "is_listed": true, "position": 4, "privacy_policy": null diff --git a/api/controllers/console/app/wraps.py b/api/controllers/console/app/wraps.py index 45e8015f723..e844348de85 100644 --- a/api/controllers/console/app/wraps.py +++ b/api/controllers/console/app/wraps.py @@ -1,7 +1,7 @@ """Controller decorators for console app resources. `get_app_model` still supports legacy handlers backed by Flask-SQLAlchemy's -scoped session. Trial app handlers compose `get_app_model_with_trial` under +scoped session. Preview handlers compose `get_previewable_app_model` under `controllers.common.session.with_session`; preview admission finishes before the request Session loads the accepted App. """ @@ -27,7 +27,7 @@ from services.app_service import AppService __all__ = [ "agent_manage_required_for_agent_app", "get_app_model", - "get_app_model_with_trial", + "get_previewable_app_model", "with_session", ] @@ -50,7 +50,7 @@ def _load_app_model_from_scoped_session(app_id: str) -> App | None: return app_model -def _load_app_model_with_trial(session: Session, app_id: str) -> App | None: +def _load_previewable_app_model(session: Session, app_id: str) -> App | None: """Load a normal App after preview admission completes outside the request Session.""" if not application_services().recommended_app_queries.is_previewable(app_id): return None @@ -184,7 +184,7 @@ def get_app_model[**P, R]( @overload -def get_app_model_with_trial[**P, R]( +def get_previewable_app_model[**P, R]( view: Callable[P, R], *, mode: AppMode | list[AppMode] | None = None, @@ -192,19 +192,25 @@ def get_app_model_with_trial[**P, R]( @overload -def get_app_model_with_trial[**P, R]( +def get_previewable_app_model[**P, R]( view: None = None, *, mode: AppMode | list[AppMode] | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... -def get_app_model_with_trial[**P, R]( +def get_previewable_app_model[**P, R]( view: Callable[P, R] | None = None, *, mode: AppMode | list[AppMode] | None = None, ) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: - """Inject a trial-registered or recommended App using the Session supplied by `with_session`.""" + """Inject an App authorized for read-only template preview. + + Preview reads accept either an explicit TrialApp registration or membership + in the recommended catalog. This does not grant trial execution, which is + separately protected by TrialAppResource's feature, registration, and quota + checks. + """ def decorator(view_func: Callable[P, R]) -> Callable[P, R]: @wraps(view_func) @@ -219,8 +225,8 @@ def get_app_model_with_trial[**P, R]( session = _get_injected_session(args) if session is None: - raise RuntimeError("get_app_model_with_trial requires @with_session") - app_model = _load_app_model_with_trial(session, app_id) + raise RuntimeError("get_previewable_app_model requires @with_session") + app_model = _load_previewable_app_model(session, app_id) if not app_model: raise AppNotFoundError() diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index a8aa9c5619e..d5388c7b542 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -38,7 +38,7 @@ from controllers.console.app.error import ( SpeechToTextDisabledError, UnsupportedAudioTypeError, ) -from controllers.console.app.wraps import get_app_model_with_trial, with_session +from controllers.console.app.wraps import get_previewable_app_model, with_session from controllers.console.explore.error import ( AppSuggestedQuestionsAfterAnswerDisabledError, NotChatAppError, @@ -823,7 +823,7 @@ class TrialSitApi(Resource): @console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__]) @with_session(write=False) - @get_app_model_with_trial(None) + @get_previewable_app_model(None) def get(self, session: Session, app_model): """Retrieve app site info. @@ -847,7 +847,7 @@ class TrialAppParameterApi(Resource): @console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__]) @with_session(write=False) - @get_app_model_with_trial(None) + @get_previewable_app_model(None) def get(self, session: Session, app_model): """Retrieve app parameters.""" @@ -865,7 +865,7 @@ class TrialAppParameterApi(Resource): class AppApi(Resource): @console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__]) @with_session(write=False) - @get_app_model_with_trial(None) + @get_previewable_app_model(None) def get(self, session: Session, app_model): """Get app detail""" @@ -881,7 +881,7 @@ class AppApi(Resource): class AppWorkflowApi(Resource): @console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__]) @with_session(write=False) - @get_app_model_with_trial(None) + @get_previewable_app_model(None) def get(self, session: Session, app_model): """Get workflow detail""" if not app_model.workflow_id: @@ -901,7 +901,7 @@ class DatasetListApi(Resource): @console_ns.doc(params=query_params_from_model(TrialDatasetListQuery)) @console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__]) @with_session(write=False) - @get_app_model_with_trial(None) + @get_previewable_app_model(None) def get(self, session: Session, app_model): page = request.args.get("page", default=1, type=int) limit = request.args.get("limit", default=20, type=int) diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 42d616053b8..37e4fcdcda9 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -21,6 +21,7 @@ from repositories.app_definition_query_repository import AppDefinitionQueryRepos from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourceApiKeyAuthBindingRepository from repositories.explore_banner_query_repository import ExploreBannerQueryRepository from repositories.installation_state_repository import InstallationStateRepository +from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository from repositories.tag_repository import TagRepository from repositories.trial_app_query_repository import TrialAppQueryRepository from repositories.trial_app_usage_repository import TrialAppUsageRepository @@ -49,7 +50,11 @@ from services.feature_service import FeatureService from services.feature_service_gateway import FeatureServiceGateway from services.file_service import FileService from services.init_validation_service import InitValidationService -from services.recommended_app_query_compat import LegacyRecommendedAppCatalogGateway +from services.recommended_app_catalog_gateway import ( + BuiltinRecommendedAppCatalogGateway, + RecommendedAppCatalogRouter, + RemoteRecommendedAppCatalogGateway, +) from services.recommended_app_query_service import RecommendedAppQueryService from services.schema_definition_service import SchemaDefinitionService from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner @@ -124,6 +129,14 @@ def build_application_services( app_definition_repository = AppDefinitionQueryRepository(session_factory=database_client) feature_gateway = FeatureServiceGateway() trial_app_enabled = FeatureService.is_trial_app_enabled() + database_catalog = DatabaseRecommendedAppCatalogRepository(session_factory=database_client, redis=redis) + builtin_catalog = BuiltinRecommendedAppCatalogGateway() + remote_catalog = RemoteRecommendedAppCatalogGateway() + recommended_app_catalog = RecommendedAppCatalogRouter( + remote=remote_catalog, + database=database_catalog, + builtin=builtin_catalog, + ) return ApplicationServices( accounts=AccountServices( profile=AccountProfileService(accounts=SQLAlchemyAccountRepository(database_client)), @@ -184,7 +197,7 @@ def build_application_services( expected_password=initialization_password, ), recommended_app_queries=RecommendedAppQueryService( - catalog=LegacyRecommendedAppCatalogGateway(session_factory=database_client), + catalog=recommended_app_catalog, trial_apps=TrialAppQueryRepository(session_factory=database_client), trial_enabled=trial_app_enabled, ), diff --git a/api/repositories/recommended_app_catalog_repository.py b/api/repositories/recommended_app_catalog_repository.py new file mode 100644 index 00000000000..9f1cd0dd408 --- /dev/null +++ b/api/repositories/recommended_app_catalog_repository.py @@ -0,0 +1,191 @@ +"""Database-backed recommended app catalog adapter.""" + +import json +import logging +from collections.abc import Sequence +from typing import cast, override + +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from constants.languages import languages +from extensions.ext_redis import RedisClientWrapper +from models.model import App, RecommendedApp +from services.app_dsl_service import AppDslService +from services.recommended_app_query_service import ( + RecommendedAppCatalogPage, + RecommendedAppCatalogQuery, + RecommendedAppDetailRecord, + RecommendedAppInfoRecord, + RecommendedAppRecord, +) + +logger = logging.getLogger(__name__) + +# Keep the legacy "explore" Redis key: Explore was the former UI name for this recommended-app surface. +_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order" + + +class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery): + def __init__(self, session_factory: sessionmaker[Session], *, redis: RedisClientWrapper) -> None: + self._session_factory = session_factory + self._redis = redis + + @override + def list_recommended(self, language: str) -> RecommendedAppCatalogPage: + with self._session_factory() as session: + recommended_apps = self._list_rows(language, session=session) + if not recommended_apps: + recommended_apps = self._list_rows(languages[0], session=session) + records, categories = self._map_rows(recommended_apps, session=session) + return RecommendedAppCatalogPage( + recommended_apps=records, + categories=tuple(self._order_categories(categories, language)), + ) + + @override + def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: + with self._session_factory() as session: + recommended_apps = self._list_rows(language, session=session, is_learn_dify=True) + if not recommended_apps and language != languages[0]: + recommended_apps = self._list_rows(languages[0], session=session, is_learn_dify=True) + records, _ = self._map_rows(recommended_apps, session=session) + return RecommendedAppCatalogPage(recommended_apps=records, categories=()) + + @override + def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: + with self._session_factory() as session: + return self._get_detail(app_id, session=session) + + @override + def contains(self, app_id: str) -> bool: + with self._session_factory() as session: + return ( + session.scalar( + select(RecommendedApp.app_id) + .join(App, App.id == RecommendedApp.app_id) + .where( + RecommendedApp.app_id == app_id, + RecommendedApp.is_listed.is_(True), + App.is_public.is_(True), + ) + .limit(1) + ) + is not None + ) + + def _order_categories(self, categories: set[str], language: str) -> list[str]: + try: + raw_categories = self._redis.get(f"{_CATEGORY_ORDER_KEY_PREFIX}:{language}") + except Exception: + logger.exception("Failed to read recommended app category order from Redis.") + return sorted(categories) + + if not raw_categories: + return sorted(categories) + if isinstance(raw_categories, bytes): + raw_categories = raw_categories.decode("utf-8") + + try: + configured_order = json.loads(raw_categories) + except (TypeError, json.JSONDecodeError): + logger.warning("Invalid recommended app category order payload for language %s.", language) + return sorted(categories) + + if not isinstance(configured_order, list): + return sorted(categories) + + string_order = [category for category in configured_order if isinstance(category, str)] + return string_order or sorted(categories) + + @staticmethod + def _list_rows( + language: str, + *, + session: Session, + is_learn_dify: bool | None = None, + ) -> list[RecommendedApp]: + filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language] + if is_learn_dify is not None: + filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify)) + return list(session.scalars(select(RecommendedApp).where(*filters)).all()) + + @classmethod + def _map_rows( + cls, + recommended_apps: Sequence[RecommendedApp], + *, + session: Session, + ) -> tuple[tuple[RecommendedAppRecord, ...], set[str]]: + categories: set[str] = set() + records: list[RecommendedAppRecord] = [] + for recommended_app in recommended_apps: + app = session.get(App, recommended_app.app_id) + if app is None or not app.is_public: + continue + + site = app.site_with_session(session=session) + if site is None: + continue + + app_categories = cls._as_string_tuple(recommended_app.categories or (), field="categories") + records.append( + RecommendedAppRecord( + app=RecommendedAppInfoRecord( + id=app.id, + name=app.name, + mode=app.mode.value, + icon=cast(str | None, app.icon), + icon_type=app.icon_type.value if app.icon_type is not None else None, + icon_background=app.icon_background, + ), + app_id=recommended_app.app_id, + description=cast(str | None, site.description), + copyright=cast(str | None, site.copyright), + privacy_policy=cast(str | None, site.privacy_policy), + custom_disclaimer=cast(str | None, site.custom_disclaimer), + categories=app_categories, + position=recommended_app.position, + is_listed=recommended_app.is_listed, + ) + ) + categories.update(app_categories) + + return tuple(records), categories + + @staticmethod + def _get_detail(app_id: str, *, session: Session) -> RecommendedAppDetailRecord | None: + recommended_app = session.scalar( + select(RecommendedApp) + .where( + RecommendedApp.is_listed.is_(True), + RecommendedApp.app_id == app_id, + ) + .limit(1) + ) + if recommended_app is None: + return None + + app = session.get(App, app_id) + if app is None or not app.is_public: + return None + + return RecommendedAppDetailRecord( + id=app.id, + name=app.name, + icon=cast(str | None, app.icon), + icon_background=app.icon_background, + mode=app.mode.value, + export_data=AppDslService.export_dsl(app_model=app, session=session), + ) + + @staticmethod + def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError(f"{field} must be a sequence of strings") + items: list[str] = [] + for item in value: + if not isinstance(item, str): + raise TypeError(f"{field} must contain only strings") + items.append(item) + return tuple(items) diff --git a/api/services/recommend_app/__init__.py b/api/services/recommend_app/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/services/recommend_app/buildin/__init__.py b/api/services/recommend_app/buildin/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/services/recommend_app/buildin/buildin_retrieval.py b/api/services/recommend_app/buildin/buildin_retrieval.py deleted file mode 100644 index d29d754b67e..00000000000 --- a/api/services/recommend_app/buildin/buildin_retrieval.py +++ /dev/null @@ -1,76 +0,0 @@ -import json -from os import path -from pathlib import Path -from typing import Any, override - -from flask import current_app -from sqlalchemy.orm import Session - -from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval -from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase -from services.recommend_app.recommend_app_type import RecommendAppType - - -class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase): - """ - Retrieval recommended app from buildin, the location is constants/recommended_apps.json - """ - - builtin_data: dict[str, Any] | None = None - - @override - def get_type(self) -> str: - return RecommendAppType.BUILDIN - - @override - def get_recommended_apps_and_categories(self, language: str, *, session: Session): - del session - result = self.fetch_recommended_apps_from_builtin(language) - return result - - @override - def get_learn_dify_apps(self, language: str, *, session: Session): - result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session) - return result - - @override - def get_recommend_app_detail(self, app_id: str, *, session: Session): - del session - result = self.fetch_recommended_app_detail_from_builtin(app_id) - return result - - @classmethod - def _get_builtin_data(cls): - """ - Get builtin data. - :return: - """ - if cls.builtin_data: - return cls.builtin_data - - root_path = current_app.root_path - cls.builtin_data = json.loads( - Path(path.join(root_path, "constants", "recommended_apps.json")).read_text(encoding="utf-8") - ) - - return cls.builtin_data or {} - - @classmethod - def fetch_recommended_apps_from_builtin(cls, language: str): - """ - Fetch recommended apps from builtin. - :param language: language - :return: - """ - builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data() - return builtin_data.get("recommended_apps", {}).get(language, {}) - - @classmethod - def fetch_recommended_app_detail_from_builtin(cls, app_id: str) -> dict[str, Any] | None: - """ - Fetch recommended app detail from builtin. - :param app_id: App ID - :return: - """ - builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data() - return builtin_data.get("app_details", {}).get(app_id) diff --git a/api/services/recommend_app/category_order.py b/api/services/recommend_app/category_order.py deleted file mode 100644 index be6b112aa40..00000000000 --- a/api/services/recommend_app/category_order.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Apply Redis-backed category ordering for DB-backed Explore apps.""" - -import json -import logging -from collections.abc import Collection -from typing import Any - -from extensions.ext_redis import redis_client - -logger = logging.getLogger(__name__) - -EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order" - - -def _category_order_key(language: str) -> str: - return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}" - - -def get_explore_app_category_order(language: str) -> list[str]: - try: - raw_categories = redis_client.get(_category_order_key(language)) - except Exception: - logger.exception("Failed to read explore app category order from Redis.") - return [] - - if not raw_categories: - return [] - - if isinstance(raw_categories, bytes): - raw_categories = raw_categories.decode("utf-8") - - try: - categories: Any = json.loads(raw_categories) - except (TypeError, json.JSONDecodeError): - logger.warning("Invalid explore app category order payload for language %s.", language) - return [] - - if not isinstance(categories, list): - return [] - - return [category for category in categories if isinstance(category, str)] - - -def order_categories(categories: Collection[str], language: str) -> list[str]: - configured_order = get_explore_app_category_order(language) - if configured_order: - return configured_order - - return sorted(categories) diff --git a/api/services/recommend_app/database/__init__.py b/api/services/recommend_app/database/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py deleted file mode 100644 index a234626813d..00000000000 --- a/api/services/recommend_app/database/database_retrieval.py +++ /dev/null @@ -1,179 +0,0 @@ -from typing import Any, NotRequired, TypedDict, override - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from constants.languages import languages -from models.model import App, RecommendedApp -from services.app_dsl_service import AppDslService -from services.recommend_app.category_order import order_categories -from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase -from services.recommend_app.recommend_app_type import RecommendAppType - - -class RecommendedAppItemDict(TypedDict): - id: str - app: App | None - app_id: str - description: Any - copyright: Any - privacy_policy: Any - custom_disclaimer: str - categories: list[str] - position: int - is_listed: bool - can_trial: NotRequired[bool] - - -class RecommendedAppsResultDict(TypedDict): - recommended_apps: list[RecommendedAppItemDict] - categories: list[str] - - -class RecommendedAppDetailDict(TypedDict): - id: str - name: str - icon: Any - icon_background: str | None - mode: str - export_data: str - - -class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): - """ - Retrieval recommended app from database - """ - - @override - def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> RecommendedAppsResultDict: - result = self.fetch_recommended_apps_from_db(language, session=session) - return result - - @override - def get_learn_dify_apps(self, language: str, *, session: Session) -> RecommendedAppsResultDict: - result = self.fetch_learn_dify_apps_from_db(language, session=session) - return result - - @override - def get_recommend_app_detail(self, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None: - result = self.fetch_recommended_app_detail_from_db(app_id, session=session) - return result - - @override - def get_type(self) -> str: - return RecommendAppType.DATABASE - - @classmethod - def fetch_recommended_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict: - """ - Fetch recommended apps from db. - :param language: language - :return: - """ - recommended_apps = cls._fetch_listed_recommended_apps(language, session=session) - - if len(recommended_apps) == 0: - recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session) - - return cls._format_recommended_apps(recommended_apps, language, session=session) - - @classmethod - def fetch_learn_dify_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict: - """ - Fetch listed recommended apps explicitly marked for the Learn Dify section. - :param language: language - :return: - """ - recommended_apps = cls._fetch_listed_recommended_apps(language, session=session, is_learn_dify=True) - - if len(recommended_apps) == 0 and language != languages[0]: - recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session, is_learn_dify=True) - - return cls._format_recommended_apps(recommended_apps, language, session=session) - - @classmethod - def _fetch_listed_recommended_apps( - cls, language: str, *, session: Session, is_learn_dify: bool | None = None - ) -> list[RecommendedApp]: - filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language] - if is_learn_dify is not None: - filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify)) - - return list(session.scalars(select(RecommendedApp).where(*filters)).all()) - - @classmethod - def _format_recommended_apps( - cls, - recommended_apps: list[RecommendedApp], - language: str, - *, - session: Session, - ) -> RecommendedAppsResultDict: - """ - Serialize DB recommended app rows into the Explore list response shape. - :param recommended_apps: recommended app rows - :param language: language used for category ordering - :return: - """ - - categories = set() - recommended_apps_result: list[RecommendedAppItemDict] = [] - for recommended_app in recommended_apps: - app = session.get(App, recommended_app.app_id) - if not app or not app.is_public: - continue - - site = app.site_with_session(session=session) - if not site: - continue - - app_categories = recommended_app.categories or [] - recommended_app_result: RecommendedAppItemDict = { - "id": recommended_app.id, - "app": app, - "app_id": recommended_app.app_id, - "description": site.description, - "copyright": site.copyright, - "privacy_policy": site.privacy_policy, - "custom_disclaimer": site.custom_disclaimer, - "categories": app_categories, - "position": recommended_app.position, - "is_listed": recommended_app.is_listed, - } - recommended_apps_result.append(recommended_app_result) - - categories.update(app_categories) - - return RecommendedAppsResultDict( - recommended_apps=recommended_apps_result, - categories=order_categories(categories, language), - ) - - @classmethod - def fetch_recommended_app_detail_from_db(cls, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None: - """ - Fetch recommended app detail from db. - :param app_id: App ID - :return: - """ - # is in public recommended list - recommended_app = session.scalar( - select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.app_id == app_id).limit(1) - ) - - if not recommended_app: - return None - - # get app detail - app_model = session.get(App, app_id) - if not app_model or not app_model.is_public: - return None - - return RecommendedAppDetailDict( - id=app_model.id, - name=app_model.name, - icon=app_model.icon, - icon_background=app_model.icon_background, - mode=app_model.mode, - export_data=AppDslService.export_dsl(app_model=app_model, session=session), - ) diff --git a/api/services/recommend_app/recommend_app_base.py b/api/services/recommend_app/recommend_app_base.py deleted file mode 100644 index 821ad476c42..00000000000 --- a/api/services/recommend_app/recommend_app_base.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Any, Protocol - -from sqlalchemy.orm import Session - - -class RecommendAppRetrievalBase(Protocol): - """Interface for recommend app retrieval.""" - - def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> Any: ... - - def get_learn_dify_apps(self, language: str, *, session: Session) -> Any: ... - - def get_recommend_app_detail(self, app_id: str, *, session: Session) -> Any: ... - - def get_type(self) -> str: ... diff --git a/api/services/recommend_app/recommend_app_factory.py b/api/services/recommend_app/recommend_app_factory.py deleted file mode 100644 index e53667c0b06..00000000000 --- a/api/services/recommend_app/recommend_app_factory.py +++ /dev/null @@ -1,23 +0,0 @@ -from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval -from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval -from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase -from services.recommend_app.recommend_app_type import RecommendAppType -from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval - - -class RecommendAppRetrievalFactory: - @staticmethod - def get_recommend_app_factory(mode: str) -> type[RecommendAppRetrievalBase]: - match mode: - case RecommendAppType.REMOTE: - return RemoteRecommendAppRetrieval - case RecommendAppType.DATABASE: - return DatabaseRecommendAppRetrieval - case RecommendAppType.BUILDIN: - return BuildInRecommendAppRetrieval - case _: - raise ValueError(f"invalid fetch recommended apps mode: {mode}") - - @staticmethod - def get_buildin_recommend_app_retrieval(): - return BuildInRecommendAppRetrieval diff --git a/api/services/recommend_app/recommend_app_type.py b/api/services/recommend_app/recommend_app_type.py deleted file mode 100644 index e60e435b3a0..00000000000 --- a/api/services/recommend_app/recommend_app_type.py +++ /dev/null @@ -1,7 +0,0 @@ -from enum import StrEnum - - -class RecommendAppType(StrEnum): - REMOTE = "remote" - BUILDIN = "builtin" - DATABASE = "db" diff --git a/api/services/recommend_app/remote/__init__.py b/api/services/recommend_app/remote/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/services/recommend_app/remote/remote_retrieval.py b/api/services/recommend_app/remote/remote_retrieval.py deleted file mode 100644 index d30306382ff..00000000000 --- a/api/services/recommend_app/remote/remote_retrieval.py +++ /dev/null @@ -1,173 +0,0 @@ -import logging -import threading -from typing import Any, override - -import httpx -from cachetools import TTLCache -from flask import has_request_context, request -from sqlalchemy.orm import Session - -from configs import dify_config -from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval -from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval -from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase -from services.recommend_app.recommend_app_type import RecommendAppType - -logger = logging.getLogger(__name__) - -_REMOTE_FETCH_CACHE_MAXSIZE = 64 -_remote_fetch_cache: TTLCache[tuple[str, str], dict[str, Any]] | None = None -_remote_fetch_cache_ttl: int | None = None -_remote_fetch_cache_lock = threading.Lock() - - -def _current_origin_headers() -> dict[str, str]: - origin = request.headers.get("Origin") if has_request_context() else None - if origin: - return {"Origin": origin} - - console_web_url = getattr(dify_config, "CONSOLE_WEB_URL", "") - if not isinstance(console_web_url, str) or not console_web_url: - return {} - return {"Origin": console_web_url} - - -def _remote_fetch_cache_key(url: str, headers: dict[str, str]) -> tuple[str, str]: - return url, headers.get("Origin", "") - - -def _hosted_fetch_cache_ttl() -> int: - ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL - if isinstance(ttl, int) and not isinstance(ttl, bool): - return ttl - return 600 - - -def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], dict[str, Any]] | None: - ttl = _hosted_fetch_cache_ttl() - if ttl <= 0: - return None - - global _remote_fetch_cache, _remote_fetch_cache_ttl - if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl: - with _remote_fetch_cache_lock: - if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl: - _remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl) - _remote_fetch_cache_ttl = ttl - return _remote_fetch_cache - - -def clear_remote_fetch_cache() -> None: - """Reset the in-memory remote fetch cache (used by tests).""" - global _remote_fetch_cache, _remote_fetch_cache_ttl - with _remote_fetch_cache_lock: - _remote_fetch_cache = None - _remote_fetch_cache_ttl = None - - -def _fetch_remote_payload(url: str) -> tuple[int, dict[str, Any] | None]: - headers = _current_origin_headers() - cache_key = _remote_fetch_cache_key(url, headers) - cache = _get_remote_fetch_cache() - if cache is not None: - with _remote_fetch_cache_lock: - cached = cache.get(cache_key) - if cached is not None: - return 200, cached - - response = httpx.get(url, headers=headers, timeout=httpx.Timeout(10.0, connect=3.0)) - status_code = response.status_code - if status_code != 200: - return status_code, None - - result: dict[str, Any] = response.json() - if cache is not None: - with _remote_fetch_cache_lock: - cache[cache_key] = result - return status_code, result - - -class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): - """ - Retrieval recommended app from dify official. - - The remote `/apps` payload is already curated for display, including category order. - Keep the response order intact so Explore matches the template service. - """ - - @override - def get_recommend_app_detail(self, app_id: str, *, session: Session): - del session - try: - result = self.fetch_recommended_app_detail_from_dify_official(app_id) - except Exception as e: - logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", e) - result = BuildInRecommendAppRetrieval.fetch_recommended_app_detail_from_builtin(app_id) - return result - - @override - def get_recommended_apps_and_categories(self, language: str, *, session: Session): - del session - try: - result = self.fetch_recommended_apps_from_dify_official(language) - except Exception as e: - logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", e) - result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin(language) - return result - - @override - def get_learn_dify_apps(self, language: str, *, session: Session): - try: - result = self.fetch_learn_dify_apps_from_dify_official(language) - except Exception as e: - logger.warning("fetch learn dify apps from dify official failed: %s, switch to database.", e) - result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session) - return result - - @override - def get_type(self) -> str: - return RecommendAppType.REMOTE - - @classmethod - def fetch_recommended_app_detail_from_dify_official(cls, app_id: str) -> dict[str, Any] | None: - """ - Fetch recommended app detail from dify official. - :param app_id: App ID - :return: - """ - domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN - url = f"{domain}/apps/{app_id}" - status_code, data = _fetch_remote_payload(url) - if status_code != 200: - return None - return data - - @classmethod - def fetch_recommended_apps_from_dify_official(cls, language: str): - """ - Fetch recommended apps from dify official. - :param language: language - :return: - """ - domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN - url = f"{domain}/apps?language={language}" - status_code, result = _fetch_remote_payload(url) - if status_code != 200: - raise ValueError(f"fetch recommended apps failed, status code: {status_code}") - - return result - - @classmethod - def fetch_learn_dify_apps_from_dify_official(cls, language: str): - """ - Fetch Learn Dify apps from dify official. - :param language: language - :return: - """ - domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN - url = f"{domain}/apps/learn-dify?language={language}" - status_code, result = _fetch_remote_payload(url) - if status_code != 200: - raise ValueError(f"fetch learn dify apps failed, status code: {status_code}") - - return result diff --git a/api/services/recommended_app_catalog_gateway.py b/api/services/recommended_app_catalog_gateway.py new file mode 100644 index 00000000000..798ec9756a5 --- /dev/null +++ b/api/services/recommended_app_catalog_gateway.py @@ -0,0 +1,376 @@ +"""Typed remote and built-in adapters for the recommended app catalog.""" + +import json +import logging +import threading +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import cast, override + +import httpx +from cachetools import TTLCache + +from configs import dify_config +from services.recommended_app_query_service import ( + RecommendedAppCatalogPage, + RecommendedAppCatalogQuery, + RecommendedAppDetailRecord, + RecommendedAppInfoRecord, + RecommendedAppRecord, +) + +logger = logging.getLogger(__name__) + +_BUILTIN_FALLBACK_LANGUAGE = "en-US" +_BUILTIN_CATALOG_PATH = Path(__file__).resolve().parents[1] / "constants" / "recommended_apps.json" +_REMOTE_FETCH_CACHE_MAXSIZE = 64 +_remote_fetch_cache: TTLCache[tuple[str, str], object] | None = None +_remote_fetch_cache_ttl: int | None = None +_remote_fetch_cache_lock = threading.Lock() + + +def _hosted_fetch_cache_ttl() -> int: + ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL + if isinstance(ttl, int) and not isinstance(ttl, bool): + return ttl + return 600 + + +def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], object] | None: + ttl = _hosted_fetch_cache_ttl() + if ttl <= 0: + return None + + global _remote_fetch_cache, _remote_fetch_cache_ttl + if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl: + with _remote_fetch_cache_lock: + if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl: + _remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl) + _remote_fetch_cache_ttl = ttl + return _remote_fetch_cache + + +def clear_remote_fetch_cache() -> None: + """Reset the in-memory remote fetch cache (used by tests).""" + global _remote_fetch_cache, _remote_fetch_cache_ttl + with _remote_fetch_cache_lock: + _remote_fetch_cache = None + _remote_fetch_cache_ttl = None + + +class _RecommendedAppSourceUnavailableError(Exception): + pass + + +class BuiltinRecommendedAppCatalogGateway(RecommendedAppCatalogQuery): + def __init__(self) -> None: + self._data: Mapping[str, object] | None = None + + @override + def list_recommended(self, language: str) -> RecommendedAppCatalogPage: + return _map_recommended_page(self._raw_page(language)) + + @override + def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: + return _map_learn_dify_page(self._raw_learn_dify_page(language)) + + @override + def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: + detail = self._raw_detail(app_id) + if detail is None: + return None + return _map_detail(_as_mapping(detail, field="recommended app detail")) + + @override + def contains(self, app_id: str) -> bool: + return self._raw_detail(app_id) is not None + + def _raw_page(self, language: str) -> Mapping[str, object]: + pages = _as_mapping(self._get_data().get("recommended_apps", {}), field="recommended_apps") + return _as_mapping(pages.get(language, {}), field="recommended app page") + + def _raw_learn_dify_page(self, language: str) -> Mapping[str, object]: + apps = self._raw_learn_dify_apps(language) + if not apps and language != _BUILTIN_FALLBACK_LANGUAGE: + apps = self._raw_learn_dify_apps(_BUILTIN_FALLBACK_LANGUAGE) + return {"recommended_apps": apps} + + def _raw_learn_dify_apps(self, language: str) -> tuple[object, ...]: + page = self._raw_page(language) + return tuple( + app + for app in _as_sequence(page.get("recommended_apps", ()), field="apps") + if _as_mapping(app, field="recommended app").get("is_learn_dify") is True + ) + + def _raw_detail(self, app_id: str) -> object | None: + details = _as_mapping(self._get_data().get("app_details", {}), field="app_details") + return details.get(app_id) + + def _get_data(self) -> Mapping[str, object]: + if self._data is None: + loaded = json.loads(_BUILTIN_CATALOG_PATH.read_text(encoding="utf-8")) + self._data = _as_mapping(loaded, field="built-in recommended app catalog") + return self._data + + +class RemoteRecommendedAppCatalogGateway(RecommendedAppCatalogQuery): + @override + def list_recommended(self, language: str) -> RecommendedAppCatalogPage: + result = self._fetch(lambda: self._fetch_page(language)) + return _map_recommended_page(_as_mapping(result, field="recommended app page")) + + @override + def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: + result = self._fetch(lambda: self._fetch_learn_dify_page(language)) + return _map_learn_dify_page(_as_mapping(result, field="Learn Dify app page")) + + @override + def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: + detail = self._fetch(lambda: self._fetch_detail(app_id)) + if detail is None: + return None + return _map_detail(_as_mapping(detail, field="recommended app detail")) + + @override + def contains(self, app_id: str) -> bool: + detail = self._fetch(lambda: self._fetch_detail(app_id)) + return detail is not None + + def _fetch_detail(self, app_id: str) -> object | None: + status_code, detail = self._get_payload(f"/apps/{app_id}") + if status_code != 200: + # Preserve the legacy detail contract: only request or decoding + # failures use the bundled fallback; HTTP responses are authoritative. + return None + return detail + + def _fetch_page(self, language: str) -> object: + status_code, page = self._get_payload(f"/apps?language={language}") + if status_code != 200: + raise ValueError(f"fetch recommended apps failed, status code: {status_code}") + return page + + def _fetch_learn_dify_page(self, language: str) -> object: + status_code, page = self._get_payload(f"/apps/learn-dify?language={language}") + if status_code != 200: + raise ValueError(f"fetch learn dify apps failed, status code: {status_code}") + return page + + @staticmethod + def _get_payload(path: str) -> tuple[int, object]: + origin = dify_config.CONSOLE_WEB_URL + + url = f"{dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN}{path}" + headers = {"Origin": origin} if origin else {} + cache_key = (url, origin) + cache = _get_remote_fetch_cache() + if cache is not None: + with _remote_fetch_cache_lock: + cached = cache.get(cache_key) + if cached is not None: + return 200, cached + + response = httpx.get( + url, + headers=headers, + timeout=httpx.Timeout(10.0, connect=3.0), + ) + if response.status_code != 200: + return response.status_code, None + + result = response.json() + if cache is not None: + with _remote_fetch_cache_lock: + cache[cache_key] = result + return response.status_code, result + + @staticmethod + def _fetch[T](fetch: Callable[[], T]) -> T: + try: + return fetch() + except Exception as error: + raise _RecommendedAppSourceUnavailableError(str(error)) from error + + +class RecommendedAppCatalogRouter(RecommendedAppCatalogQuery): + def __init__( + self, + *, + remote: RecommendedAppCatalogQuery, + database: RecommendedAppCatalogQuery, + builtin: RecommendedAppCatalogQuery, + ) -> None: + self._remote = remote + self._builtin = builtin + self._sources: dict[str, RecommendedAppCatalogQuery] = { + "remote": remote, + "db": database, + "builtin": builtin, + } + + @override + def list_recommended(self, language: str) -> RecommendedAppCatalogPage: + source = self._source() + if source is not self._remote: + page = source.list_recommended(language) + else: + try: + page = self._remote.list_recommended(language) + except _RecommendedAppSourceUnavailableError as error: + logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", error) + page = self._builtin.list_recommended(language) + + if not page.recommended_apps: + return self._builtin.list_recommended(_BUILTIN_FALLBACK_LANGUAGE) + return page + + @override + def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: + source = self._source() + if source is not self._remote: + return source.list_learn_dify(language) + try: + return self._remote.list_learn_dify(language) + except _RecommendedAppSourceUnavailableError as error: + logger.warning("fetch learn dify apps from dify official failed: %s, switch to built-in.", error) + return self._builtin.list_learn_dify(language) + + @override + def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: + source = self._source() + if source is not self._remote: + return source.get_detail(app_id) + try: + return self._remote.get_detail(app_id) + except _RecommendedAppSourceUnavailableError as error: + logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error) + return self._builtin.get_detail(app_id) + + @override + def contains(self, app_id: str) -> bool: + source = self._source() + if source is not self._remote: + return source.contains(app_id) + try: + return self._remote.contains(app_id) + except _RecommendedAppSourceUnavailableError as error: + logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error) + return self._builtin.contains(app_id) + + def _source(self) -> RecommendedAppCatalogQuery: + mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE + try: + return self._sources[mode] + except KeyError: + raise ValueError(f"invalid fetch recommended apps mode: {mode}") from None + + +def _map_recommended_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage: + if not source.get("recommended_apps"): + return RecommendedAppCatalogPage(recommended_apps=(), categories=()) + return _map_page(source) + + +def _map_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage: + return RecommendedAppCatalogPage( + recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")), + categories=_as_string_tuple(source["categories"], field="categories"), + ) + + +def _map_learn_dify_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage: + return RecommendedAppCatalogPage( + recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")), + categories=(), + ) + + +def _map_app(source: object) -> RecommendedAppRecord: + source = _as_mapping(source, field="recommended app") + app_id = source["app_id"] + if not isinstance(app_id, str): + raise TypeError("app_id must be a string") + + app_source = source.get("app") + if app_source is not None: + app_source = _as_mapping(app_source, field="app") + + return RecommendedAppRecord( + app=_map_app_info(app_source), + app_id=app_id, + description=cast(str | None, source.get("description")), + copyright=cast(str | None, source.get("copyright")), + privacy_policy=cast(str | None, source.get("privacy_policy")), + custom_disclaimer=cast(str | None, source.get("custom_disclaimer")), + categories=_as_string_tuple(source.get("categories", ()), field="categories"), + position=cast(int | None, source.get("position")), + is_listed=cast(bool | None, source.get("is_listed")), + ) + + +def _map_app_info(source: Mapping[str, object] | None) -> RecommendedAppInfoRecord | None: + if source is None: + return None + app_id = source["id"] + if not isinstance(app_id, str): + raise TypeError("app.id must be a string") + return RecommendedAppInfoRecord( + id=app_id, + name=cast(str | None, source.get("name")), + mode=_enum_string(source.get("mode"), field="app.mode"), + icon=cast(str | None, source.get("icon")), + icon_type=_enum_string(source.get("icon_type"), field="app.icon_type"), + icon_background=cast(str | None, source.get("icon_background")), + ) + + +def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord: + app_id = source["id"] + name = source["name"] + export_data = source["export_data"] + if not isinstance(app_id, str): + raise TypeError("id must be a string") + if not isinstance(name, str): + raise TypeError("name must be a string") + if not isinstance(export_data, str): + raise TypeError("export_data must be a string") + + mode = _enum_string(source["mode"], field="mode") + if mode is None: + raise TypeError("mode must be a string or string enum") + return RecommendedAppDetailRecord( + id=app_id, + name=name, + icon=cast(str | None, source.get("icon")), + icon_background=cast(str | None, source.get("icon_background")), + mode=mode, + export_data=export_data, + ) + + +def _as_mapping(value: object, *, field: str) -> Mapping[str, object]: + if not isinstance(value, Mapping): + raise TypeError(f"{field} must be a mapping") + return cast(Mapping[str, object], value) + + +def _as_sequence(value: object, *, field: str) -> Sequence[object]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError(f"{field} must be a sequence") + return cast(Sequence[object], value) + + +def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]: + values = _as_sequence(value, field=field) + if not all(isinstance(item, str) for item in values): + raise TypeError(f"{field} must contain only strings") + return cast(tuple[str, ...], tuple(values)) + + +def _enum_string(value: object, *, field: str) -> str | None: + if value is None: + return None + if isinstance(value, str): + return str(value) + raise TypeError(f"{field} must be a string or string enum") diff --git a/api/services/recommended_app_query_compat.py b/api/services/recommended_app_query_compat.py deleted file mode 100644 index 468fe185e39..00000000000 --- a/api/services/recommended_app_query_compat.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Compatibility adapter for the existing recommended app retrieval stack.""" - -from collections.abc import Mapping, Sequence -from typing import cast, override - -from sqlalchemy.orm import Session, sessionmaker - -from configs import dify_config -from models.model import App -from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory -from services.recommended_app_query_service import ( - RecommendedAppCatalogGateway, - RecommendedAppCatalogPage, - RecommendedAppDetailRecord, - RecommendedAppInfoRecord, - RecommendedAppRecord, -) - - -class LegacyRecommendedAppCatalogGateway(RecommendedAppCatalogGateway): - """Map the mixed dict/ORM retrieval results to persistence-neutral records.""" - - def __init__(self, session_factory: sessionmaker[Session]) -> None: - self._session_factory = session_factory - - @override - def is_recommended(self, app_id: str) -> bool: - retrieval = self._configured_retrieval() - with self._session_factory() as session: - return retrieval.get_recommend_app_detail(app_id, session=session) is not None - - @override - def list_recommended(self, language: str) -> RecommendedAppCatalogPage: - retrieval = self._configured_retrieval() - with self._session_factory() as session: - result = retrieval.get_recommended_apps_and_categories(language, session=session) - return self._map_recommended_page(result) - - @override - def list_builtin(self, language: str) -> RecommendedAppCatalogPage: - retrieval = RecommendAppRetrievalFactory.get_buildin_recommend_app_retrieval() - return self._map_page(retrieval.fetch_recommended_apps_from_builtin(language)) - - @override - def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: - retrieval = self._configured_retrieval() - with self._session_factory() as session: - result = retrieval.get_learn_dify_apps(language, session=session) - raw_apps = cast(Sequence[object], result["recommended_apps"]) - return RecommendedAppCatalogPage( - recommended_apps=tuple(self._map_app(app) for app in raw_apps), - categories=(), - ) - - @override - def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: - retrieval = self._configured_retrieval() - with self._session_factory() as session: - result = retrieval.get_recommend_app_detail(app_id, session=session) - if result is None: - return None - if not isinstance(result, Mapping): - raise TypeError("recommended app detail must be a mapping") - return self._map_detail(cast(Mapping[str, object], result)) - - @staticmethod - def _configured_retrieval(): - retrieval_type = RecommendAppRetrievalFactory.get_recommend_app_factory( - dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE - ) - return retrieval_type() - - @classmethod - def _map_page(cls, result: Mapping[str, object]) -> RecommendedAppCatalogPage: - raw_apps = cast(Sequence[object], result["recommended_apps"]) - return RecommendedAppCatalogPage( - recommended_apps=tuple(cls._map_app(app) for app in raw_apps), - categories=cls._as_string_tuple(result["categories"], field="categories"), - ) - - @classmethod - def _map_recommended_page(cls, result: Mapping[str, object]) -> RecommendedAppCatalogPage: - if not result.get("recommended_apps"): - return RecommendedAppCatalogPage(recommended_apps=(), categories=()) - return cls._map_page(result) - - @classmethod - def _map_app(cls, source: object) -> RecommendedAppRecord: - if not isinstance(source, Mapping): - raise TypeError("recommended app must be a mapping") - - source = cast(Mapping[str, object], source) - app_id = source["app_id"] - if not isinstance(app_id, str): - raise TypeError("app_id must be a string") - app_source = source.get("app") - if app_source is not None and not isinstance(app_source, (Mapping, App)): - raise TypeError("app must be a mapping or App") - - return RecommendedAppRecord( - app=cls._map_app_info(cast(Mapping[str, object] | App | None, app_source)), - app_id=app_id, - description=cast(str | None, source.get("description")), - copyright=cast(str | None, source.get("copyright")), - privacy_policy=cast(str | None, source.get("privacy_policy")), - custom_disclaimer=cast(str | None, source.get("custom_disclaimer")), - categories=cls._as_string_tuple(source.get("categories", ()), field="categories"), - position=cast(int | None, source.get("position")), - is_listed=cast(bool | None, source.get("is_listed")), - ) - - @classmethod - def _map_app_info(cls, source: Mapping[str, object] | App | None) -> RecommendedAppInfoRecord | None: - if source is None: - return None - - app_id: object - name: object - mode: object - icon: object - icon_type: object - icon_background: object - if isinstance(source, App): - app_id = source.id - name = source.name - mode = source.mode - icon = source.icon - icon_type = source.icon_type - icon_background = source.icon_background - else: - app_id = source["id"] - name = source.get("name") - mode = source.get("mode") - icon = source.get("icon") - icon_type = source.get("icon_type") - icon_background = source.get("icon_background") - - if not isinstance(app_id, str): - raise TypeError("app.id must be a string") - - return RecommendedAppInfoRecord( - id=app_id, - name=cast(str | None, name), - mode=cls._enum_string(mode, field="app.mode"), - icon=cast(str | None, icon), - icon_type=cls._enum_string(icon_type, field="app.icon_type"), - icon_background=cast(str | None, icon_background), - ) - - @classmethod - def _map_detail(cls, source: Mapping[str, object]) -> RecommendedAppDetailRecord: - app_id = source["id"] - name = source["name"] - export_data = source["export_data"] - if not isinstance(app_id, str): - raise TypeError("id must be a string") - if not isinstance(name, str): - raise TypeError("name must be a string") - if not isinstance(export_data, str): - raise TypeError("export_data must be a string") - - mode = cls._enum_string(source["mode"], field="mode") - if mode is None: - raise TypeError("mode must be a string or string enum") - - return RecommendedAppDetailRecord( - id=app_id, - name=name, - icon=cast(str | None, source.get("icon")), - icon_background=cast(str | None, source.get("icon_background")), - mode=mode, - export_data=export_data, - ) - - @staticmethod - def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]: - if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): - raise TypeError(f"{field} must be a sequence of strings") - items: list[str] = [] - for item in value: - if not isinstance(item, str): - raise TypeError(f"{field} must contain only strings") - items.append(item) - return tuple(items) - - @staticmethod - def _enum_string(value: object, *, field: str) -> str | None: - if value is None: - return None - if isinstance(value, str): - return str(value) - raise TypeError(f"{field} must be a string or string enum") diff --git a/api/services/recommended_app_query_service.py b/api/services/recommended_app_query_service.py index d9ad5d9b9e7..5b79f40d505 100644 --- a/api/services/recommended_app_query_service.py +++ b/api/services/recommended_app_query_service.py @@ -5,8 +5,6 @@ from typing import NamedTuple, Protocol from constants.languages import languages -_BUILTIN_FALLBACK_LANGUAGE = "en-US" - class RecommendedAppInfoRecord(NamedTuple): id: str @@ -43,17 +41,17 @@ class RecommendedAppDetailRecord(NamedTuple): export_data: str -class RecommendedAppCatalogGateway(Protocol): - def is_recommended(self, app_id: str) -> bool: ... +class RecommendedAppCatalogQuery(Protocol): + """Read from the recommended-app catalog.""" def list_recommended(self, language: str) -> RecommendedAppCatalogPage: ... - def list_builtin(self, language: str) -> RecommendedAppCatalogPage: ... - def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: ... def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: ... + def contains(self, app_id: str) -> bool: ... + class TrialAppQuery(Protocol): def existing_ids(self, app_ids: Sequence[str]) -> Set[str]: ... @@ -99,7 +97,7 @@ class RecommendedAppQueryService: def __init__( self, *, - catalog: RecommendedAppCatalogGateway, + catalog: RecommendedAppCatalogQuery, trial_apps: TrialAppQuery, trial_enabled: bool, ) -> None: @@ -113,7 +111,7 @@ class RecommendedAppQueryService: def is_previewable(self, app_id: str) -> bool: if app_id in self._trial_apps.existing_ids((app_id,)): return True - return self._catalog.is_recommended(app_id) + return self._catalog.contains(app_id) def list_recommended( self, @@ -123,8 +121,6 @@ class RecommendedAppQueryService: ) -> RecommendedAppListResult: language = self._resolve_language(requested_language, interface_language) page = self._catalog.list_recommended(language) - if not page.recommended_apps: - page = self._catalog.list_builtin(_BUILTIN_FALLBACK_LANGUAGE) return RecommendedAppListResult( recommended_apps=self._with_trial_status(page.recommended_apps), diff --git a/api/tests/test_containers_integration_tests/.ruff.toml b/api/tests/test_containers_integration_tests/.ruff.toml index 49244091ffb..a58180c68b1 100644 --- a/api/tests/test_containers_integration_tests/.ruff.toml +++ b/api/tests/test_containers_integration_tests/.ruff.toml @@ -23,7 +23,6 @@ extend-select = ["ANN401", "ARG"] "services/dataset_collection_binding.py" = ["ARG002"] "services/document_service_status.py" = ["ARG002"] "services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG002"] -"services/recommend_app/test_database_retrieval.py" = ["ARG002"] "services/test_account_service.py" = ["ARG002"] "services/test_advanced_prompt_template_service.py" = ["ARG002"] "services/test_app_dsl_service.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"] diff --git a/api/tests/test_containers_integration_tests/pyrefly.toml b/api/tests/test_containers_integration_tests/pyrefly.toml index cf707c4f947..cd654925428 100644 --- a/api/tests/test_containers_integration_tests/pyrefly.toml +++ b/api/tests/test_containers_integration_tests/pyrefly.toml @@ -42,7 +42,6 @@ project-excludes = [ "services/dataset_collection_binding.py", "services/dataset_service_update_delete.py", "services/document_service_status.py", - "services/recommend_app/test_database_retrieval.py", "services/test_account_service.py", "services/test_advanced_prompt_template_service.py", "services/test_agent_service.py", diff --git a/api/tests/test_containers_integration_tests/repositories/test_recommended_app_catalog_repository.py b/api/tests/test_containers_integration_tests/repositories/test_recommended_app_catalog_repository.py new file mode 100644 index 00000000000..242d8c8324c --- /dev/null +++ b/api/tests/test_containers_integration_tests/repositories/test_recommended_app_catalog_repository.py @@ -0,0 +1,119 @@ +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from sqlalchemy.orm import Session, object_session, sessionmaker + +from extensions.ext_redis import RedisClientWrapper +from models.model import App, RecommendedApp, Site +from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository +from services.recommended_app_query_service import RecommendedAppDetailRecord + + +def _add_catalog_app( + session: Session, + *, + categories: list[str] | None = None, + language: str = "en-US", + is_public: bool = True, + with_site: bool = True, +) -> App: + app = App( + tenant_id=str(uuid4()), + name=f"app-{uuid4()}", + mode="chat", + enable_site=True, + enable_api=True, + is_public=is_public, + ) + app.id = str(uuid4()) + session.add(app) + session.add( + RecommendedApp( + app_id=app.id, + description={"en-US": "test"}, + copyright="copy", + privacy_policy="privacy", + category="writing", + categories=["writing"] if categories is None else categories, + language=language, + is_listed=True, + position=1, + ) + ) + if with_site: + session.add( + Site( + app_id=app.id, + title=f"site-{uuid4()}", + default_language="en-US", + customize_token_strategy="not_allow", + description="description", + copyright="copyright", + privacy_policy="privacy", + custom_disclaimer="disclaimer", + ) + ) + session.commit() + return app + + +def _repository(session: Session) -> DatabaseRecommendedAppCatalogRepository: + redis = MagicMock(spec=RedisClientWrapper) + redis.get.return_value = None + return DatabaseRecommendedAppCatalogRepository( + sessionmaker(bind=session.get_bind(), expire_on_commit=False), + redis=redis, + ) + + +def test_list_maps_postgres_models_with_owned_session( + db_session_with_containers: Session, +) -> None: + app = _add_catalog_app( + db_session_with_containers, + categories=["writing", "assistant"], + ) + private_app = _add_catalog_app(db_session_with_containers, is_public=False) + no_site_app = _add_catalog_app(db_session_with_containers, with_site=False) + + page = _repository(db_session_with_containers).list_recommended("fr-FR") + + record = next(item for item in page.recommended_apps if item.app_id == app.id) + assert record.app is not None + assert record.app.id == app.id + assert record.app.mode == "chat" + assert record.description == "description" + assert record.categories == ("writing", "assistant") + assert {"writing", "assistant"} <= set(page.categories) + assert private_app.id not in {item.app_id for item in page.recommended_apps} + assert no_site_app.id not in {item.app_id for item in page.recommended_apps} + + +def test_membership_does_not_export_dsl_with_owned_session( + db_session_with_containers: Session, +) -> None: + app = _add_catalog_app(db_session_with_containers, with_site=False) + repository = _repository(db_session_with_containers) + + def export_dsl(*, app_model: App, session: Session) -> str: + assert object_session(app_model) is session + assert session is not db_session_with_containers + return "exported_yaml" + + with patch( + "repositories.recommended_app_catalog_repository.AppDslService.export_dsl", + side_effect=export_dsl, + ) as mock_export_dsl: + detail = repository.get_detail(app.id) + is_in_catalog = repository.contains(app.id) + + assert detail == RecommendedAppDetailRecord( + id=app.id, + name=app.name, + icon=app.icon, + icon_background=app.icon_background, + mode="chat", + export_data="exported_yaml", + ) + assert is_in_catalog is True + mock_export_dsl.assert_called_once() diff --git a/api/tests/test_containers_integration_tests/services/recommend_app/__init__.py b/api/tests/test_containers_integration_tests/services/recommend_app/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py deleted file mode 100644 index 145dc947b52..00000000000 --- a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py +++ /dev/null @@ -1,301 +0,0 @@ -from __future__ import annotations - -from unittest.mock import patch -from uuid import uuid4 - -from flask import Flask -from sqlalchemy.orm import Session - -from models.model import App, RecommendedApp, Site -from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval - - -def _create_app(db_session: Session, *, tenant_id: str, is_public: bool = True) -> App: - app = App( - tenant_id=tenant_id, - name=f"app-{uuid4()}", - mode="chat", - enable_site=True, - enable_api=True, - is_public=is_public, - ) - app.id = str(uuid4()) - db_session.add(app) - db_session.commit() - return app - - -def _create_site(db_session: Session, *, app_id: str) -> Site: - site = Site( - app_id=app_id, - title=f"site-{uuid4()}", - default_language="en-US", - customize_token_strategy="not_allow", - description="desc", - copyright="copy", - privacy_policy="pp", - custom_disclaimer="cd", - ) - site.id = str(uuid4()) - db_session.add(site) - db_session.commit() - return site - - -def _create_recommended_app( - db_session, - *, - app_id: str, - category: str = "chat", - categories: list[str] | None = None, - language: str = "en-US", - is_listed: bool = True, - is_learn_dify: bool = False, - position: int = 1, -) -> RecommendedApp: - rec = RecommendedApp( - app_id=app_id, - description={"en-US": "test"}, - copyright="copy", - privacy_policy="pp", - category=category, - categories=[category] if categories is None else categories, - language=language, - is_listed=is_listed, - is_learn_dify=is_learn_dify, - position=position, - ) - rec.id = str(uuid4()) - db_session.add(rec) - db_session.commit() - return rec - - -class TestFetchRecommendedAppsFromDb: - def test_returns_apps_and_sorted_categories( - self, flask_app_with_containers: Flask, db_session_with_containers: Session - ): - tenant_id = str(uuid4()) - app1 = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_site(db_session_with_containers, app_id=app1.id) - _create_recommended_app(db_session_with_containers, app_id=app1.id, category="writing") - - app2 = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_site(db_session_with_containers, app_id=app2.id) - _create_recommended_app(db_session_with_containers, app_id=app2.id, category="assistant") - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db( - "en-US", session=db_session_with_containers - ) - - app_ids = {r["app_id"] for r in result["recommended_apps"]} - assert app1.id in app_ids - assert app2.id in app_ids - assert "assistant" in result["categories"] - assert "writing" in result["categories"] - - def test_returns_multiple_categories_for_one_app( - self, flask_app_with_containers: Flask, db_session_with_containers: Session - ): - tenant_id = str(uuid4()) - created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_site(db_session_with_containers, app_id=created_app.id) - _create_recommended_app( - db_session_with_containers, - app_id=created_app.id, - category="writing", - categories=["writing", "assistant"], - ) - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db( - "en-US", session=db_session_with_containers - ) - - recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) - assert recommended_app["categories"] == ["writing", "assistant"] - assert "writing" in result["categories"] - assert "assistant" in result["categories"] - - def test_ignores_legacy_category_when_categories_are_empty( - self, - flask_app_with_containers: Flask, - db_session_with_containers: Session, - ): - legacy_category = f"legacy-empty-{uuid4()}" - tenant_id = str(uuid4()) - created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_site(db_session_with_containers, app_id=created_app.id) - _create_recommended_app( - db_session_with_containers, - app_id=created_app.id, - category=legacy_category, - categories=[], - ) - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db( - "en-US", session=db_session_with_containers - ) - - recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) - assert "category" not in recommended_app - assert recommended_app["categories"] == [] - assert legacy_category not in result["categories"] - - def test_falls_back_to_default_language_when_empty( - self, flask_app_with_containers: Flask, db_session_with_containers: Session - ): - tenant_id = str(uuid4()) - app1 = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_site(db_session_with_containers, app_id=app1.id) - _create_recommended_app(db_session_with_containers, app_id=app1.id, language="en-US") - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db( - "fr-FR", session=db_session_with_containers - ) - - app_ids = {r["app_id"] for r in result["recommended_apps"]} - assert app1.id in app_ids - - def test_skips_non_public_apps(self, flask_app_with_containers: Flask, db_session_with_containers: Session): - tenant_id = str(uuid4()) - app1 = _create_app(db_session_with_containers, tenant_id=tenant_id, is_public=False) - _create_site(db_session_with_containers, app_id=app1.id) - _create_recommended_app(db_session_with_containers, app_id=app1.id) - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db( - "en-US", session=db_session_with_containers - ) - - app_ids = {r["app_id"] for r in result["recommended_apps"]} - assert app1.id not in app_ids - - def test_skips_apps_without_site(self, flask_app_with_containers: Flask, db_session_with_containers: Session): - tenant_id = str(uuid4()) - app1 = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_recommended_app(db_session_with_containers, app_id=app1.id) - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db( - "en-US", session=db_session_with_containers - ) - - app_ids = {r["app_id"] for r in result["recommended_apps"]} - assert app1.id not in app_ids - - def test_fetch_learn_dify_apps_uses_flag_not_categories( - self, - flask_app_with_containers, - db_session_with_containers: Session, - ): - tenant_id = str(uuid4()) - learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_site(db_session_with_containers, app_id=learn_dify_app.id) - _create_recommended_app( - db_session_with_containers, - app_id=learn_dify_app.id, - category="workflow", - categories=["Workflow"], - is_learn_dify=True, - ) - - category_only_app = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_site(db_session_with_containers, app_id=category_only_app.id) - _create_recommended_app( - db_session_with_containers, - app_id=category_only_app.id, - category="Learn Dify", - categories=["Learn Dify"], - is_learn_dify=False, - ) - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db( - "en-US", session=db_session_with_containers - ) - - app_ids = {r["app_id"] for r in result["recommended_apps"]} - assert learn_dify_app.id in app_ids - assert category_only_app.id not in app_ids - recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == learn_dify_app.id) - assert recommended_app["categories"] == ["Workflow"] - - def test_fetch_learn_dify_apps_falls_back_to_default_language( - self, - flask_app_with_containers, - db_session_with_containers: Session, - ): - tenant_id = str(uuid4()) - learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_site(db_session_with_containers, app_id=learn_dify_app.id) - _create_recommended_app( - db_session_with_containers, - app_id=learn_dify_app.id, - categories=["Workflow"], - is_learn_dify=True, - language="en-US", - ) - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db( - "fr-FR", session=db_session_with_containers - ) - - app_ids = {r["app_id"] for r in result["recommended_apps"]} - assert learn_dify_app.id in app_ids - - -class TestFetchRecommendedAppDetailFromDb: - def test_returns_none_when_not_listed(self, flask_app_with_containers: Flask, db_session_with_containers: Session): - result = DatabaseRecommendAppRetrieval.fetch_recommended_app_detail_from_db( - str(uuid4()), session=db_session_with_containers - ) - - assert result is None - - def test_returns_none_when_app_not_public( - self, flask_app_with_containers: Flask, db_session_with_containers: Session - ): - tenant_id = str(uuid4()) - app1 = _create_app(db_session_with_containers, tenant_id=tenant_id, is_public=False) - _create_recommended_app(db_session_with_containers, app_id=app1.id) - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_recommended_app_detail_from_db( - app1.id, session=db_session_with_containers - ) - - assert result is None - - @patch("services.recommend_app.database.database_retrieval.AppDslService") - def test_returns_detail_on_success( - self, mock_dsl, flask_app_with_containers: Flask, db_session_with_containers: Session - ): - tenant_id = str(uuid4()) - app1 = _create_app(db_session_with_containers, tenant_id=tenant_id) - _create_site(db_session_with_containers, app_id=app1.id) - _create_recommended_app(db_session_with_containers, app_id=app1.id) - mock_dsl.export_dsl.return_value = "exported_yaml" - - db_session_with_containers.expire_all() - - result = DatabaseRecommendAppRetrieval.fetch_recommended_app_detail_from_db( - app1.id, session=db_session_with_containers - ) - - assert result is not None - assert result["id"] == app1.id - assert result["export_data"] == "exported_yaml" diff --git a/api/tests/unit_tests/.ruff.toml b/api/tests/unit_tests/.ruff.toml index 5255ad121b5..32c17b823bd 100644 --- a/api/tests/unit_tests/.ruff.toml +++ b/api/tests/unit_tests/.ruff.toml @@ -347,7 +347,6 @@ extend-select = ["ANN401", "ARG"] "services/rag_pipeline/test_rag_pipeline_service.py" = ["ARG001", "ARG005"] "services/rag_pipeline/test_rag_pipeline_task_proxy.py" = ["ARG001", "ARG005"] "services/rag_pipeline/test_rag_pipeline_transform_service.py" = ["ARG001"] -"services/recommend_app/test_remote_retrieval.py" = ["ARG002"] "services/retention/workflow_run/test_archive_download_preparation.py" = ["ARG002"] "services/retention/workflow_run/test_archive_log_service.py" = ["ARG001", "ARG002"] "services/retention/workflow_run/test_bundle_archive_maintenance.py" = ["TID251"] diff --git a/api/tests/unit_tests/controllers/console/app/test_wraps.py b/api/tests/unit_tests/controllers/console/app/test_wraps.py index 4c29863b5db..e341dc15d84 100644 --- a/api/tests/unit_tests/controllers/console/app/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/app/test_wraps.py @@ -58,7 +58,7 @@ def test_get_app_model_rejects_wrong_mode(monkeypatch: pytest.MonkeyPatch, sqlit handler(app_id=app_model.id) -def test_load_app_model_with_trial_rejects_app_outside_preview_admission( +def test_load_previewable_app_model_rejects_app_outside_preview_admission( monkeypatch: pytest.MonkeyPatch, ) -> None: session = MagicMock(spec=Session) @@ -72,12 +72,12 @@ def test_load_app_model_with_trial_rejects_app_outside_preview_admission( ) monkeypatch.setattr(wraps_module.AppService, "get_normal_app_by_id", app_loader) - assert wraps_module._load_app_model_with_trial(session, "app-1") is None + assert wraps_module._load_previewable_app_model(session, "app-1") is None recommended_app_queries.is_previewable.assert_called_once_with("app-1") app_loader.assert_not_called() -def test_load_app_model_with_trial_rejects_non_normal_app( +def test_load_previewable_app_model_rejects_non_normal_app( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: app_model = _persist_app(sqlite_session) @@ -92,17 +92,17 @@ def test_load_app_model_with_trial_rejects_non_normal_app( lambda: SimpleNamespace(recommended_app_queries=recommended_app_queries), ) - assert wraps_module._load_app_model_with_trial(sqlite_session, app_id) is None + assert wraps_module._load_previewable_app_model(sqlite_session, app_id) is None -def test_get_app_model_with_trial_rejects_app_outside_preview_admission( +def test_get_previewable_app_model_rejects_app_outside_preview_admission( monkeypatch: pytest.MonkeyPatch, unbound_session: Session ) -> None: app_loader = MagicMock(return_value=None) - monkeypatch.setattr(wraps_module, "_load_app_model_with_trial", app_loader) + monkeypatch.setattr(wraps_module, "_load_previewable_app_model", app_loader) class Handler: - @wraps_module.get_app_model_with_trial + @wraps_module.get_previewable_app_model def get(self, _injected_session, app_model): return app_model.id @@ -179,7 +179,7 @@ def test_preview_admission_precedes_request_session_transaction( class Handler: @with_session(write=False) - @wraps_module.get_app_model_with_trial(None) + @wraps_module.get_previewable_app_model(None) def get(self, injected_session, app_model): assert injected_session is sqlite_session return app_model.id @@ -189,8 +189,8 @@ def test_preview_admission_precedes_request_session_transaction( assert request_transaction_begins == 1 -def test_get_app_model_with_trial_requires_injected_session() -> None: - @wraps_module.get_app_model_with_trial(None) +def test_get_previewable_app_model_requires_injected_session() -> None: + @wraps_module.get_previewable_app_model(None) def handler(app_model): return app_model.id diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index af641c988f7..2f2966b3844 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -198,10 +198,10 @@ def test_trial_dataset_list_preserves_slim_dataset_fields(app: Flask, unbound_se "api_type", [module.TrialSitApi, module.TrialAppParameterApi, module.AppApi, module.AppWorkflowApi, module.DatasetListApi], ) -def test_trial_app_handlers_use_explicit_read_session(api_type: type) -> None: +def test_preview_handlers_use_explicit_read_session(api_type: type) -> None: source = getsource(api_type.get) - assert "@with_session(write=False)\n @get_app_model_with_trial(None)" in source + assert "@with_session(write=False)\n @get_previewable_app_model(None)" in source assert tuple(signature(api_type.get).parameters)[:3] == ("self", "session", "app_model") diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index 6bdc806c727..d727f47221f 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -18,6 +18,7 @@ from extensions.ext_redis import RedisClientWrapper from models.model import AccountTrialAppRecord, DifySetup from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository from repositories.account_repository import SQLAlchemyAccountRepository +from services import recommended_app_catalog_gateway from services.account_activation_adapters import ( BillingAccountActivationEligibility, BillingWorkspaceMembershipCache, @@ -403,3 +404,40 @@ def test_webapp_permission_adapter_maps_connection_failure() -> None: ext_application_services._is_user_allowed_to_access_webapp("user-1", "app-1") assert raised.value.__cause__ is failure + + +def test_build_application_services_wires_dynamic_recommended_catalog( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(ext_application_services.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "builtin") + services = ext_application_services.build_application_services( + database_client=sqlite_session_factory, + deployment_edition=DeploymentEdition.COMMUNITY, + initialization_password="", + redis=MagicMock(spec=RedisClientWrapper), + ) + + builtin_payload = json.dumps( + { + "recommended_apps": { + "en-US": { + "recommended_apps": [{"app": None, "app_id": "app-1", "categories": []}], + "categories": [], + } + } + } + ) + with patch.object(recommended_app_catalog_gateway.Path, "read_text", return_value=builtin_payload): + result = services.recommended_app_queries.list_recommended( + requested_language="en-US", + interface_language=None, + ) + assert result.recommended_apps + + monkeypatch.setattr(ext_application_services.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "invalid") + with pytest.raises(ValueError, match="invalid fetch recommended apps mode: invalid"): + services.recommended_app_queries.list_recommended( + requested_language="en-US", + interface_language=None, + ) diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml index 2b0337fde19..36b9a2e0605 100644 --- a/api/tests/unit_tests/pyrefly.toml +++ b/api/tests/unit_tests/pyrefly.toml @@ -800,11 +800,6 @@ project-excludes = [ "services/rag_pipeline/test_rag_pipeline_service.py", "services/rag_pipeline/test_rag_pipeline_task_proxy.py", "services/rag_pipeline/test_rag_pipeline_transform_service.py", - "services/recommend_app/test_buildin_retrieval.py", - "services/recommend_app/test_category_order.py", - "services/recommend_app/test_recommend_app_factory.py", - "services/recommend_app/test_recommend_app_type.py", - "services/recommend_app/test_remote_retrieval.py", "services/retention/test_messages_clean_policy.py", "services/retention/workflow_run/test_archive_download_preparation.py", "services/retention/workflow_run/test_archive_download_task_cache.py", diff --git a/api/tests/unit_tests/repositories/test_recommended_app_catalog_repository.py b/api/tests/unit_tests/repositories/test_recommended_app_catalog_repository.py new file mode 100644 index 00000000000..31bf573306f --- /dev/null +++ b/api/tests/unit_tests/repositories/test_recommended_app_catalog_repository.py @@ -0,0 +1,249 @@ +import json +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from sqlalchemy import event +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from extensions.ext_redis import RedisClientWrapper +from models.model import App, AppMode, RecommendedApp, Site +from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository +from services.recommended_app_query_service import RecommendedAppDetailRecord + + +def _add_catalog_app( + session: Session, + *, + categories: list[str] | None = None, + language: str = "en-US", + is_public: bool = True, + is_listed: bool = True, + is_learn_dify: bool = False, + with_site: bool = True, +) -> App: + app = App( + id=str(uuid4()), + tenant_id=str(uuid4()), + name="Recommended App", + mode=AppMode.CHAT, + icon_type=None, + icon=None, + icon_background="#fff", + enable_site=True, + enable_api=True, + is_public=is_public, + ) + recommended_app = RecommendedApp( + app_id=app.id, + description={}, + copyright="copyright", + privacy_policy="privacy", + category="Workflow", + categories=["Workflow"] if categories is None else categories, + custom_disclaimer="catalog disclaimer", + position=1, + is_listed=is_listed, + is_learn_dify=is_learn_dify, + language=language, + ) + session.add_all([app, recommended_app]) + if with_site: + session.add( + Site( + app_id=app.id, + title="Recommended App", + description="site description", + copyright="site copyright", + privacy_policy="site privacy", + custom_disclaimer="site disclaimer", + default_language="en-US", + customize_token_strategy="not_allow", + ) + ) + session.commit() + return app + + +def _redis() -> MagicMock: + redis = MagicMock(spec=RedisClientWrapper) + redis.get.return_value = None + return redis + + +def _repository( + session_factory: sessionmaker[Session], + *, + redis: RedisClientWrapper | None = None, +) -> DatabaseRecommendedAppCatalogRepository: + return DatabaseRecommendedAppCatalogRepository( + session_factory, + redis=redis if redis is not None else _redis(), + ) + + +def test_list_recommended_returns_typed_records_and_falls_back_language( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + app = _add_catalog_app(session) + + repository = _repository(sqlite_session_factory) + page = repository.list_recommended("fr-FR") + + assert page.categories == ("Workflow",) + assert len(page.recommended_apps) == 1 + record = page.recommended_apps[0] + assert record.app_id == app.id + assert record.app is not None + assert record.app.id == app.id + assert record.app.mode == "chat" + assert record.description == "site description" + assert record.custom_disclaimer == "site disclaimer" + assert record.categories == ("Workflow",) + + +def test_list_recommended_skips_private_apps_and_apps_without_sites( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + _add_catalog_app(session, is_public=False) + _add_catalog_app(session, with_site=False) + + repository = _repository(sqlite_session_factory) + + assert repository.list_recommended("en-US").recommended_apps == () + + +def test_list_recommended_does_not_restore_legacy_category_when_categories_are_empty( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + app = _add_catalog_app(session, categories=[]) + + page = _repository(sqlite_session_factory).list_recommended("en-US") + + record = next(item for item in page.recommended_apps if item.app_id == app.id) + assert record.categories == () + assert "Workflow" not in page.categories + + +def test_list_recommended_uses_redis_category_order( + sqlite_engine: Engine, + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + _add_catalog_app(session, categories=["A", "B", "C", "D"]) + + checked_out_connections = 0 + + def record_checkout(_dbapi_connection, _connection_record, _connection_proxy) -> None: + nonlocal checked_out_connections + checked_out_connections += 1 + + def record_checkin(_dbapi_connection, _connection_record) -> None: + nonlocal checked_out_connections + checked_out_connections -= 1 + + def get_category_order(_key: str) -> bytes: + assert checked_out_connections == 0 + return json.dumps(["C", "A", "B"]).encode() + + redis = _redis() + redis.get.side_effect = get_category_order + event.listen(sqlite_engine, "checkout", record_checkout) + event.listen(sqlite_engine, "checkin", record_checkin) + try: + page = _repository(sqlite_session_factory, redis=redis).list_recommended("en-US") + finally: + event.remove(sqlite_engine, "checkout", record_checkout) + event.remove(sqlite_engine, "checkin", record_checkin) + + assert page.categories == ("C", "A", "B") + redis.get.assert_called_once_with("explore:apps:category_order:en-US") + + +def test_list_recommended_sorts_categories_without_redis_order( + sqlite_session_factory: sessionmaker[Session], +) -> None: + redis = _redis() + with sqlite_session_factory() as session: + _add_catalog_app(session, categories=["B", "A", "C"]) + + page = _repository(sqlite_session_factory, redis=redis).list_recommended("en-US") + + assert page.categories == ("A", "B", "C") + + +def test_list_learn_dify_filters_flag_and_hides_page_categories( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + learn_app = _add_catalog_app(session, is_learn_dify=True) + _add_catalog_app(session, is_learn_dify=False) + + redis = _redis() + repository = _repository(sqlite_session_factory, redis=redis) + page = repository.list_learn_dify("fr-FR") + + assert [app.app_id for app in page.recommended_apps] == [learn_app.id] + assert page.recommended_apps[0].categories == ("Workflow",) + assert page.categories == () + redis.get.assert_not_called() + + +def test_membership_does_not_export_dsl( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + app = _add_catalog_app(session) + + repository = _repository(sqlite_session_factory) + with patch( + "repositories.recommended_app_catalog_repository.AppDslService.export_dsl", + return_value="exported yaml", + ) as export_dsl: + detail = repository.get_detail(app.id) + is_in_catalog = repository.contains(app.id) + + assert detail == RecommendedAppDetailRecord( + id=app.id, + name="Recommended App", + icon=None, + icon_background="#fff", + mode="chat", + export_data="exported yaml", + ) + assert is_in_catalog is True + export_dsl.assert_called_once() + + +def test_detail_rejects_unlisted_or_private_apps(sqlite_session_factory: sessionmaker[Session]) -> None: + with sqlite_session_factory() as session: + private_app = _add_catalog_app(session, is_public=False) + unlisted_app = _add_catalog_app(session, is_listed=False) + missing_app_id = str(uuid4()) + + repository = _repository(sqlite_session_factory) + + assert repository.get_detail(private_app.id) is None + assert repository.get_detail(unlisted_app.id) is None + assert repository.get_detail(missing_app_id) is None + assert repository.contains(private_app.id) is False + assert repository.contains(unlisted_app.id) is False + assert repository.contains(missing_app_id) is False + + +def test_detail_does_not_require_site(sqlite_session_factory: sessionmaker[Session]) -> None: + with sqlite_session_factory() as session: + app = _add_catalog_app(session, with_site=False) + + repository = _repository(sqlite_session_factory) + with patch( + "repositories.recommended_app_catalog_repository.AppDslService.export_dsl", + return_value="exported yaml", + ): + detail = repository.get_detail(app.id) + + assert detail is not None + assert detail.id == app.id diff --git a/api/tests/unit_tests/services/recommend_app/__init__.py b/api/tests/unit_tests/services/recommend_app/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py b/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py deleted file mode 100644 index 638deea417e..00000000000 --- a/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py +++ /dev/null @@ -1,138 +0,0 @@ -import json -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import yaml -from sqlalchemy.orm import Session - -from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval -from services.recommend_app.recommend_app_type import RecommendAppType - -SAMPLE_BUILTIN_DATA = { - "recommended_apps": { - "en-US": {"categories": ["writing"], "apps": [{"id": "app-1"}]}, - "zh-Hans": {"categories": ["search"], "apps": [{"id": "app-2"}]}, - }, - "app_details": { - "app-1": {"id": "app-1", "name": "Writer", "mode": "chat"}, - "app-2": {"id": "app-2", "name": "Searcher", "mode": "workflow"}, - }, -} - - -@pytest.fixture(autouse=True) -def _reset_cache(): - BuildInRecommendAppRetrieval.builtin_data = None - yield - BuildInRecommendAppRetrieval.builtin_data = None - - -class TestBuildInRecommendAppRetrieval: - def test_get_type(self): - retrieval = BuildInRecommendAppRetrieval() - assert retrieval.get_type() == RecommendAppType.BUILDIN - - @pytest.mark.parametrize("sqlite_session", [()], indirect=True) - def test_get_recommended_apps_and_categories_delegates(self, sqlite_session: Session): - with patch.object( - BuildInRecommendAppRetrieval, - "fetch_recommended_apps_from_builtin", - return_value={"apps": []}, - ) as mock_fetch: - retrieval = BuildInRecommendAppRetrieval() - result = retrieval.get_recommended_apps_and_categories("en-US", session=sqlite_session) - mock_fetch.assert_called_once_with("en-US") - assert result == {"apps": []} - assert not sqlite_session.in_transaction() - - @pytest.mark.parametrize("sqlite_session", [()], indirect=True) - def test_get_learn_dify_apps_delegates_to_database(self, sqlite_session: Session): - expected = {"recommended_apps": [{"id": "learn-dify-app"}]} - with patch( - "services.recommend_app.buildin.buildin_retrieval.DatabaseRecommendAppRetrieval" - ) as mock_database_retrieval: - mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = expected - - result = BuildInRecommendAppRetrieval().get_learn_dify_apps("en-US", session=sqlite_session) - - assert result == expected - mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US", session=sqlite_session) - assert not sqlite_session.in_transaction() - - @pytest.mark.parametrize("sqlite_session", [()], indirect=True) - def test_get_recommend_app_detail_delegates(self, sqlite_session: Session): - with patch.object( - BuildInRecommendAppRetrieval, - "fetch_recommended_app_detail_from_builtin", - return_value={"id": "app-1"}, - ) as mock_fetch: - retrieval = BuildInRecommendAppRetrieval() - result = retrieval.get_recommend_app_detail("app-1", session=sqlite_session) - mock_fetch.assert_called_once_with("app-1") - assert result == {"id": "app-1"} - assert not sqlite_session.in_transaction() - - def test_get_builtin_data_reads_json_and_caches(self, tmp_path: Path): - json_file = tmp_path / "constants" / "recommended_apps.json" - json_file.parent.mkdir(parents=True) - json_file.write_text(json.dumps(SAMPLE_BUILTIN_DATA)) - - mock_app = MagicMock() - mock_app.root_path = str(tmp_path) - - with patch( - "services.recommend_app.buildin.buildin_retrieval.current_app", - mock_app, - ): - first = BuildInRecommendAppRetrieval._get_builtin_data() - second = BuildInRecommendAppRetrieval._get_builtin_data() - - assert first == SAMPLE_BUILTIN_DATA - assert first is second - - def test_fetch_recommended_apps_from_builtin(self): - BuildInRecommendAppRetrieval.builtin_data = SAMPLE_BUILTIN_DATA - result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin("en-US") - assert result == SAMPLE_BUILTIN_DATA["recommended_apps"]["en-US"] - - def test_fetch_recommended_apps_from_builtin_missing_language(self): - BuildInRecommendAppRetrieval.builtin_data = SAMPLE_BUILTIN_DATA - result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin("fr-FR") - assert result == {} - - def test_fetch_recommended_app_detail_from_builtin(self): - BuildInRecommendAppRetrieval.builtin_data = SAMPLE_BUILTIN_DATA - result = BuildInRecommendAppRetrieval.fetch_recommended_app_detail_from_builtin("app-1") - assert result == {"id": "app-1", "name": "Writer", "mode": "chat"} - - def test_fetch_recommended_app_detail_from_builtin_missing(self): - BuildInRecommendAppRetrieval.builtin_data = SAMPLE_BUILTIN_DATA - result = BuildInRecommendAppRetrieval.fetch_recommended_app_detail_from_builtin("nonexistent") - assert result is None - - -def test_builtin_workflow_templates_have_unique_end_output_variables(): - """Workflow publish validation rejects duplicate End output variable names, so the bundled - templates must not ship with duplicates or users cannot publish them (see issue #38278).""" - data_path = Path(__file__).resolve().parents[4] / "constants" / "recommended_apps.json" - data = json.loads(data_path.read_text(encoding="utf-8")) - - offenders: dict[str, list[str]] = {} - for app_id, detail in data.get("app_details", {}).items(): - export_data = detail.get("export_data") - if not export_data: - continue - dsl = yaml.safe_load(export_data) - nodes = (dsl or {}).get("workflow", {}).get("graph", {}).get("nodes", []) - output_names = [ - output.get("variable") - for node in nodes - if node.get("data", {}).get("type") == "end" - for output in (node.get("data", {}).get("outputs") or []) - ] - duplicates = sorted({name for name in output_names if output_names.count(name) > 1}) - if duplicates: - offenders[detail.get("name", app_id).strip()] = duplicates - - assert offenders == {}, f"templates with duplicate End output variable names: {offenders}" diff --git a/api/tests/unit_tests/services/recommend_app/test_category_order.py b/api/tests/unit_tests/services/recommend_app/test_category_order.py deleted file mode 100644 index 3b94021f26a..00000000000 --- a/api/tests/unit_tests/services/recommend_app/test_category_order.py +++ /dev/null @@ -1,26 +0,0 @@ -import json -from unittest.mock import patch - -from services.recommend_app.category_order import get_explore_app_category_order, order_categories - - -@patch("services.recommend_app.category_order.redis_client.get") -def test_get_explore_app_category_order_returns_redis_list(mock_get): - mock_get.return_value = json.dumps(["C", "A", "B"]).encode() - - assert get_explore_app_category_order("en-US") == ["C", "A", "B"] - mock_get.assert_called_once_with("explore:apps:category_order:en-US") - - -@patch("services.recommend_app.category_order.redis_client.get") -def test_order_categories_uses_redis_order_as_source_of_truth(mock_get): - mock_get.return_value = json.dumps(["C", "A", "B"]).encode() - - assert order_categories({"A", "B", "C", "D"}, "en-US") == ["C", "A", "B"] - - -@patch("services.recommend_app.category_order.redis_client.get") -def test_order_categories_falls_back_to_sorted_categories_without_redis_order(mock_get): - mock_get.return_value = None - - assert order_categories({"B", "A", "C"}, "en-US") == ["A", "B", "C"] diff --git a/api/tests/unit_tests/services/recommend_app/test_database_retrieval.py b/api/tests/unit_tests/services/recommend_app/test_database_retrieval.py deleted file mode 100644 index 6209a6e1c5a..00000000000 --- a/api/tests/unit_tests/services/recommend_app/test_database_retrieval.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Unit tests for database recommendation retrieval delegation.""" - -import uuid -from unittest.mock import MagicMock, patch - -from sqlalchemy.engine import Engine -from sqlalchemy.orm import Session - -from models import model as model_module -from models.model import App, AppMode, RecommendedApp, Site -from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval -from services.recommend_app.recommend_app_type import RecommendAppType - - -class TestDatabaseRecommendAppRetrieval: - def test_get_type(self) -> None: - assert DatabaseRecommendAppRetrieval().get_type() == RecommendAppType.DATABASE - - def test_get_recommended_apps_delegates(self, sqlite_engine: Engine) -> None: - with ( - Session(sqlite_engine) as session, - patch.object( - DatabaseRecommendAppRetrieval, - "fetch_recommended_apps_from_db", - return_value={"recommended_apps": [], "categories": []}, - ) as mock_fetch, - ): - result = DatabaseRecommendAppRetrieval().get_recommended_apps_and_categories("en-US", session=session) - - mock_fetch.assert_called_once_with("en-US", session=session) - assert result == {"recommended_apps": [], "categories": []} - - def test_get_recommend_app_detail_delegates(self, sqlite_engine: Engine) -> None: - with ( - Session(sqlite_engine) as session, - patch.object( - DatabaseRecommendAppRetrieval, - "fetch_recommended_app_detail_from_db", - return_value={"id": "app-1"}, - ) as mock_fetch, - ): - result = DatabaseRecommendAppRetrieval().get_recommend_app_detail("app-1", session=session) - - mock_fetch.assert_called_once_with("app-1", session=session) - assert result == {"id": "app-1"} - - def test_fetch_recommended_apps_uses_only_injected_session(self, sqlite_session: Session) -> None: - app = App( - id=str(uuid.uuid4()), - tenant_id=str(uuid.uuid4()), - name="Recommended App", - description="description", - mode=AppMode.CHAT, - icon_type=None, - icon=None, - icon_background=None, - enable_site=True, - enable_api=True, - is_public=True, - max_active_requests=None, - ) - site = Site( - app_id=app.id, - title="Recommended App", - description="site description", - default_language="en-US", - customize_token_strategy="uuid", - ) - recommended = RecommendedApp( - app_id=app.id, - description={}, - copyright="copyright", - privacy_policy="privacy", - category="Workflow", - categories=["Workflow"], - language="en-US", - ) - sqlite_session.add_all([app, site, recommended]) - sqlite_session.commit() - global_session = MagicMock() - global_session.scalar.side_effect = AssertionError("database retrieval must use the injected session") - - with patch.object(model_module.db, "session", global_session): - result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db( - "en-US", - session=sqlite_session, - ) - - assert result["recommended_apps"][0]["app"] is app - assert result["recommended_apps"][0]["description"] == "site description" - assert result["categories"] == ["Workflow"] - global_session.scalar.assert_not_called() diff --git a/api/tests/unit_tests/services/recommend_app/test_recommend_app_factory.py b/api/tests/unit_tests/services/recommend_app/test_recommend_app_factory.py deleted file mode 100644 index 036cba0cc00..00000000000 --- a/api/tests/unit_tests/services/recommend_app/test_recommend_app_factory.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest - -from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval -from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval -from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory -from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval - - -class TestRecommendAppRetrievalFactory: - @pytest.mark.parametrize( - ("mode", "expected_class"), - [ - ("remote", RemoteRecommendAppRetrieval), - ("builtin", BuildInRecommendAppRetrieval), - ("db", DatabaseRecommendAppRetrieval), - ], - ) - def test_factory_returns_correct_class(self, mode, expected_class): - result = RecommendAppRetrievalFactory.get_recommend_app_factory(mode) - assert result is expected_class - - def test_factory_raises_for_unknown_mode(self): - with pytest.raises(ValueError, match="invalid fetch recommended apps mode"): - RecommendAppRetrievalFactory.get_recommend_app_factory("invalid_mode") - - def test_get_buildin_recommend_app_retrieval(self): - result = RecommendAppRetrievalFactory.get_buildin_recommend_app_retrieval() - assert result is BuildInRecommendAppRetrieval diff --git a/api/tests/unit_tests/services/recommend_app/test_recommend_app_type.py b/api/tests/unit_tests/services/recommend_app/test_recommend_app_type.py deleted file mode 100644 index 08f72a6f774..00000000000 --- a/api/tests/unit_tests/services/recommend_app/test_recommend_app_type.py +++ /dev/null @@ -1,18 +0,0 @@ -from services.recommend_app.recommend_app_type import RecommendAppType - - -def test_enum_values(): - assert RecommendAppType.REMOTE == "remote" - assert RecommendAppType.BUILDIN == "builtin" - assert RecommendAppType.DATABASE == "db" - - -def test_enum_membership(): - assert "remote" in RecommendAppType.__members__.values() - assert "builtin" in RecommendAppType.__members__.values() - assert "db" in RecommendAppType.__members__.values() - - -def test_enum_is_str(): - for member in RecommendAppType: - assert isinstance(member, str) diff --git a/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py b/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py deleted file mode 100644 index 165381c1f68..00000000000 --- a/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py +++ /dev/null @@ -1,296 +0,0 @@ -from collections.abc import Iterator -from unittest.mock import MagicMock, patch - -import pytest -from flask import Flask -from sqlalchemy import Engine -from sqlalchemy.orm import Session - -from services.recommend_app.recommend_app_type import RecommendAppType -from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval, clear_remote_fetch_cache - - -@pytest.fixture(autouse=True) -def _clear_remote_fetch_cache_between_tests(): - clear_remote_fetch_cache() - yield - clear_remote_fetch_cache() - - -@pytest.fixture -def empty_sqlite_session(sqlite_engine: Engine) -> Iterator[Session]: - with Session(sqlite_engine) as session: - yield session - - -class TestRemoteRecommendAppRetrieval: - def test_get_type(self): - assert RemoteRecommendAppRetrieval().get_type() == RecommendAppType.REMOTE - - @patch.object( - RemoteRecommendAppRetrieval, - "fetch_recommended_app_detail_from_dify_official", - return_value={"id": "app-1"}, - ) - def test_get_recommend_app_detail_success(self, mock_fetch, empty_sqlite_session: Session): - result = RemoteRecommendAppRetrieval().get_recommend_app_detail("app-1", session=empty_sqlite_session) - assert result == {"id": "app-1"} - mock_fetch.assert_called_once_with("app-1") - assert not empty_sqlite_session.in_transaction() - - @patch( - "services.recommend_app.remote.remote_retrieval" - ".BuildInRecommendAppRetrieval.fetch_recommended_app_detail_from_builtin", - return_value={"id": "fallback"}, - ) - @patch.object( - RemoteRecommendAppRetrieval, - "fetch_recommended_app_detail_from_dify_official", - side_effect=ConnectionError("timeout"), - ) - def test_get_recommend_app_detail_falls_back_on_error( - self, mock_fetch, mock_builtin, empty_sqlite_session: Session - ): - result = RemoteRecommendAppRetrieval().get_recommend_app_detail("app-1", session=empty_sqlite_session) - assert result == {"id": "fallback"} - mock_builtin.assert_called_once_with("app-1") - assert not empty_sqlite_session.in_transaction() - - @patch.object( - RemoteRecommendAppRetrieval, - "fetch_recommended_apps_from_dify_official", - return_value={"recommended_apps": [], "categories": []}, - ) - def test_get_recommended_apps_success(self, mock_fetch, empty_sqlite_session: Session): - result = RemoteRecommendAppRetrieval().get_recommended_apps_and_categories( - "en-US", session=empty_sqlite_session - ) - assert result == {"recommended_apps": [], "categories": []} - assert not empty_sqlite_session.in_transaction() - - @patch( - "services.recommend_app.remote.remote_retrieval" - ".BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin", - return_value={"recommended_apps": [{"id": "builtin"}]}, - ) - @patch.object( - RemoteRecommendAppRetrieval, - "fetch_recommended_apps_from_dify_official", - side_effect=ValueError("server error"), - ) - def test_get_recommended_apps_falls_back_on_error(self, mock_fetch, mock_builtin, empty_sqlite_session: Session): - result = RemoteRecommendAppRetrieval().get_recommended_apps_and_categories( - "en-US", session=empty_sqlite_session - ) - assert result == {"recommended_apps": [{"id": "builtin"}]} - assert not empty_sqlite_session.in_transaction() - - @patch.object( - RemoteRecommendAppRetrieval, - "fetch_learn_dify_apps_from_dify_official", - return_value={"recommended_apps": [{"id": "learn-dify-app"}]}, - ) - def test_get_learn_dify_apps_success(self, mock_fetch, empty_sqlite_session: Session): - result = RemoteRecommendAppRetrieval().get_learn_dify_apps("en-US", session=empty_sqlite_session) - - assert result == {"recommended_apps": [{"id": "learn-dify-app"}]} - mock_fetch.assert_called_once_with("en-US") - assert not empty_sqlite_session.in_transaction() - - @patch( - "services.recommend_app.remote.remote_retrieval.DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db", - return_value={"recommended_apps": [{"id": "db-fallback"}]}, - ) - @patch.object( - RemoteRecommendAppRetrieval, - "fetch_learn_dify_apps_from_dify_official", - side_effect=ValueError("server error"), - ) - def test_get_learn_dify_apps_falls_back_to_database_on_error( - self, mock_fetch, mock_database, empty_sqlite_session: Session - ): - result = RemoteRecommendAppRetrieval().get_learn_dify_apps("en-US", session=empty_sqlite_session) - - assert result == {"recommended_apps": [{"id": "db-fallback"}]} - mock_database.assert_called_once_with("en-US", session=empty_sqlite_session) - assert not empty_sqlite_session.in_transaction() - - -class TestFetchFromDifyOfficial: - @pytest.fixture(autouse=True) - def _remote_config(self, config_overrides): - config_overrides( - HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://example.com", - HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=300, - CONSOLE_WEB_URL="", - ) - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_detail_returns_json_on_200(self, mock_get): - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"id": "app-1", "name": "Test"} - mock_get.return_value = mock_response - - result = RemoteRecommendAppRetrieval.fetch_recommended_app_detail_from_dify_official("app-1") - - assert result == {"id": "app-1", "name": "Test"} - mock_get.assert_called_once() - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_detail_returns_none_on_non_200(self, mock_get): - mock_get.return_value = MagicMock(status_code=404) - - result = RemoteRecommendAppRetrieval.fetch_recommended_app_detail_from_dify_official("app-1") - - assert result is None - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_preserves_remote_categories_order_on_200(self, mock_get): - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = { - "recommended_apps": [], - "categories": ["writing", "agent", "chat"], - } - mock_get.return_value = mock_response - - result = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert result["categories"] == ["writing", "agent", "chat"] - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_raises_on_non_200(self, mock_get): - mock_get.return_value = MagicMock(status_code=500) - - with pytest.raises(ValueError, match="fetch recommended apps failed"): - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_without_categories_key(self, mock_get): - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"recommended_apps": []} - mock_get.return_value = mock_response - - result = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert "categories" not in result - assert mock_get.call_args.kwargs["headers"] == {} - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_forwards_request_origin_header(self, mock_get, config_overrides): - config_overrides(CONSOLE_WEB_URL="https://saas.dify.dev") - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"recommended_apps": []} - mock_get.return_value = mock_response - - flask_app = Flask(__name__) - with flask_app.test_request_context(headers={"Origin": "https://cloud.example.com"}): - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert mock_get.call_args.kwargs["headers"] == {"Origin": "https://cloud.example.com"} - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_falls_back_to_console_web_url_origin(self, mock_get, config_overrides): - config_overrides(CONSOLE_WEB_URL="https://saas.dify.dev/console") - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"recommended_apps": []} - mock_get.return_value = mock_response - - flask_app = Flask(__name__) - with flask_app.test_request_context(): - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert mock_get.call_args.kwargs["headers"] == {"Origin": "https://saas.dify.dev/console"} - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_falls_back_to_console_web_url_without_request_context(self, mock_get, config_overrides): - config_overrides(CONSOLE_WEB_URL="http://localhost:3000/console") - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"recommended_apps": []} - mock_get.return_value = mock_response - - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert mock_get.call_args.kwargs["headers"] == {"Origin": "http://localhost:3000/console"} - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_uses_console_web_url_without_scheme(self, mock_get, config_overrides): - config_overrides(CONSOLE_WEB_URL="saas.dify.dev") - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"recommended_apps": []} - mock_get.return_value = mock_response - - flask_app = Flask(__name__) - with flask_app.test_request_context(): - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert mock_get.call_args.kwargs["headers"] == {"Origin": "saas.dify.dev"} - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_learn_dify_apps_returns_json_on_200(self, mock_get): - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"recommended_apps": [{"id": "learn-dify-app"}]} - mock_get.return_value = mock_response - - result = RemoteRecommendAppRetrieval.fetch_learn_dify_apps_from_dify_official("en-US") - - assert result == {"recommended_apps": [{"id": "learn-dify-app"}]} - assert mock_get.call_args.args[0] == "https://example.com/apps/learn-dify?language=en-US" - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_learn_dify_apps_raises_on_non_200(self, mock_get): - mock_get.return_value = MagicMock(status_code=500) - - with pytest.raises(ValueError, match="fetch learn dify apps failed"): - RemoteRecommendAppRetrieval.fetch_learn_dify_apps_from_dify_official("en-US") - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_uses_cache_for_repeated_requests(self, mock_get, config_overrides): - config_overrides(HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600) - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"recommended_apps": [{"id": "app-1"}]} - mock_get.return_value = mock_response - - first = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - second = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert first == second == {"recommended_apps": [{"id": "app-1"}]} - mock_get.assert_called_once() - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_does_not_cache_failed_responses(self, mock_get, config_overrides): - config_overrides(HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600) - mock_get.return_value = MagicMock(status_code=500) - - with pytest.raises(ValueError, match="fetch recommended apps failed"): - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - with pytest.raises(ValueError, match="fetch recommended apps failed"): - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert mock_get.call_count == 2 - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_skips_cache_when_ttl_disabled(self, mock_get, config_overrides): - config_overrides(HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=0) - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"recommended_apps": []} - mock_get.return_value = mock_response - - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert mock_get.call_count == 2 - - @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_cache_isolated_by_origin_header(self, mock_get, config_overrides): - config_overrides(HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600) - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"recommended_apps": []} - mock_get.return_value = mock_response - - flask_app = Flask(__name__) - with flask_app.test_request_context(headers={"Origin": "https://cloud-a.example.com"}): - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - with flask_app.test_request_context(headers={"Origin": "https://cloud-b.example.com"}): - RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - - assert mock_get.call_count == 2 diff --git a/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py b/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py new file mode 100644 index 00000000000..bcd48c1b2d3 --- /dev/null +++ b/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py @@ -0,0 +1,661 @@ +import json +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest +import yaml + +from services import recommended_app_catalog_gateway as gateway_module +from services.recommended_app_catalog_gateway import ( + BuiltinRecommendedAppCatalogGateway, + RecommendedAppCatalogRouter, + RemoteRecommendedAppCatalogGateway, +) +from services.recommended_app_query_service import ( + RecommendedAppCatalogPage, + RecommendedAppDetailRecord, + RecommendedAppInfoRecord, + RecommendedAppRecord, +) + + +def _page_payload(*app_ids: str, learn_dify_ids: frozenset[str] = frozenset()) -> dict[str, object]: + app_ids = app_ids or ("app-1",) + return { + "recommended_apps": [ + { + "app": { + "id": app_id, + "name": "App", + "mode": "chat", + "icon": "icon.png", + "icon_type": "image", + "icon_background": "#fff", + }, + "app_id": app_id, + "description": "description", + "copyright": None, + "privacy_policy": None, + "categories": ["Workflow"], + "position": 1, + "is_listed": True, + **({"is_learn_dify": True} if app_id in learn_dify_ids else {}), + } + for app_id in app_ids + ], + "categories": ["Workflow"], + } + + +def _detail_payload() -> dict[str, object]: + return { + "id": "app-1", + "name": "App", + "icon": None, + "icon_background": None, + "mode": "chat", + "export_data": "{}", + } + + +def _expected_page(*, categories: tuple[str, ...] = ("Workflow",)) -> RecommendedAppCatalogPage: + return RecommendedAppCatalogPage( + recommended_apps=( + RecommendedAppRecord( + app=RecommendedAppInfoRecord( + id="app-1", + name="App", + mode="chat", + icon="icon.png", + icon_type="image", + icon_background="#fff", + ), + app_id="app-1", + description="description", + copyright=None, + privacy_policy=None, + custom_disclaimer=None, + categories=("Workflow",), + position=1, + is_listed=True, + ), + ), + categories=categories, + ) + + +class TestBuiltinRecommendedAppCatalogGateway: + def test_maps_bundled_catalog(self) -> None: + gateway = BuiltinRecommendedAppCatalogGateway() + page = gateway.list_recommended("en-US") + learn_dify = gateway.list_learn_dify("ja-JP") + detail = gateway.get_detail(page.recommended_apps[0].app_id) + + assert page.recommended_apps + assert [app.app_id for app in learn_dify.recommended_apps] == [ + "f00c4531-6551-45ee-808f-1d7903099515", + "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7", + "e9870913-dd01-4710-9f06-15d4180ca1ce", + ] + assert all(gateway.get_detail(app.app_id) is not None for app in learn_dify.recommended_apps) + assert detail is not None + + def test_bundled_workflow_templates_have_unique_end_output_variables(self) -> None: + data_path = Path(gateway_module.__file__).resolve().parents[1] / "constants" / "recommended_apps.json" + data = json.loads(data_path.read_text(encoding="utf-8")) + + offenders: dict[str, list[str]] = {} + for app_id, detail in data.get("app_details", {}).items(): + export_data = detail.get("export_data") + if not export_data: + continue + dsl = yaml.safe_load(export_data) + nodes = (dsl or {}).get("workflow", {}).get("graph", {}).get("nodes", []) + output_names = [ + output.get("variable") + for node in nodes + if node.get("data", {}).get("type") == "end" + for output in (node.get("data", {}).get("outputs") or []) + ] + duplicates = sorted({name for name in output_names if output_names.count(name) > 1}) + if duplicates: + offenders[detail.get("name", app_id).strip()] = duplicates + + assert offenders == {}, f"templates with duplicate End output variable names: {offenders}" + + def test_maps_builtin_payload(self) -> None: + gateway = BuiltinRecommendedAppCatalogGateway() + gateway._data = { + "recommended_apps": {"en-US": _page_payload("app-1", "app-2", learn_dify_ids=frozenset({"app-1"}))}, + "app_details": {"app-1": _detail_payload()}, + } + + assert [app.app_id for app in gateway.list_recommended("en-US").recommended_apps] == ["app-1", "app-2"] + assert gateway.list_learn_dify("en-US") == RecommendedAppCatalogPage( + recommended_apps=_expected_page().recommended_apps, + categories=(), + ) + assert gateway.get_detail("app-1") == RecommendedAppDetailRecord( + id="app-1", + name="App", + icon=None, + icon_background=None, + mode="chat", + export_data="{}", + ) + + def test_membership_uses_raw_non_none_detail(self) -> None: + gateway = BuiltinRecommendedAppCatalogGateway() + gateway._data = {"app_details": {"malformed": object()}} + + assert gateway.contains("malformed") is True + assert gateway.contains("missing") is False + with pytest.raises(TypeError, match="recommended app detail must be a mapping"): + gateway.get_detail("malformed") + + def test_missing_language_returns_empty_page(self) -> None: + gateway = BuiltinRecommendedAppCatalogGateway() + gateway._data = {"recommended_apps": {}} + + assert gateway.list_recommended("fr-FR") == RecommendedAppCatalogPage( + recommended_apps=(), + categories=(), + ) + assert gateway.list_learn_dify("fr-FR") == RecommendedAppCatalogPage( + recommended_apps=(), + categories=(), + ) + + def test_nonempty_page_requires_categories(self) -> None: + page = _page_payload() + del page["categories"] + gateway = BuiltinRecommendedAppCatalogGateway() + gateway._data = {"recommended_apps": {"en-US": page}} + + with pytest.raises(KeyError, match="categories"): + gateway.list_recommended("en-US") + + @pytest.mark.parametrize("categories", ["Agent", b"Agent", ["Agent", 1]]) + def test_rejects_malformed_page_categories(self, categories: object) -> None: + page = _page_payload() + page["categories"] = categories + gateway = BuiltinRecommendedAppCatalogGateway() + gateway._data = {"recommended_apps": {"en-US": page}} + + with pytest.raises(TypeError, match="categories must"): + gateway.list_recommended("en-US") + + def test_rejects_malformed_app_categories(self) -> None: + gateway = BuiltinRecommendedAppCatalogGateway() + gateway._data = { + "recommended_apps": { + "en-US": { + "recommended_apps": [{"app": None, "app_id": "app-1", "categories": "Agent"}], + "categories": ["Agent"], + } + } + } + + with pytest.raises(TypeError, match="categories must"): + gateway.list_recommended("en-US") + + def test_rejects_non_string_detail_mode(self) -> None: + detail = _detail_payload() + detail["mode"] = object() + gateway = BuiltinRecommendedAppCatalogGateway() + gateway._data = {"app_details": {"app-1": detail}} + + with pytest.raises(TypeError, match="mode must be a string"): + gateway.get_detail("app-1") + + def test_reads_builtin_file_once_per_gateway(self) -> None: + gateway = BuiltinRecommendedAppCatalogGateway() + payload = json.dumps({"recommended_apps": {"en-US": _page_payload()}}) + + with patch.object(gateway_module.Path, "read_text", return_value=payload) as read_text: + gateway.list_recommended("en-US") + gateway.list_recommended("en-US") + + read_text.assert_called_once_with(encoding="utf-8") + + +class TestRemoteRecommendedAppCatalogGateway: + @pytest.fixture(autouse=True) + def _use_remote_mode(self, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + gateway_module.clear_remote_fetch_cache() + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + yield + gateway_module.clear_remote_fetch_cache() + + def test_maps_remote_pages_without_reordering(self, monkeypatch: pytest.MonkeyPatch) -> None: + gateway = RemoteRecommendedAppCatalogGateway() + payload = _page_payload("app-2", "app-1") + payload["categories"] = ["Writing", "Agent"] + monkeypatch.setattr(gateway, "_fetch_page", MagicMock(return_value=payload)) + monkeypatch.setattr(gateway, "_fetch_learn_dify_page", MagicMock(return_value=payload)) + + recommended = gateway.list_recommended("en-US") + learn_dify = gateway.list_learn_dify("en-US") + assert [app.app_id for app in recommended.recommended_apps] == ["app-2", "app-1"] + assert recommended.categories == ("Writing", "Agent") + assert [app.app_id for app in learn_dify.recommended_apps] == ["app-2", "app-1"] + assert learn_dify.categories == () + + def test_list_fetch_error_falls_back_through_builtin_en_us(self, monkeypatch: pytest.MonkeyPatch) -> None: + fallback = MagicMock() + empty_page = RecommendedAppCatalogPage(recommended_apps=(), categories=()) + fallback_page = _expected_page(categories=("builtin",)) + fallback.list_recommended.side_effect = [empty_page, fallback_page] + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=MagicMock(), + builtin=fallback, + ) + + monkeypatch.setattr(remote, "_fetch_page", MagicMock(side_effect=ConnectionError("timeout"))) + + assert router.list_recommended("fr-FR") == fallback_page + assert fallback.list_recommended.call_args_list == [call("fr-FR"), call("en-US")] + + def test_json_decode_error_falls_back_to_builtin(self, monkeypatch: pytest.MonkeyPatch) -> None: + fallback = MagicMock() + expected_page = _expected_page() + fallback.list_recommended.return_value = expected_page + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=MagicMock(), + builtin=fallback, + ) + response = MagicMock(status_code=200) + response.json.side_effect = ValueError("invalid JSON") + monkeypatch.setattr(gateway_module.httpx, "get", MagicMock(return_value=response)) + + assert router.list_recommended("en-US") == expected_page + fallback.list_recommended.assert_called_once_with("en-US") + + def test_payload_mapping_error_does_not_fall_back(self, monkeypatch: pytest.MonkeyPatch) -> None: + fallback = MagicMock() + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=MagicMock(), + builtin=fallback, + ) + monkeypatch.setattr(remote, "_fetch_page", MagicMock(return_value=object())) + + with pytest.raises(TypeError, match="recommended app page must be a mapping"): + router.list_recommended("en-US") + fallback.list_recommended.assert_not_called() + + def test_learn_dify_fetch_error_falls_back_to_builtin(self, monkeypatch: pytest.MonkeyPatch) -> None: + builtin = MagicMock() + database = MagicMock() + page = RecommendedAppCatalogPage(recommended_apps=(), categories=()) + builtin.list_learn_dify.return_value = page + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=database, + builtin=builtin, + ) + + monkeypatch.setattr(remote, "_fetch_learn_dify_page", MagicMock(side_effect=ConnectionError("timeout"))) + + assert router.list_learn_dify("ja-JP") == page + builtin.list_learn_dify.assert_called_once_with("ja-JP") + database.list_learn_dify.assert_not_called() + + def test_empty_remote_learn_dify_page_does_not_fall_back(self, monkeypatch: pytest.MonkeyPatch) -> None: + database = MagicMock() + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=database, + builtin=MagicMock(), + ) + monkeypatch.setattr( + remote, + "_fetch_learn_dify_page", + MagicMock(return_value={"recommended_apps": [], "categories": []}), + ) + + assert router.list_learn_dify("en-US") == RecommendedAppCatalogPage( + recommended_apps=(), + categories=(), + ) + database.list_learn_dify.assert_not_called() + + @pytest.mark.parametrize("status_code", [404, 500]) + def test_detail_non_200_returns_none_without_builtin_fallback( + self, + monkeypatch: pytest.MonkeyPatch, + status_code: int, + ) -> None: + fallback = MagicMock() + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=MagicMock(), + builtin=fallback, + ) + response = MagicMock(status_code=status_code) + monkeypatch.setattr("services.recommended_app_catalog_gateway.httpx.get", MagicMock(return_value=response)) + + assert router.get_detail("missing") is None + fallback.get_detail.assert_not_called() + + def test_detail_fetch_error_falls_back_to_builtin(self, monkeypatch: pytest.MonkeyPatch) -> None: + fallback = MagicMock() + fallback_detail = RecommendedAppDetailRecord( + id="fallback", + name="Fallback", + icon=None, + icon_background=None, + mode="chat", + export_data="{}", + ) + fallback.get_detail.return_value = fallback_detail + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=MagicMock(), + builtin=fallback, + ) + + monkeypatch.setattr(remote, "_fetch_detail", MagicMock(side_effect=ConnectionError("timeout"))) + + assert router.get_detail("app-1") == fallback_detail + fallback.get_detail.assert_called_once_with("app-1") + + def test_detail_mapping_error_does_not_fall_back(self, monkeypatch: pytest.MonkeyPatch) -> None: + fallback = MagicMock() + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=MagicMock(), + builtin=fallback, + ) + monkeypatch.setattr(remote, "_fetch_detail", MagicMock(return_value=object())) + + with pytest.raises(TypeError, match="recommended app detail must be a mapping"): + router.get_detail("app-1") + fallback.get_detail.assert_not_called() + + def test_learn_dify_mapping_error_does_not_fall_back(self, monkeypatch: pytest.MonkeyPatch) -> None: + database = MagicMock() + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=database, + builtin=MagicMock(), + ) + monkeypatch.setattr(remote, "_fetch_learn_dify_page", MagicMock(return_value=object())) + + with pytest.raises(TypeError, match="Learn Dify app page must be a mapping"): + router.list_learn_dify("en-US") + database.list_learn_dify.assert_not_called() + + def test_membership_accepts_raw_non_none_payload(self, monkeypatch: pytest.MonkeyPatch) -> None: + gateway = RemoteRecommendedAppCatalogGateway() + monkeypatch.setattr(gateway, "_fetch_detail", MagicMock(return_value=object())) + + assert gateway.contains("app-1") is True + + @pytest.mark.parametrize("status_code", [404, 500]) + def test_membership_non_200_does_not_fall_back( + self, + monkeypatch: pytest.MonkeyPatch, + status_code: int, + ) -> None: + fallback = MagicMock() + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=MagicMock(), + builtin=fallback, + ) + response = MagicMock(status_code=status_code) + monkeypatch.setattr(gateway_module.httpx, "get", MagicMock(return_value=response)) + + assert router.contains("missing") is False + fallback.contains.assert_not_called() + + def test_membership_fetch_error_falls_back_to_builtin(self, monkeypatch: pytest.MonkeyPatch) -> None: + fallback = MagicMock() + fallback.contains.return_value = True + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=MagicMock(), + builtin=fallback, + ) + monkeypatch.setattr(remote, "_fetch_detail", MagicMock(side_effect=ConnectionError("timeout"))) + + assert router.contains("app-1") is True + fallback.contains.assert_called_once_with("app-1") + + def test_remote_request_uses_configured_origin_and_timeouts( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + response = MagicMock(status_code=200) + response.json.return_value = _detail_payload() + http_get = MagicMock(return_value=response) + monkeypatch.setattr(gateway_module.httpx, "get", http_get) + monkeypatch.setattr( + gateway_module.dify_config, + "HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN", + "https://catalog.example.com", + ) + monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", "https://console.example.com") + gateway = RemoteRecommendedAppCatalogGateway() + gateway.get_detail("app-1") + + http_get.assert_called_once() + call = http_get.call_args + assert call.args == ("https://catalog.example.com/apps/app-1",) + assert call.kwargs["headers"] == {"Origin": "https://console.example.com"} + assert call.kwargs["timeout"].connect == 3.0 + assert call.kwargs["timeout"].read == 10.0 + + def test_remote_request_uses_cache(self, monkeypatch: pytest.MonkeyPatch) -> None: + response = MagicMock(status_code=200) + response.json.return_value = _page_payload() + http_get = MagicMock(return_value=response) + monkeypatch.setattr(gateway_module.httpx, "get", http_get) + monkeypatch.setattr( + gateway_module.dify_config, + "HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN", + "https://catalog.example.com", + ) + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 600) + gateway = RemoteRecommendedAppCatalogGateway() + + assert gateway.list_recommended("en-US") == _expected_page() + assert gateway.list_recommended("en-US") == _expected_page() + http_get.assert_called_once() + + def test_remote_request_does_not_cache_failed_responses(self, monkeypatch: pytest.MonkeyPatch) -> None: + response = MagicMock(status_code=500) + http_get = MagicMock(return_value=response) + monkeypatch.setattr(gateway_module.httpx, "get", http_get) + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 600) + expected_page = _expected_page() + fallback = MagicMock() + fallback.list_recommended.return_value = expected_page + router = RecommendedAppCatalogRouter( + remote=RemoteRecommendedAppCatalogGateway(), + database=MagicMock(), + builtin=fallback, + ) + + assert router.list_recommended("en-US") == expected_page + assert router.list_recommended("en-US") == expected_page + assert http_get.call_count == 2 + + def test_remote_request_skips_cache_when_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + response = MagicMock(status_code=200) + response.json.return_value = _page_payload() + http_get = MagicMock(return_value=response) + monkeypatch.setattr(gateway_module.httpx, "get", http_get) + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 0) + gateway = RemoteRecommendedAppCatalogGateway() + + gateway.list_recommended("en-US") + gateway.list_recommended("en-US") + assert http_get.call_count == 2 + + def test_remote_request_cache_isolated_by_configured_origin( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + response = MagicMock(status_code=200) + response.json.return_value = _page_payload() + http_get = MagicMock(return_value=response) + monkeypatch.setattr(gateway_module.httpx, "get", http_get) + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 600) + gateway = RemoteRecommendedAppCatalogGateway() + monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", "https://cloud-a.example.com") + gateway.list_recommended("en-US") + monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", "https://cloud-b.example.com") + gateway.list_recommended("en-US") + + assert http_get.call_count == 2 + + @pytest.mark.parametrize( + ("console_web_url", "expected_headers"), + [ + ("saas.dify.dev", {"Origin": "saas.dify.dev"}), + ("http://localhost:3000/console", {"Origin": "http://localhost:3000/console"}), + ("", {}), + ], + ) + def test_remote_request_uses_console_web_url( + self, + monkeypatch: pytest.MonkeyPatch, + console_web_url: str, + expected_headers: dict[str, str], + ) -> None: + response = MagicMock(status_code=200) + response.json.return_value = _detail_payload() + http_get = MagicMock(return_value=response) + monkeypatch.setattr(gateway_module.httpx, "get", http_get) + monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", console_web_url) + gateway = RemoteRecommendedAppCatalogGateway() + gateway.get_detail("app-1") + + assert http_get.call_args.kwargs["headers"] == expected_headers + + @pytest.mark.parametrize( + ("operation", "expected_url"), + [ + ("recommended", "https://catalog.example.com/apps?language=ja-JP"), + ("learn_dify", "https://catalog.example.com/apps/learn-dify?language=ja-JP"), + ], + ) + def test_remote_list_non_200_uses_expected_fallback( + self, + monkeypatch: pytest.MonkeyPatch, + operation: str, + expected_url: str, + ) -> None: + response = MagicMock(status_code=500) + http_get = MagicMock(return_value=response) + monkeypatch.setattr(gateway_module.httpx, "get", http_get) + monkeypatch.setattr( + gateway_module.dify_config, + "HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN", + "https://catalog.example.com", + ) + fallback = MagicMock() + database = MagicMock() + expected_page = _expected_page() + fallback.list_recommended.return_value = expected_page + fallback.list_learn_dify.return_value = expected_page + remote = RemoteRecommendedAppCatalogGateway() + router = RecommendedAppCatalogRouter( + remote=remote, + database=database, + builtin=fallback, + ) + + result = router.list_recommended("ja-JP") if operation == "recommended" else router.list_learn_dify("ja-JP") + fallback_call = fallback.list_recommended if operation == "recommended" else fallback.list_learn_dify + + assert result == expected_page + assert http_get.call_args.args == (expected_url,) + fallback_call.assert_called_once_with("ja-JP") + database.list_learn_dify.assert_not_called() + + +class TestRecommendedAppCatalogRouter: + def test_empty_page_falls_back_to_builtin_en_us(self, monkeypatch: pytest.MonkeyPatch) -> None: + remote = MagicMock() + builtin = MagicMock() + remote.list_recommended.return_value = RecommendedAppCatalogPage(recommended_apps=(), categories=()) + expected_page = _expected_page(categories=("builtin",)) + builtin.list_recommended.return_value = expected_page + gateway = RecommendedAppCatalogRouter( + remote=remote, + database=MagicMock(), + builtin=builtin, + ) + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + + assert gateway.list_recommended("ja-JP") == expected_page + remote.list_recommended.assert_called_once_with("ja-JP") + builtin.list_recommended.assert_called_once_with("en-US") + + def test_resolves_mode_for_every_operation(self, monkeypatch: pytest.MonkeyPatch) -> None: + remote = MagicMock() + database = MagicMock() + builtin = MagicMock() + gateway = RecommendedAppCatalogRouter( + remote=remote, + database=database, + builtin=builtin, + ) + + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + gateway.list_recommended("en-US") + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "db") + gateway.list_learn_dify("en-US") + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "builtin") + gateway.get_detail("app-1") + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + gateway.contains("app-1") + + remote.list_recommended.assert_called_once_with("en-US") + database.list_learn_dify.assert_called_once_with("en-US") + builtin.get_detail.assert_called_once_with("app-1") + remote.contains.assert_called_once_with("app-1") + + def test_builtin_mode_reads_builtin_learn_dify(self, monkeypatch: pytest.MonkeyPatch) -> None: + builtin = MagicMock() + database = MagicMock() + expected_page = RecommendedAppCatalogPage(recommended_apps=(), categories=()) + builtin.list_learn_dify.return_value = expected_page + gateway = RecommendedAppCatalogRouter( + remote=MagicMock(), + database=database, + builtin=builtin, + ) + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "builtin") + + assert gateway.list_learn_dify("en-US") == expected_page + builtin.list_learn_dify.assert_called_once_with("en-US") + database.list_learn_dify.assert_not_called() + + def test_rejects_invalid_mode(self, monkeypatch: pytest.MonkeyPatch) -> None: + gateway = RecommendedAppCatalogRouter( + remote=MagicMock(), + database=MagicMock(), + builtin=MagicMock(), + ) + monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "invalid") + + with pytest.raises(ValueError, match="invalid fetch recommended apps mode: invalid"): + gateway.list_recommended("en-US") diff --git a/api/tests/unit_tests/services/test_recommended_app_query_compat.py b/api/tests/unit_tests/services/test_recommended_app_query_compat.py deleted file mode 100644 index f1bf8c8f15c..00000000000 --- a/api/tests/unit_tests/services/test_recommended_app_query_compat.py +++ /dev/null @@ -1,261 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from sqlalchemy.orm import Session, sessionmaker - -from models.model import App, AppMode, IconType -from services import recommended_app_query_compat as compat_module -from services.recommended_app_query_compat import LegacyRecommendedAppCatalogGateway -from services.recommended_app_query_service import ( - RecommendedAppCatalogPage, - RecommendedAppDetailRecord, - RecommendedAppInfoRecord, - RecommendedAppRecord, -) - - -@pytest.fixture -def gateway_dependencies( - monkeypatch: pytest.MonkeyPatch, -) -> tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock]: - session = MagicMock(spec=Session) - session_factory = MagicMock(spec=sessionmaker) - session_factory.return_value.__enter__.return_value = session - retrieval = MagicMock() - retrieval_type = MagicMock(return_value=retrieval) - get_factory = MagicMock(return_value=retrieval_type) - monkeypatch.setattr(compat_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") - monkeypatch.setattr( - compat_module.RecommendAppRetrievalFactory, - "get_recommend_app_factory", - get_factory, - ) - return LegacyRecommendedAppCatalogGateway(session_factory), session, retrieval, get_factory - - -@pytest.mark.parametrize(("detail", "expected"), [(object(), True), (None, False)]) -def test_is_recommended_uses_configured_retrieval_without_mapping_detail( - detail: object | None, - expected: bool, - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, session, retrieval, get_factory = gateway_dependencies - retrieval.get_recommend_app_detail.return_value = detail - - assert gateway.is_recommended("app-1") is expected - get_factory.assert_called_once_with("remote") - retrieval.get_recommend_app_detail.assert_called_once_with("app-1", session=session) - - -@pytest.mark.parametrize("app_source_kind", ["mapping", "orm"]) -def test_list_recommended_selects_configured_retrieval_and_maps_mixed_results( - app_source_kind: str, - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, session, retrieval, get_factory = gateway_dependencies - app = App( - tenant_id="tenant-1", - name="App", - mode=AppMode.CHAT, - icon_type=IconType.IMAGE, - icon="icon.png", - icon_background="#fff", - enable_site=True, - enable_api=True, - ) - app.id = "app-1" - app_source: object = app - if app_source_kind == "mapping": - app_source = { - "id": "app-1", - "name": "App", - "mode": "chat", - "icon": "icon.png", - "icon_type": "image", - "icon_background": "#fff", - } - retrieval.get_recommended_apps_and_categories.return_value = { - "recommended_apps": [ - { - "app": app_source, - "app_id": "app-1", - "description": "description", - "copyright": None, - "privacy_policy": None, - "categories": ["Workflow"], - "position": 1, - "is_listed": True, - } - ], - "categories": ["Workflow"], - } - - result = gateway.list_recommended("en-US") - - assert result == RecommendedAppCatalogPage( - recommended_apps=( - RecommendedAppRecord( - app=RecommendedAppInfoRecord( - id="app-1", - name="App", - mode="chat", - icon="icon.png", - icon_type="image", - icon_background="#fff", - ), - app_id="app-1", - description="description", - copyright=None, - privacy_policy=None, - custom_disclaimer=None, - categories=("Workflow",), - position=1, - is_listed=True, - ), - ), - categories=("Workflow",), - ) - get_factory.assert_called_once_with("remote") - retrieval.get_recommended_apps_and_categories.assert_called_once_with("en-US", session=session) - - -def test_list_builtin_uses_builtin_source( - monkeypatch: pytest.MonkeyPatch, - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, _, _, _ = gateway_dependencies - builtin = MagicMock() - builtin_result: dict[str, object] = {"recommended_apps": [], "categories": []} - builtin.fetch_recommended_apps_from_builtin.return_value = builtin_result - get_builtin = MagicMock(return_value=builtin) - monkeypatch.setattr( - compat_module.RecommendAppRetrievalFactory, - "get_buildin_recommend_app_retrieval", - get_builtin, - ) - - result = gateway.list_builtin("en-US") - - assert result == RecommendedAppCatalogPage(recommended_apps=(), categories=()) - builtin.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") - - -def test_list_learn_dify_delegates_to_configured_retrieval( - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, session, retrieval, _ = gateway_dependencies - learn_dify_result: dict[str, object] = {"recommended_apps": [], "categories": ["ignored"]} - retrieval.get_learn_dify_apps.return_value = learn_dify_result - - result = gateway.list_learn_dify("fr-FR") - - assert result.categories == () - retrieval.get_learn_dify_apps.assert_called_once_with("fr-FR", session=session) - - -def test_list_recommended_maps_none_apps_to_empty_page( - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, _, retrieval, _ = gateway_dependencies - retrieval.get_recommended_apps_and_categories.return_value = { - "recommended_apps": None, - "categories": ["ignored"], - } - - assert gateway.list_recommended("en-US") == RecommendedAppCatalogPage(recommended_apps=(), categories=()) - - -def test_list_recommended_rejects_missing_categories_for_nonempty_page( - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, _, retrieval, _ = gateway_dependencies - retrieval.get_recommended_apps_and_categories.return_value = { - "recommended_apps": [{"app_id": "app-1", "app": None}], - } - - with pytest.raises(KeyError, match="categories"): - gateway.list_recommended("en-US") - - -@pytest.mark.parametrize("categories", ["Agent", b"Agent", ["Agent", 1]]) -def test_list_recommended_rejects_malformed_categories( - categories: object, - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, _, retrieval, _ = gateway_dependencies - retrieval.get_recommended_apps_and_categories.return_value = { - "recommended_apps": [{"app_id": "app-1", "app": None, "categories": list[str]()}], - "categories": categories, - } - - with pytest.raises(TypeError, match="categories must"): - gateway.list_recommended("en-US") - - -def test_list_recommended_rejects_malformed_app_categories( - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, _, retrieval, _ = gateway_dependencies - retrieval.get_recommended_apps_and_categories.return_value = { - "recommended_apps": [{"app_id": "app-1", "app": None, "categories": "Agent"}], - "categories": ["Agent"], - } - - with pytest.raises(TypeError, match="categories must"): - gateway.list_recommended("en-US") - - -def test_get_detail_maps_result_and_preserves_nullable_contract( - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, session, retrieval, _ = gateway_dependencies - retrieval.get_recommend_app_detail.side_effect = [ - { - "id": "app-1", - "name": "App", - "icon": None, - "icon_background": None, - "mode": AppMode.CHAT, - "export_data": "{}", - }, - None, - ] - - assert gateway.get_detail("app-1") == RecommendedAppDetailRecord( - id="app-1", - name="App", - icon=None, - icon_background=None, - mode="chat", - export_data="{}", - ) - assert gateway.get_detail("missing") is None - assert retrieval.get_recommend_app_detail.call_args_list[0].args == ("app-1",) - assert retrieval.get_recommend_app_detail.call_args_list[0].kwargs == {"session": session} - - -def test_get_detail_rejects_arbitrary_enum_like_object( - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, _, retrieval, _ = gateway_dependencies - retrieval.get_recommend_app_detail.return_value = { - "id": "app-1", - "name": "App", - "icon": None, - "icon_background": None, - "mode": object(), - "export_data": "{}", - } - - with pytest.raises(TypeError, match="mode must be a string or string enum"): - gateway.get_detail("app-1") - - -def test_get_detail_rejects_non_mapping_result( - gateway_dependencies: tuple[LegacyRecommendedAppCatalogGateway, MagicMock, MagicMock, MagicMock], -) -> None: - gateway, _, retrieval, _ = gateway_dependencies - retrieval.get_recommend_app_detail.return_value = object() - - with pytest.raises(TypeError, match="recommended app detail must be a mapping"): - gateway.get_detail("app-1") diff --git a/api/tests/unit_tests/services/test_recommended_app_query_service.py b/api/tests/unit_tests/services/test_recommended_app_query_service.py index d035c5edb78..09d661b6754 100644 --- a/api/tests/unit_tests/services/test_recommended_app_query_service.py +++ b/api/tests/unit_tests/services/test_recommended_app_query_service.py @@ -66,20 +66,20 @@ def test_is_previewable_accepts_trial_registration_without_querying_catalog() -> assert service.is_previewable("app-1") is True trial_apps.existing_ids.assert_called_once_with(("app-1",)) - catalog.is_recommended.assert_not_called() + catalog.contains.assert_not_called() @pytest.mark.parametrize("expected", [True, False]) def test_is_previewable_falls_back_to_catalog(expected: bool) -> None: catalog = MagicMock() - catalog.is_recommended.return_value = expected + catalog.contains.return_value = expected trial_apps = MagicMock() trial_apps.existing_ids.return_value = frozenset() service, _ = _service(catalog=catalog, trial_apps=trial_apps) assert service.is_previewable("app-1") is expected trial_apps.existing_ids.assert_called_once_with(("app-1",)) - catalog.is_recommended.assert_called_once_with("app-1") + catalog.contains.assert_called_once_with("app-1") @pytest.mark.parametrize( @@ -108,19 +108,6 @@ def test_list_recommended_resolves_language( catalog.list_recommended.assert_called_once_with(expected) -def test_list_recommended_falls_back_to_builtin_en_us_when_empty() -> None: - catalog = MagicMock() - catalog.list_recommended.return_value = _page(categories=("remote",)) - catalog.list_builtin.return_value = _page("builtin-app", categories=("builtin",)) - service, _ = _service(catalog=catalog) - - result = service.list_recommended(requested_language="ja-JP", interface_language=None) - - catalog.list_builtin.assert_called_once_with("en-US") - assert [app.app_id for app in result.recommended_apps] == ["builtin-app"] - assert result.categories == ("builtin",) - - def test_list_recommended_disables_upstream_trial_without_querying_trial_apps() -> None: catalog = MagicMock() catalog.list_recommended.return_value = _page("app-1") @@ -149,7 +136,7 @@ def test_list_recommended_enriches_trial_status_in_one_bulk_query() -> None: trial_apps.existing_ids.assert_called_once_with(["app-1", "app-2"]) -def test_list_learn_dify_does_not_apply_general_builtin_fallback_or_return_categories() -> None: +def test_list_learn_dify_does_not_return_categories() -> None: catalog = MagicMock() catalog.list_learn_dify.return_value = _page(categories=("ignored",)) service, _ = _service(catalog=catalog) @@ -157,7 +144,6 @@ def test_list_learn_dify_does_not_apply_general_builtin_fallback_or_return_categ result = service.list_learn_dify(requested_language="invalid", interface_language="fr-FR") catalog.list_learn_dify.assert_called_once_with("fr-FR") - catalog.list_builtin.assert_not_called() assert result.recommended_apps == () assert not hasattr(result, "categories")