mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor(api): replace legacy recommended app retrieval stack (#40395)
This commit is contained in:
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",
|
||||
|
||||
+119
@@ -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()
|
||||
Whitespace-only changes.
-301
@@ -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
|
||||
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")
|
||||
|
||||
|
||||
Reference in new issue
Block a user