fix: refresh MCP tool metadata after updates and align App DSL test stubs (#35354)

Co-authored-by: Stephen Zhou <hi@hyoban.cc>
This commit is contained in:
hyl64 2026-04-29 10:41:15 +08:00 committed by GitHub
parent 0536549f73
commit 16d408d908
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 41 additions and 60 deletions

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import base64
import json
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, patch
from uuid import uuid4
@ -17,7 +18,7 @@ from core.trigger.constants import (
)
from extensions.ext_redis import redis_client
from graphon.enums import BuiltinNodeTypes
from models import Account, AppMode
from models import Account, App, AppMode
from models.model import AppModelConfig, IconType
from services import app_dsl_service
from services.account_service import AccountService, TenantService
@ -67,6 +68,22 @@ def _pending_yaml_content(version: str = "99.0.0") -> bytes:
return (f'version: "{version}"\nkind: app\napp:\n name: Loop Test\n mode: workflow\n').encode()
def _app_stub(**overrides: Any) -> App:
defaults = {
"id": str(uuid4()),
"tenant_id": _DEFAULT_TENANT_ID,
"mode": AppMode.WORKFLOW.value,
"name": "n",
"description": "d",
"icon_type": IconType.EMOJI,
"icon": "i",
"icon_background": "#fff",
"use_icon_as_answer_icon": False,
"app_model_config": None,
}
return cast(App, SimpleNamespace(**(defaults | overrides)))
class TestAppDslService:
"""Integration tests for AppDslService using testcontainers."""
@ -585,7 +602,7 @@ class TestAppDslService:
def test_check_dependencies_returns_empty_when_no_redis_data(self, db_session_with_containers):
service = AppDslService(db_session_with_containers)
app_model = SimpleNamespace(id=str(uuid4()), tenant_id=_DEFAULT_TENANT_ID)
app_model = _app_stub()
result = service.check_dependencies(app_model=app_model)
assert result.leaked_dependencies == []
@ -614,7 +631,7 @@ class TestAppDslService:
)
service = AppDslService(db_session_with_containers)
result = service.check_dependencies(app_model=SimpleNamespace(id=app_id, tenant_id=_DEFAULT_TENANT_ID))
result = service.check_dependencies(app_model=_app_stub(id=app_id))
assert len(result.leaked_dependencies) == 1
def test_check_dependencies_with_real_app(self, db_session_with_containers, mock_external_service_dependencies):
@ -656,9 +673,7 @@ class TestAppDslService:
lambda _m: SimpleNamespace(kind="conv"),
)
app = SimpleNamespace(
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
app = _app_stub(
mode=AppMode.WORKFLOW.value,
name="old",
description="old-desc",
@ -667,7 +682,6 @@ class TestAppDslService:
icon_background="#111111",
updated_by=None,
updated_at=None,
app_model_config=None,
)
service = AppDslService(db_session_with_containers)
updated = service._create_or_update_app(
@ -745,15 +759,7 @@ class TestAppDslService:
service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Missing workflow data"):
service._create_or_update_app(
app=SimpleNamespace(
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.WORKFLOW.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
app=_app_stub(mode=AppMode.WORKFLOW.value),
data={"app": {"mode": AppMode.WORKFLOW.value}},
account=_account_mock(),
)
@ -762,15 +768,7 @@ class TestAppDslService:
service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Missing model_config"):
service._create_or_update_app(
app=SimpleNamespace(
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.CHAT.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
app=_app_stub(mode=AppMode.CHAT.value),
data={"app": {"mode": AppMode.CHAT.value}},
account=_account_mock(),
)
@ -799,15 +797,7 @@ class TestAppDslService:
service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Invalid app mode"):
service._create_or_update_app(
app=SimpleNamespace(
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.RAG_PIPELINE.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
app=_app_stub(mode=AppMode.RAG_PIPELINE.value),
data={"app": {"mode": AppMode.RAG_PIPELINE.value}},
account=_account_mock(),
)
@ -828,29 +818,16 @@ class TestAppDslService:
lambda *_args, **_kwargs: model_calls.append(True),
)
workflow_app = SimpleNamespace(
workflow_app = _app_stub(
mode=AppMode.WORKFLOW.value,
tenant_id=_DEFAULT_TENANT_ID,
name="n",
icon="i",
icon_type="emoji",
icon_background="#fff",
description="d",
use_icon_as_answer_icon=False,
app_model_config=None,
)
AppDslService.export_dsl(workflow_app)
assert workflow_calls == [True]
chat_app = SimpleNamespace(
chat_app = _app_stub(
mode=AppMode.CHAT.value,
tenant_id=_DEFAULT_TENANT_ID,
name="n",
icon="i",
icon_type="emoji",
icon_background="#fff",
description="d",
use_icon_as_answer_icon=False,
app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}),
)
AppDslService.export_dsl(chat_app)
@ -863,16 +840,14 @@ class TestAppDslService:
lambda **_kwargs: None,
)
emoji_app = SimpleNamespace(
emoji_app = _app_stub(
mode=AppMode.WORKFLOW.value,
tenant_id=_DEFAULT_TENANT_ID,
name="Emoji App",
icon="🎨",
icon_type=IconType.EMOJI,
icon_background="#FF5733",
description="App with emoji icon",
use_icon_as_answer_icon=True,
app_model_config=None,
)
yaml_output = AppDslService.export_dsl(emoji_app)
data = yaml.safe_load(yaml_output)
@ -880,16 +855,14 @@ class TestAppDslService:
assert data["app"]["icon_type"] == "emoji"
assert data["app"]["icon_background"] == "#FF5733"
image_app = SimpleNamespace(
image_app = _app_stub(
mode=AppMode.WORKFLOW.value,
tenant_id=_DEFAULT_TENANT_ID,
name="Image App",
icon="https://example.com/icon.png",
icon_type=IconType.IMAGE,
icon_background="#FFEAD5",
description="App with image icon",
use_icon_as_answer_icon=False,
app_model_config=None,
)
yaml_output = AppDslService.export_dsl(image_app)
data = yaml.safe_load(yaml_output)
@ -1106,7 +1079,7 @@ class TestAppDslService:
export_data: dict = {}
AppDslService._append_workflow_export_data(
export_data=export_data,
app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID),
app_model=_app_stub(),
include_secret=False,
workflow_id=None,
)
@ -1132,7 +1105,7 @@ class TestAppDslService:
with pytest.raises(ValueError, match="Missing draft workflow configuration"):
AppDslService._append_workflow_export_data(
export_data={},
app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID),
app_model=_app_stub(),
include_secret=False,
workflow_id=None,
)
@ -1160,7 +1133,7 @@ class TestAppDslService:
monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x)
app_model_config = SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": [{"credential_id": "secret"}]}})
app_model = SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID, app_model_config=app_model_config)
app_model = _app_stub(app_model_config=app_model_config)
export_data: dict = {}
AppDslService._append_model_config_export_data(export_data, app_model)
@ -1169,7 +1142,7 @@ class TestAppDslService:
def test_append_model_config_export_data_requires_app_config(self):
with pytest.raises(ValueError, match="Missing app configuration"):
AppDslService._append_model_config_export_data({}, SimpleNamespace(app_model_config=None))
AppDslService._append_model_config_export_data({}, _app_stub(app_model_config=None))
# ── Dependency Extraction ─────────────────────────────────────────

View File

@ -12,6 +12,7 @@ const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' })
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockInvalidateMCPTools = vi.fn()
const mockInvalidateAllMCPTools = vi.fn()
const mockOpenOAuthPopup = vi.fn()
// Mutable mock state
@ -33,6 +34,7 @@ vi.mock('@/service/use-tools', () => ({
isFetching: mockIsFetching,
}),
useInvalidateMCPTools: () => mockInvalidateMCPTools,
useInvalidateAllMCPTools: () => mockInvalidateAllMCPTools,
useUpdateMCPTools: () => ({
mutateAsync: mockUpdateTools,
isPending: mockIsUpdating,
@ -180,6 +182,7 @@ describe('MCPDetailContent', () => {
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockInvalidateMCPTools.mockClear()
mockInvalidateAllMCPTools.mockClear()
mockOpenOAuthPopup.mockClear()
// Reset mock return values
@ -513,6 +516,7 @@ describe('MCPDetailContent', () => {
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1')
expect(mockInvalidateAllMCPTools).toHaveBeenCalled()
expect(onUpdate).toHaveBeenCalled()
})
})
@ -530,6 +534,7 @@ describe('MCPDetailContent', () => {
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
expect(mockInvalidateAllMCPTools).toHaveBeenCalled()
})
})
})

View File

@ -26,6 +26,7 @@ import { openOAuthPopup } from '@/hooks/use-oauth'
import {
useAuthorizeMCP,
useDeleteMCP,
useInvalidateAllMCPTools,
useInvalidateMCPTools,
useMCPTools,
useUpdateMCP,
@ -61,6 +62,7 @@ const MCPDetailContent: FC<Props> = ({
const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '')
const invalidateMCPTools = useInvalidateMCPTools()
const invalidateAllMCPTools = useInvalidateAllMCPTools()
const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools()
const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP()
const toolList = data?.tools || []
@ -76,8 +78,9 @@ const MCPDetailContent: FC<Props> = ({
return
await updateTools(detail.id)
invalidateMCPTools(detail.id)
invalidateAllMCPTools()
onUpdate()
}, [detail, hideUpdateConfirm, invalidateMCPTools, onUpdate, updateTools])
}, [detail, hideUpdateConfirm, invalidateAllMCPTools, invalidateMCPTools, onUpdate, updateTools])
const { mutateAsync: updateMCP } = useUpdateMCP({})
const { mutateAsync: deleteMCP } = useDeleteMCP({})