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

This commit is contained in:
非法操作 authored and GitHub committed 2026-08-20 13:13:43 +00:00
1 parent de7e43a7ef
commit 1bb9183af9
41 files changed
+1704 -1941

No files matched your search

@@ -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"
-1
View File
@@ -347,7 +347,6 @@ extend-select = ["ANN401", "ARG"]
"services/rag_pipeline/test_rag_pipeline_service.py" = ["ARG001", "ARG005"]
"services/rag_pipeline/test_rag_pipeline_task_proxy.py" = ["ARG001", "ARG005"]
"services/rag_pipeline/test_rag_pipeline_transform_service.py" = ["ARG001"]
"services/recommend_app/test_remote_retrieval.py" = ["ARG002"]
"services/retention/workflow_run/test_archive_download_preparation.py" = ["ARG002"]
"services/retention/workflow_run/test_archive_log_service.py" = ["ARG001", "ARG002"]
"services/retention/workflow_run/test_bundle_archive_maintenance.py" = ["TID251"]
@@ -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,
)
-5
View File
@@ -800,11 +800,6 @@ project-excludes = [
"services/rag_pipeline/test_rag_pipeline_service.py",
"services/rag_pipeline/test_rag_pipeline_task_proxy.py",
"services/rag_pipeline/test_rag_pipeline_transform_service.py",
"services/recommend_app/test_buildin_retrieval.py",
"services/recommend_app/test_category_order.py",
"services/recommend_app/test_recommend_app_factory.py",
"services/recommend_app/test_recommend_app_type.py",
"services/recommend_app/test_remote_retrieval.py",
"services/retention/test_messages_clean_policy.py",
"services/retention/workflow_run/test_archive_download_preparation.py",
"services/retention/workflow_run/test_archive_download_task_cache.py",
@@ -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
Whitespace-only changes.
@@ -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")