mirror of
https://github.com/langgenius/dify.git
synced 2026-09-02 22:36:15 +08:00
refactor(api): replace legacy recommended app retrieval stack (#40395)
This commit is contained in:
parent
de7e43a7ef
commit
1bb9183af9
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
191
api/repositories/recommended_app_catalog_repository.py
Normal file
191
api/repositories/recommended_app_catalog_repository.py
Normal 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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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),
|
||||
)
|
||||
@ -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: ...
|
||||
@ -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
|
||||
@ -1,7 +0,0 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class RecommendAppType(StrEnum):
|
||||
REMOTE = "remote"
|
||||
BUILDIN = "builtin"
|
||||
DATABASE = "db"
|
||||
@ -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
|
||||
376
api/services/recommended_app_catalog_gateway.py
Normal file
376
api/services/recommended_app_catalog_gateway.py
Normal 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")
|
||||
@ -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")
|
||||
@ -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),
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
@ -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"
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -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}"
|
||||
@ -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"]
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user