refactor(api): replace legacy recommended app retrieval stack (#40395)

This commit is contained in:
非法操作 2026-08-20 13:13:43 +00:00 committed by GitHub
parent de7e43a7ef
commit 1bb9183af9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1704 additions and 1941 deletions

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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,
),

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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),
)

View File

@ -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: ...

View File

@ -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

View File

@ -1,7 +0,0 @@
from enum import StrEnum
class RecommendAppType(StrEnum):
REMOTE = "remote"
BUILDIN = "builtin"
DATABASE = "db"

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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),

View File

@ -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"]

View File

@ -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",

View File

@ -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()

View File

@ -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"

View File

@ -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"]

View File

@ -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

View File

@ -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")

View File

@ -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,
)

View File

@ -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",

View File

@ -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

View File

@ -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}"

View File

@ -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"]

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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")