mirror of
https://github.com/langgenius/dify.git
synced 2026-09-03 23:47:16 +08:00
# Conflicts: # api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py # api/tests/unit_tests/services/test_async_workflow_service.py # web/features/new-rag/__tests__/new-knowledge-list.spec.tsx
410 lines
19 KiB
Python
410 lines
19 KiB
Python
"""Unit tests for the standalone OpenAPI export helper."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _walk_values(value):
|
|
yield value
|
|
match value:
|
|
case dict():
|
|
for child in value.values():
|
|
yield from _walk_values(child)
|
|
case list():
|
|
for child in value:
|
|
yield from _walk_values(child)
|
|
|
|
|
|
def _load_generate_swagger_specs_module():
|
|
api_dir = Path(__file__).resolve().parents[3]
|
|
script_path = api_dir / "dev" / "generate_swagger_specs.py"
|
|
|
|
spec = importlib.util.spec_from_file_location("generate_swagger_specs", script_path)
|
|
assert spec
|
|
assert spec.loader
|
|
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module) # type: ignore[attr-defined]
|
|
return module
|
|
|
|
|
|
def _operation_ids(payload):
|
|
methods = {"delete", "get", "head", "options", "patch", "post", "put", "trace"}
|
|
for path_item in payload["paths"].values():
|
|
for method, operation in path_item.items():
|
|
if method in methods and isinstance(operation, dict) and "operationId" in operation:
|
|
yield operation["operationId"]
|
|
|
|
|
|
def _get_operations(payload):
|
|
for path_item in payload["paths"].values():
|
|
operation = path_item.get("get")
|
|
if isinstance(operation, dict):
|
|
yield operation
|
|
|
|
|
|
def _response_schema(operation, status="200"):
|
|
return operation["responses"][status]["content"]["application/json"]["schema"]
|
|
|
|
|
|
def _request_schema(operation, content_type="application/json"):
|
|
return operation["requestBody"]["content"][content_type]["schema"]
|
|
|
|
|
|
def _nullable_schema_ref(schema):
|
|
if "$ref" in schema:
|
|
return schema["$ref"]
|
|
return next(item["$ref"] for item in schema["anyOf"] if "$ref" in item)
|
|
|
|
|
|
def test_generate_specs_writes_console_web_and_service_openapi_files(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
|
|
assert [path.name for path in written_paths] == [
|
|
"console-openapi.json",
|
|
"web-openapi.json",
|
|
"service-openapi.json",
|
|
"openapi-openapi.json",
|
|
]
|
|
|
|
for path in written_paths:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["openapi"].startswith("3.")
|
|
assert "paths" in payload
|
|
|
|
|
|
def test_generate_specs_writes_openapi_with_resolvable_references_and_no_nulls(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
|
|
for path in written_paths:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
schemas = payload["components"]["schemas"]
|
|
refs = {
|
|
item["$ref"].removeprefix("#/components/schemas/")
|
|
for item in _walk_values(payload)
|
|
if isinstance(item, dict)
|
|
and isinstance(item.get("$ref"), str)
|
|
and item["$ref"].startswith("#/components/schemas/")
|
|
}
|
|
|
|
assert refs <= set(schemas)
|
|
assert all(value is not None for value in _walk_values(payload))
|
|
|
|
|
|
def test_generate_specs_writes_unique_operation_ids(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
|
|
for path in written_paths:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
operation_ids = list(_operation_ids(payload))
|
|
|
|
assert len(operation_ids) == len(set(operation_ids))
|
|
|
|
|
|
def test_system_features_specs_exclude_backend_only_fields(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
excluded_fields = {
|
|
"enable_trial_app",
|
|
"is_allow_create_workspace",
|
|
"max_plugin_package_size",
|
|
"plugin_manager",
|
|
}
|
|
|
|
for spec_name in ("console-openapi.json", "web-openapi.json"):
|
|
spec_path = next(path for path in written_paths if path.name == spec_name)
|
|
payload = json.loads(spec_path.read_text(encoding="utf-8"))
|
|
schemas = payload["components"]["schemas"]
|
|
system_features_schema = schemas["SystemFeatureModel"]
|
|
|
|
assert excluded_fields.isdisjoint(system_features_schema["properties"])
|
|
assert "PluginManagerModel" not in schemas
|
|
|
|
|
|
def test_generate_specs_writes_get_operations_without_request_bodies(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
|
|
for path in written_paths:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
assert all("requestBody" not in operation for operation in _get_operations(payload))
|
|
|
|
|
|
def test_generate_specs_include_command_oriented_knowledge_fs_contract(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
openapi_path = next(path for path in written_paths if path.name == "openapi-openapi.json")
|
|
payload = json.loads(openapi_path.read_text(encoding="utf-8"))
|
|
paths = payload["paths"]
|
|
prefix = "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs"
|
|
|
|
assert {
|
|
f"{prefix}:cat": "get",
|
|
f"{prefix}:diff": "post",
|
|
f"{prefix}:find": "get",
|
|
f"{prefix}:grep": "get",
|
|
f"{prefix}:ls": "get",
|
|
f"{prefix}:stat": "get",
|
|
f"{prefix}:tree": "get",
|
|
} == {
|
|
path: next(method for method in item if method != "parameters")
|
|
for path, item in paths.items()
|
|
if path.startswith(prefix)
|
|
}
|
|
assert not any("/knowledge-fs/spaces/" in path or "/entries" in path for path in paths)
|
|
|
|
list_operation = paths[f"{prefix}:ls"]["get"]
|
|
query_names = {parameter["name"] for parameter in list_operation["parameters"] if parameter["in"] == "query"}
|
|
assert query_names == {"consistency_class", "page_size", "page_token", "path"}
|
|
path_names = {parameter["name"] for parameter in list_operation["parameters"] if parameter["in"] == "path"}
|
|
assert path_names == {"knowledge_space_id", "workspace_id"}
|
|
assert "requestBody" not in list_operation
|
|
assert list_operation["operationId"] == "ls_knowledge_fs"
|
|
assert list_operation["summary"] == "List a KnowledgeFS directory (ls)"
|
|
assert "stable KnowledgeFS traversal order" in list_operation["description"]
|
|
assert "total is intentionally omitted" in list_operation["description"]
|
|
assert "WORKSPACE_READ" in list_operation["description"]
|
|
assert _response_schema(list_operation)["$ref"] == "#/components/schemas/KnowledgeFSEntryListResponse"
|
|
for status in ("400", "401", "403", "404", "409", "413", "422", "503"):
|
|
assert _response_schema(list_operation, status)["$ref"] == "#/components/schemas/ErrorBody"
|
|
|
|
list_schema = payload["components"]["schemas"]["KnowledgeFSEntryListResponse"]
|
|
assert {"data", "has_more", "next_page_token", "path", "truncated"} <= set(list_schema["properties"])
|
|
entry_schema = payload["components"]["schemas"]["KnowledgeFSEntryResponse"]
|
|
assert {item.get("type") for item in entry_schema["properties"]["resource_type"]["anyOf"]} == {
|
|
"null",
|
|
"string",
|
|
}
|
|
metadata_schema = payload["components"]["schemas"]["KnowledgeFSEntryMetadataResponse"]
|
|
assert metadata_schema["properties"]["resource_type"]["type"] == "string"
|
|
assert "enum" not in metadata_schema["properties"]["resource_type"]
|
|
|
|
diff_operation = paths[f"{prefix}:diff"]["post"]
|
|
assert diff_operation["operationId"] == "diff_knowledge_fs"
|
|
assert "consume model quota" in diff_operation["description"]
|
|
assert "Automatic retries are not safe" in diff_operation["description"]
|
|
assert _request_schema(diff_operation)["$ref"] == "#/components/schemas/KnowledgeFSEntryComparePayload"
|
|
assert _response_schema(diff_operation)["$ref"] == ("#/components/schemas/KnowledgeFSEntryComparisonResponse")
|
|
|
|
tree_operation = paths[f"{prefix}:tree"]["get"]
|
|
assert tree_operation["operationId"] == "tree_knowledge_fs"
|
|
|
|
|
|
def test_generate_specs_writes_service_api_reference_descriptions(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
service_path = next(path for path in written_paths if path.name == "service-openapi.json")
|
|
payload = json.loads(service_path.read_text(encoding="utf-8"))
|
|
|
|
chat_operation = payload["paths"]["/chat-messages"]["post"]
|
|
assert chat_operation["summary"] == "Send Chat Message"
|
|
assert chat_operation["description"] == "Send a request to the chat application."
|
|
assert chat_operation["tags"] == ["Chatflows", "Chats"]
|
|
|
|
rename_operation = payload["paths"]["/conversations/{c_id}/name"]["post"]
|
|
assert rename_operation["summary"] == "Rename Conversation"
|
|
|
|
|
|
def test_standalone_inline_model_name_includes_list_constraints():
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
from flask_restx import fields
|
|
|
|
cases = (
|
|
({"min_items": 1}, {"min_items": 2}),
|
|
({"max_items": 1}, {"max_items": 2}),
|
|
({"unique": True}, {"unique": False}),
|
|
)
|
|
for first_kwargs, second_kwargs in cases:
|
|
first_inline_model = {"items": fields.List(fields.String, **first_kwargs)}
|
|
second_inline_model = {"items": fields.List(fields.String, **second_kwargs)}
|
|
|
|
assert module._inline_model_name(first_inline_model) != module._inline_model_name(second_inline_model)
|
|
|
|
|
|
def test_generate_specs_is_idempotent(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
first_paths = module.generate_specs(tmp_path / "first")
|
|
second_paths = module.generate_specs(tmp_path / "second")
|
|
|
|
assert [path.name for path in first_paths] == [path.name for path in second_paths]
|
|
for first_path, second_path in zip(first_paths, second_paths):
|
|
assert first_path.read_text(encoding="utf-8") == second_path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_generate_specs_include_agent_v2_knowledge_set_schema_and_query_enums(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
console_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
|
payload = json.loads(console_path.read_text(encoding="utf-8"))
|
|
schemas = payload["components"]["schemas"]
|
|
|
|
assert "AgentKnowledgeSetConfig" in schemas
|
|
assert schemas["AgentSoulKnowledgeConfig"]["properties"]["sets"]["items"]["$ref"] == (
|
|
"#/components/schemas/AgentKnowledgeSetConfig"
|
|
)
|
|
assert schemas["AgentKnowledgeQueryMode"]["enum"] == ["generated_query", "user_query"]
|
|
|
|
|
|
def test_generate_specs_include_console_contract_shapes_for_schema_migration(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
console_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
|
payload = json.loads(console_path.read_text(encoding="utf-8"))
|
|
schemas = payload["components"]["schemas"]
|
|
paths = payload["paths"]
|
|
|
|
file_upload_schema = _request_schema(paths["/files/upload"]["post"], "multipart/form-data")
|
|
assert file_upload_schema["required"] == ["file"]
|
|
assert file_upload_schema["properties"]["file"]["format"] == "binary"
|
|
assert file_upload_schema["properties"]["file"]["type"] == "string"
|
|
assert file_upload_schema["properties"]["source"]["enum"] == ["datasets"]
|
|
|
|
api_key_auth_binding_schema = _request_schema(paths["/api-key-auth/data-source/binding"]["post"])
|
|
assert api_key_auth_binding_schema["$ref"] == "#/components/schemas/ApiKeyAuthBindingPayload"
|
|
assert schemas["ApiKeyAuthBindingPayload"]["properties"]["credentials"]["$ref"] == (
|
|
"#/components/schemas/ApiKeyAuthCredentialsPayload"
|
|
)
|
|
assert schemas["ApiKeyAuthCredentialsPayload"]["properties"]["config"]["$ref"] == (
|
|
"#/components/schemas/ApiKeyAuthConfigPayload"
|
|
)
|
|
assert schemas["ApiKeyAuthConfigPayload"]["properties"]["api_key"]["minLength"] == 1
|
|
|
|
invoices_schema_ref = _response_schema(paths["/billing/invoices"]["get"])["$ref"].removeprefix(
|
|
"#/components/schemas/"
|
|
)
|
|
assert schemas[invoices_schema_ref]["properties"]["url"]["type"] == "string"
|
|
|
|
app_detail_schema = schemas["RecommendedAppDetailResponse"]
|
|
assert app_detail_schema["properties"]["id"]["type"] == "string"
|
|
assert app_detail_schema["properties"]["export_data"]["type"] == "string"
|
|
assert app_detail_schema["properties"]["can_trial"]["type"] == "boolean"
|
|
assert "anyOf" not in app_detail_schema["properties"]["can_trial"]
|
|
assert "can_trial" in app_detail_schema["required"]
|
|
app_list_item_schema = schemas["RecommendedAppResponse"]
|
|
assert app_list_item_schema["properties"]["can_trial"]["type"] == "boolean"
|
|
assert "anyOf" not in app_list_item_schema["properties"]["can_trial"]
|
|
assert "can_trial" in app_list_item_schema["required"]
|
|
assert _response_schema(paths["/explore/apps/{app_id}"]["get"])["$ref"] == (
|
|
"#/components/schemas/RecommendedAppDetailResponse"
|
|
)
|
|
assert "404" in paths["/explore/apps/{app_id}"]["get"]["responses"]
|
|
assert "RecommendedAppDetailNullableResponse" not in schemas
|
|
assert schemas["RecommendedAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True
|
|
assert schemas["InstalledAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True
|
|
assert _response_schema(paths["/apps/{app_id}"]["get"])["$ref"] == "#/components/schemas/AppDetailWithSite"
|
|
app_model_config = schemas["AppDetailWithSite"]["properties"]["model_config"]
|
|
assert {"$ref": "#/components/schemas/AppModelConfigResponse"} in app_model_config["anyOf"]
|
|
app_detail = schemas["AppDetail"]
|
|
assert "mode" in app_detail["properties"]
|
|
assert "mode_compatible_with_agent" not in app_detail["properties"]
|
|
sync_draft_workflow = schemas["SyncDraftWorkflowResponse"]
|
|
assert _response_schema(paths["/apps/{app_id}/workflows/draft"]["post"])["$ref"] == (
|
|
"#/components/schemas/SyncDraftWorkflowResponse"
|
|
)
|
|
assert sync_draft_workflow["properties"]["updated_at"]["type"] == "integer"
|
|
tool_icon_schema = schemas["ExploreAppMetaResponse"]["properties"]["tool_icons"]["additionalProperties"]
|
|
assert {"type": "string"} in tool_icon_schema["anyOf"]
|
|
assert {"additionalProperties": True, "type": "object"} in tool_icon_schema["anyOf"]
|
|
assert "ToolIconResponse" not in schemas
|
|
|
|
plugin_versions = schemas["PluginVersionsResponse"]["properties"]["versions"]
|
|
assert plugin_versions["additionalProperties"]["anyOf"][0]["$ref"] == "#/components/schemas/LatestPluginCache"
|
|
assert plugin_versions["additionalProperties"]["anyOf"][1]["type"] == "null"
|
|
plugin_installations = schemas["PluginInstallationsResponse"]["properties"]["plugins"]
|
|
assert plugin_installations["items"]["$ref"] == "#/components/schemas/PluginInstallationItemResponse"
|
|
|
|
rbac_whitelist_request = _request_schema(paths["/workspaces/current/rbac/apps/{app_id}/whitelist"]["put"])
|
|
assert rbac_whitelist_request["$ref"] == "#/components/schemas/_ResourceAccessScopeRequest"
|
|
app_access_policy_params = paths["/workspaces/current/rbac/apps/{app_id}/access-policy"]["get"]["parameters"]
|
|
language_param = next(param for param in app_access_policy_params if param["name"] == "language")
|
|
assert language_param["schema"]["enum"] == ["en", "ja", "zh"]
|
|
|
|
trigger_list_schema = _response_schema(paths["/workspaces/current/triggers"]["get"])
|
|
assert trigger_list_schema["$ref"] == "#/components/schemas/TriggerProviderListResponse"
|
|
trigger_builder_create_schema = _response_schema(
|
|
paths["/workspaces/current/trigger-provider/{provider}/subscriptions/builder/create"]["post"]
|
|
)
|
|
assert trigger_builder_create_schema["$ref"] == "#/components/schemas/TriggerSubscriptionBuilderCreateResponse"
|
|
assert (
|
|
schemas["TriggerSubscriptionBuilderCreateResponse"]["properties"]["subscription_builder"]["$ref"]
|
|
== "#/components/schemas/SubscriptionBuilderApiEntity"
|
|
)
|
|
|
|
conversation_variables = schemas["ConversationVariableUpdatePayload"]["properties"]["conversation_variables"]
|
|
assert conversation_variables["items"]["$ref"] == "#/components/schemas/ConversationVariableItemPayload"
|
|
workflow_features = schemas["WorkflowFeaturesPayload"]["properties"]["features"]
|
|
assert workflow_features["$ref"] == "#/components/schemas/WorkflowFeaturesConfigPayload"
|
|
workflow_feature_properties = schemas["WorkflowFeaturesConfigPayload"]["properties"]
|
|
assert _nullable_schema_ref(workflow_feature_properties["suggested_questions_after_answer"]) == (
|
|
"#/components/schemas/WorkflowSuggestedQuestionsAfterAnswerPayload"
|
|
)
|
|
assert _nullable_schema_ref(workflow_feature_properties["text_to_speech"]) == (
|
|
"#/components/schemas/WorkflowTextToSpeechPayload"
|
|
)
|
|
assert _nullable_schema_ref(workflow_feature_properties["sensitive_word_avoidance"]) == (
|
|
"#/components/schemas/WorkflowSensitiveWordAvoidancePayload"
|
|
)
|
|
assert {"enabled", "model", "prompt"} <= set(schemas["WorkflowSuggestedQuestionsAfterAnswerPayload"]["properties"])
|
|
assert {"enabled", "language", "voice", "autoPlay"} <= set(schemas["WorkflowTextToSpeechPayload"]["properties"])
|
|
assert {"enabled", "type", "config"} <= set(schemas["WorkflowSensitiveWordAvoidancePayload"]["properties"])
|
|
file_upload = schemas["WorkflowFileUploadPayload"]["properties"]
|
|
assert {"document", "audio", "video", "custom", "preview_config"} <= set(file_upload)
|
|
assert "detail" in schemas["WorkflowFileUploadImagePayload"]["properties"]
|
|
assert {"mode", "file_type_list"} <= set(schemas["WorkflowFileUploadPreviewConfigPayload"]["properties"])
|
|
assert schemas["AccountWithRoleResponse"]["properties"]["avatar_url"]["readOnly"] is True
|
|
|
|
|
|
def test_checked_in_agent_v2_knowledge_openapi_and_generated_contracts_are_in_sync():
|
|
api_dir = Path(__file__).resolve().parents[3]
|
|
repo_root = api_dir.parent
|
|
|
|
markdown = (api_dir / "openapi" / "markdown" / "console-openapi.md").read_text(encoding="utf-8")
|
|
agent_types = (
|
|
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "types.gen.ts"
|
|
).read_text(encoding="utf-8")
|
|
apps_types = (
|
|
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "types.gen.ts"
|
|
).read_text(encoding="utf-8")
|
|
agent_zod = (
|
|
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "zod.gen.ts"
|
|
).read_text(encoding="utf-8")
|
|
apps_zod = (
|
|
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "zod.gen.ts"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "#### AgentKnowledgeSetConfig" in markdown
|
|
assert "#### AgentSoulKnowledgeConfig" in markdown
|
|
assert "#### AgentKnowledgeQueryMode" in markdown
|
|
|
|
for content in (agent_types, apps_types):
|
|
assert "export type AgentKnowledgeSetConfig = {" in content
|
|
assert "export type AgentSoulKnowledgeConfig = {" in content
|
|
assert "AgentKnowledgeQueryMode" in content
|
|
assert "generated_query" in content
|
|
assert "user_query" in content
|
|
|
|
for content in (agent_zod, apps_zod):
|
|
assert "export const zAgentKnowledgeSetConfig = z.object({" in content
|
|
assert "export const zAgentSoulKnowledgeConfig = z.object({" in content
|
|
assert "zAgentKnowledgeQueryMode = z.enum([" in content
|
|
assert "generated_query" in content
|
|
assert "user_query" in content
|