mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
249 lines
9.0 KiB
Python
249 lines
9.0 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from typing import Protocol, cast
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from pydantic import ValidationError
|
|
from werkzeug.exceptions import NotFound, ServiceUnavailable
|
|
|
|
from controllers.openapi._models import (
|
|
MAX_PAGE_LIMIT,
|
|
AppDescribeResponse,
|
|
PermittedExternalAppsListQuery,
|
|
PermittedExternalAppsListResponse,
|
|
)
|
|
from controllers.openapi._models import (
|
|
AppDescribeQuery as _app_discovery_AppDescribeQuery,
|
|
)
|
|
from controllers.openapi.apps import AppDescribeApi, AppListQuery
|
|
from controllers.openapi.apps import AppDescribeQuery as _app_describe_query_AppDescribeQuery
|
|
from controllers.openapi.apps_permitted_external import PermittedExternalAppDescribeApi, PermittedExternalAppsListApi
|
|
from machinery.context import AppRequestContext
|
|
from models.model import AppMode
|
|
from services.app.query_service import AppDescription
|
|
from services.app_service import AppListParams
|
|
from services.entities.app_entities import AppSummary
|
|
from services.errors.app import AppDiscoveryNotFoundError, PermittedAppsUnavailableError
|
|
from tests.helpers.unit.controllers.openapi._mode_constants import LISTABLE_MODES, NON_LISTABLE_MODES
|
|
from tests.unit_tests.controllers.conftest import ControllerTestServices
|
|
|
|
"""Unit tests for AppDescribeQuery (`?fields=` allow-list)."""
|
|
|
|
|
|
def test_no_fields_returns_none() -> None:
|
|
q = _app_describe_query_AppDescribeQuery.model_validate({})
|
|
assert q.fields is None
|
|
|
|
|
|
def test_empty_string_returns_none() -> None:
|
|
q = _app_describe_query_AppDescribeQuery.model_validate({"fields": ""})
|
|
assert q.fields is None
|
|
|
|
|
|
def test_single_field() -> None:
|
|
q = _app_describe_query_AppDescribeQuery.model_validate({"fields": "info"})
|
|
assert q.fields == {"info"}
|
|
|
|
|
|
def test_comma_list() -> None:
|
|
q = _app_describe_query_AppDescribeQuery.model_validate({"fields": "info,parameters"})
|
|
assert q.fields == {"info", "parameters"}
|
|
|
|
|
|
def test_whitespace_tolerant() -> None:
|
|
q = _app_describe_query_AppDescribeQuery.model_validate({"fields": " info , input_schema "})
|
|
assert q.fields == {"info", "input_schema"}
|
|
|
|
|
|
def test_unknown_member_rejected() -> None:
|
|
with pytest.raises(ValidationError):
|
|
_app_describe_query_AppDescribeQuery.model_validate({"fields": "garbage"})
|
|
|
|
|
|
def test_unknown_among_known_rejected() -> None:
|
|
with pytest.raises(ValidationError):
|
|
_app_describe_query_AppDescribeQuery.model_validate({"fields": "info,garbage"})
|
|
|
|
|
|
def test_extra_param_forbidden() -> None:
|
|
with pytest.raises(ValidationError):
|
|
_app_describe_query_AppDescribeQuery.model_validate({"fields": "info", "page": "1"})
|
|
|
|
|
|
"""Transport mappings over stable admitted contexts."""
|
|
|
|
|
|
class Endpoint[T](Protocol):
|
|
__handler__: Callable[..., T]
|
|
|
|
|
|
@pytest.mark.parametrize("api", [AppDescribeApi(), PermittedExternalAppDescribeApi()])
|
|
def test_describe_passes_admitted_scope_and_projects_response(
|
|
app: Flask,
|
|
app_query_services: ControllerTestServices,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
api: AppDescribeApi | PermittedExternalAppDescribeApi,
|
|
) -> None:
|
|
context = AppRequestContext(tenant_id="workspace-id", app_id="app-id")
|
|
calls: list[tuple[AppRequestContext, set[str] | None]] = []
|
|
|
|
def describe(ctx: AppRequestContext, fields: set[str] | None) -> AppDescription:
|
|
calls.append((ctx, fields))
|
|
return AppDescription(
|
|
AppSummary("app-id", "workspace-id", "App", "", AppMode.CHAT, "normal", None, None), True, None
|
|
)
|
|
|
|
monkeypatch.setattr(app_query_services.apps.discovery, "describe", describe)
|
|
with app.test_request_context():
|
|
result = cast(Endpoint[AppDescribeResponse], api.get).__handler__(
|
|
api, context, "app-id", query=_app_discovery_AppDescribeQuery.model_validate({"fields": "info"})
|
|
)
|
|
assert calls == [(context, {"info"})]
|
|
assert result.info is not None
|
|
assert result.info.id == "app-id"
|
|
assert result.parameters is None
|
|
assert result.input_schema is None
|
|
|
|
|
|
@pytest.mark.parametrize("api", [AppDescribeApi(), PermittedExternalAppDescribeApi()])
|
|
def test_describe_translates_disappearing_app_to_404(
|
|
app: Flask,
|
|
app_query_services: ControllerTestServices,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
api: AppDescribeApi | PermittedExternalAppDescribeApi,
|
|
) -> None:
|
|
def missing(*_args: object) -> AppDescription:
|
|
raise AppDiscoveryNotFoundError("app not found")
|
|
|
|
monkeypatch.setattr(app_query_services.apps.discovery, "describe", missing)
|
|
context = AppRequestContext(tenant_id="workspace-id", app_id="app-id")
|
|
with app.test_request_context(), pytest.raises(NotFound, match="app not found"):
|
|
cast(Endpoint[AppDescribeResponse], api.get).__handler__(
|
|
api, context, "app-id", query=_app_discovery_AppDescribeQuery()
|
|
)
|
|
|
|
|
|
def test_external_failure_is_translated_at_http_boundary(
|
|
app: Flask,
|
|
app_query_services: ControllerTestServices,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
def unavailable(*_args: object) -> None:
|
|
raise PermittedAppsUnavailableError("permitted_apps_unavailable")
|
|
|
|
monkeypatch.setattr(app_query_services.apps.discovery, "list_permitted_apps", unavailable)
|
|
api = PermittedExternalAppsListApi()
|
|
with app.test_request_context(), pytest.raises(ServiceUnavailable, match="permitted_apps_unavailable"):
|
|
cast(Endpoint[PermittedExternalAppsListResponse], api.get).__handler__(
|
|
api, query=PermittedExternalAppsListQuery()
|
|
)
|
|
|
|
|
|
"""Unit tests for AppListQuery — the /apps query-param validator.
|
|
|
|
Runs against the model directly, not the HTTP layer. Pins:
|
|
- defaults match the plan (page=1, limit=20).
|
|
- workspace_id is required.
|
|
- numeric bounds enforced (page >= 1, limit in [1, MAX_PAGE_LIMIT]).
|
|
- mode validates against the SupportedAppType enum (listable app types only).
|
|
- name has a length cap.
|
|
"""
|
|
|
|
|
|
WS_ID = "00000000-0000-0000-0000-000000000001"
|
|
|
|
|
|
def test_defaults() -> None:
|
|
q = AppListQuery.model_validate({"workspace_id": WS_ID})
|
|
assert q.workspace_id == WS_ID
|
|
assert q.page == 1
|
|
assert q.limit == 20
|
|
assert q.mode is None
|
|
assert q.name is None
|
|
|
|
|
|
def test_workspace_id_required() -> None:
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({})
|
|
|
|
|
|
def test_page_must_be_positive() -> None:
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "page": 0})
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "page": -1})
|
|
|
|
|
|
def test_page_rejects_non_integer_string() -> None:
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "page": "abc"})
|
|
|
|
|
|
def test_limit_must_be_positive() -> None:
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "limit": 0})
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "limit": -1})
|
|
|
|
|
|
def test_limit_caps_at_max_page_limit() -> None:
|
|
# Boundary accepts.
|
|
q = AppListQuery.model_validate({"workspace_id": WS_ID, "limit": MAX_PAGE_LIMIT})
|
|
assert q.limit == MAX_PAGE_LIMIT
|
|
|
|
# Just over rejects.
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "limit": MAX_PAGE_LIMIT + 1})
|
|
|
|
|
|
@pytest.mark.parametrize("mode", LISTABLE_MODES)
|
|
def test_mode_accepts_listable_app_types(mode: str) -> None:
|
|
q = AppListQuery.model_validate({"workspace_id": WS_ID, "mode": mode})
|
|
assert q.mode is not None
|
|
assert q.mode.value == mode
|
|
|
|
|
|
def test_mode_rejects_unknown_value() -> None:
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "mode": "not-a-mode"})
|
|
|
|
|
|
@pytest.mark.parametrize("mode", NON_LISTABLE_MODES)
|
|
def test_mode_rejects_non_listable_app_modes(mode: str) -> None:
|
|
"""rag-pipeline (a knowledge Pipeline), channel (unused) and agent (roster-owned)
|
|
are AppMode members but not standalone listable apps — the `app` face rejects them."""
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "mode": mode})
|
|
|
|
|
|
def test_name_length_capped() -> None:
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "name": "x" * 200})
|
|
with pytest.raises(ValidationError):
|
|
AppListQuery.model_validate({"workspace_id": WS_ID, "name": "x" * 201})
|
|
|
|
|
|
def test_all_fields_accept_valid_values() -> None:
|
|
"""Pin the happy-path acceptance for every field in one place."""
|
|
q = AppListQuery.model_validate(
|
|
{
|
|
"workspace_id": WS_ID,
|
|
"page": 5,
|
|
"limit": 50,
|
|
"mode": "workflow",
|
|
"name": "search",
|
|
}
|
|
)
|
|
assert q.workspace_id == WS_ID
|
|
assert q.page == 5
|
|
assert q.limit == 50
|
|
assert q.mode is not None
|
|
assert q.mode.value == "workflow"
|
|
assert q.name == "search"
|
|
|
|
|
|
def test_max_page_limit_is_accepted_by_the_app_service() -> None:
|
|
# The catalog advertises MAX_PAGE_LIMIT; the service model must not reject it.
|
|
assert AppListParams(limit=MAX_PAGE_LIMIT).limit == MAX_PAGE_LIMIT
|