test(mcp): enhance unit tests for MCP components and improve mock handling

- Added beforeEach hooks to reset mock states in  and  for better test isolation.
- Refactored mock data handling in  to streamline test setup.
- Improved error handling in  to gracefully manage JSON parsing errors.
- Updated  to utilize a pending status for optimistic updates, enhancing user experience during state changes.

These changes aim to improve the reliability and maintainability of tests across MCP components.
This commit is contained in:
CodingOnStar
2026-01-26 14:41:19 +08:00
parent 6489903c77
commit 53a620b6ce
5 changed files with 189 additions and 270 deletions

View File

@ -1,9 +1,8 @@
/* eslint-disable react/no-unnecessary-use-prefix */
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NewMCPCard from './create-card'
// Track the mock functions
@ -41,10 +40,13 @@ vi.mock('./modal', () => ({
},
}))
// Mutable workspace manager state
let mockIsCurrentWorkspaceManager = true
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
isCurrentWorkspaceEditor: true,
}),
}))
@ -83,6 +85,11 @@ describe('NewMCPCard', () => {
handleCreate: vi.fn(),
}
beforeEach(() => {
mockCreateMCP.mockClear()
mockIsCurrentWorkspaceManager = true
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
@ -133,19 +140,11 @@ describe('NewMCPCard', () => {
describe('Non-Manager User', () => {
it('should not render card when user is not workspace manager', () => {
// Override the mock for this test
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceEditor: false,
}),
}))
mockIsCurrentWorkspaceManager = false
// The component should not render
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Since isCurrentWorkspaceManager is mocked as true at module level,
// the card will still render. This test documents the expected behavior.
expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument()
expect(screen.queryByText('tools.mcp.create.cardTitle')).not.toBeInTheDocument()
})
})
@ -166,10 +165,6 @@ describe('NewMCPCard', () => {
})
describe('Modal Interactions', () => {
beforeEach(() => {
mockCreateMCP.mockClear()
})
it('should call create function when modal confirms', async () => {
const handleCreate = vi.fn()
render(<NewMCPCard handleCreate={handleCreate} />, { wrapper: createWrapper() })

View File

@ -126,9 +126,19 @@ export const useMCPModalForm = (data?: ToolWithProvider) => {
}
catch (e) {
let errorMessage = 'Failed to fetch remote icon'
const errorData = await (e as Response).json()
if (errorData?.code)
errorMessage = `Upload failed: ${errorData.code}`
if (e instanceof Response) {
try {
const errorData = await e.json()
if (errorData?.code)
errorMessage = `Upload failed: ${errorData.code}`
}
catch {
// Ignore JSON parsing errors
}
}
else if (e instanceof Error) {
errorMessage = e.message
}
console.error('Failed to fetch remote icon:', e)
Toast.notify({ type: 'warning', message: errorMessage })
}

View File

@ -1,14 +1,28 @@
/* eslint-disable react/no-unnecessary-use-prefix */
import type { ReactNode } from 'react'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import { useMCPServiceCardState } from './use-mcp-service-card'
// Mutable mock data for MCP server detail
let mockMCPServerDetailData: {
id: string
status: string
server_code: string
description: string
parameters: Record<string, unknown>
} | undefined = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
// Mock service hooks
vi.mock('@/service/use-tools', () => ({
useUpdateMCPServer: () => ({
@ -18,16 +32,8 @@ vi.mock('@/service/use-tools', () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
useMCPServerDetail: (appId: string) => ({
data: appId
? {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
: undefined,
useMCPServerDetail: () => ({
data: mockMCPServerDetailData,
}),
useInvalidateMCPServerDetail: () => vi.fn(),
}))
@ -85,6 +91,17 @@ describe('useMCPServiceCardState', () => {
api_base_url: 'https://api.example.com/v1',
} as AppDetailResponse & Partial<AppSSO>)
beforeEach(() => {
// Reset mock data to default (published server)
mockMCPServerDetailData = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
})
describe('Initialization', () => {
it('should initialize with correct default values for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
@ -350,38 +367,26 @@ describe('useMCPServiceCardState', () => {
describe('Unpublished Server', () => {
it('should open modal and return not activated when enabling unpublished server', async () => {
// Override mock to return no data (unpublished server)
vi.doMock('@/service/use-tools', () => ({
useUpdateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
}),
useRefreshMCPServerCode: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
useMCPServerDetail: () => ({
data: undefined, // No server data = unpublished
}),
useInvalidateMCPServerDetail: () => vi.fn(),
}))
// Set mock to return undefined (unpublished server)
mockMCPServerDetailData = undefined
// For this test, we need to test the scenario where server is not published
// Since we can't easily change the mock, we verify the behavior with the current mock
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// The current mock returns published server, so status change works normally
// This test documents the expected behavior
// Verify server is not published
expect(result.current.serverPublished).toBe(false)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(true)
})
// With published server, activation succeeds
expect(statusResult?.activated).toBe(true)
// Should open modal and return not activated
expect(result.current.showMCPServerModal).toBe(true)
expect(statusResult?.activated).toBe(false)
})
})

View File

@ -9,102 +9,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import MCPServiceCard from './mcp-service-card'
// Mutable mock state for dynamic testing
type MockServerDetail = {
id: string
status: string
server_code: string
description: string
parameters?: Record<string, unknown>
}
type MockAppContext = {
isCurrentWorkspaceManager: boolean
isCurrentWorkspaceEditor: boolean
}
type MockWorkflowData = {
graph: {
nodes: Array<{
data: {
type: string
variables?: Array<{ variable: string, label: string }>
}
}>
}
}
type MockBasicAppConfig = {
updated_at?: string
user_input_form?: unknown[]
}
let mockServerDetail: MockServerDetail | undefined = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
let mockAppContext: MockAppContext = {
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}
let mockWorkflowData: MockWorkflowData = {
graph: {
nodes: [
{ data: { type: 'start', variables: [{ variable: 'input', label: 'Input' }] } },
],
},
}
let mockBasicAppConfig: MockBasicAppConfig = {
updated_at: '2024-01-01',
user_input_form: [],
}
const mockUpdateMCPServer = vi.fn().mockResolvedValue({})
const mockRefreshMCPServerCode = vi.fn().mockResolvedValue({})
const mockInvalidateMCPServerDetail = vi.fn()
// Mock service hooks
vi.mock('@/service/use-tools', () => ({
useUpdateMCPServer: () => ({
mutateAsync: mockUpdateMCPServer,
}),
useRefreshMCPServerCode: () => ({
mutateAsync: mockRefreshMCPServerCode,
isPending: false,
}),
useMCPServerDetail: (appId: string) => ({
data: appId ? mockServerDetail : undefined,
}),
useInvalidateMCPServerDetail: () => mockInvalidateMCPServerDetail,
}))
// Mock workflow hook
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: (appId: string) => ({
data: appId ? mockWorkflowData : undefined,
}),
}))
// Mock app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockAppContext,
}))
// Mock apps service
vi.mock('@/service/apps', () => ({
fetchAppDetail: vi.fn().mockImplementation(() =>
Promise.resolve({
model_config: mockBasicAppConfig,
}),
),
}))
// Mock MCPServerModal
vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({
default: ({ show, onHide }: { show: boolean, onHide: () => void }) => {
@ -133,15 +37,39 @@ vi.mock('@/app/components/base/confirm', () => ({
}))
// Mutable mock handlers for hook
let mockHandleStatusChange = vi.fn().mockResolvedValue({ activated: true })
let mockHandleServerModalHide = vi.fn().mockReturnValue({ shouldDeactivate: false })
let mockHandleGenCode = vi.fn()
let mockOpenConfirmDelete = vi.fn()
let mockCloseConfirmDelete = vi.fn()
let mockOpenServerModal = vi.fn()
const mockHandleStatusChange = vi.fn().mockResolvedValue({ activated: true })
const mockHandleServerModalHide = vi.fn().mockReturnValue({ shouldDeactivate: false })
const mockHandleGenCode = vi.fn()
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockOpenServerModal = vi.fn()
// Mutable hook state
let mockHookState = {
// Type for mock hook state
type MockHookState = {
genLoading: boolean
isLoading: boolean
serverPublished: boolean
serverActivated: boolean
serverURL: string
detail: {
id: string
status: string
server_code: string
description: string
parameters: Record<string, unknown>
} | undefined
isCurrentWorkspaceManager: boolean
toggleDisabled: boolean
isMinimalState: boolean
appUnpublished: boolean
missingStartNode: boolean
showConfirmDelete: boolean
showMCPServerModal: boolean
latestParams: Array<unknown>
}
// Default hook state factory - creates fresh state for each test
const createDefaultHookState = (): MockHookState => ({
genLoading: false,
isLoading: false,
serverPublished: true,
@ -162,9 +90,12 @@ let mockHookState = {
showConfirmDelete: false,
showMCPServerModal: false,
latestParams: [],
}
})
// Mock the hook
// Mutable hook state - modify this in tests to change component behavior
let mockHookState = createDefaultHookState()
// Mock the hook - uses mockHookState which can be modified per test
vi.mock('./hooks/use-mcp-service-card', () => ({
useMCPServiceCardState: () => ({
...mockHookState,
@ -198,63 +129,16 @@ describe('MCPServiceCard', () => {
} as AppDetailResponse & Partial<AppSSO>)
beforeEach(() => {
// Reset all mock states
mockServerDetail = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
mockAppContext = {
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}
mockWorkflowData = {
graph: {
nodes: [
{ data: { type: 'start', variables: [{ variable: 'input', label: 'Input' }] } },
],
},
}
mockBasicAppConfig = {
updated_at: '2024-01-01',
user_input_form: [],
}
mockUpdateMCPServer.mockClear()
mockRefreshMCPServerCode.mockClear()
mockInvalidateMCPServerDetail.mockClear()
// Reset hook state to defaults before each test
mockHookState = createDefaultHookState()
// Reset hook mocks
mockHandleStatusChange = vi.fn().mockResolvedValue({ activated: true })
mockHandleServerModalHide = vi.fn().mockReturnValue({ shouldDeactivate: false })
mockHandleGenCode = vi.fn()
mockOpenConfirmDelete = vi.fn()
mockCloseConfirmDelete = vi.fn()
mockOpenServerModal = vi.fn()
mockHookState = {
genLoading: false,
isLoading: false,
serverPublished: true,
serverActivated: true,
serverURL: 'https://api.example.com/mcp/server/abc123/mcp',
detail: {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
},
isCurrentWorkspaceManager: true,
toggleDisabled: false,
isMinimalState: false,
appUnpublished: false,
missingStartNode: false,
showConfirmDelete: false,
showMCPServerModal: false,
latestParams: [],
}
// Reset all mock function call history
mockHandleStatusChange.mockClear().mockResolvedValue({ activated: true })
mockHandleServerModalHide.mockClear().mockReturnValue({ shouldDeactivate: false })
mockHandleGenCode.mockClear()
mockOpenConfirmDelete.mockClear()
mockCloseConfirmDelete.mockClear()
mockOpenServerModal.mockClear()
})
describe('Rendering', () => {
@ -502,7 +386,15 @@ describe('MCPServiceCard', () => {
describe('Server Not Published', () => {
beforeEach(() => {
mockServerDetail = undefined
// Modify hookState to simulate unpublished server
mockHookState = {
...createDefaultHookState(),
serverPublished: false,
serverActivated: false,
serverURL: '***********',
detail: undefined,
isMinimalState: true,
}
})
it('should show add description button when server is not published', () => {
@ -541,12 +433,17 @@ describe('MCPServiceCard', () => {
describe('Inactive Server', () => {
beforeEach(() => {
mockServerDetail = {
id: 'server-123',
status: 'inactive',
server_code: 'abc123',
description: 'Test server',
parameters: {},
// Modify hookState to simulate inactive server
mockHookState = {
...createDefaultHookState(),
serverActivated: false,
detail: {
id: 'server-123',
status: 'inactive',
server_code: 'abc123',
description: 'Test server',
parameters: {},
},
}
})
@ -575,9 +472,10 @@ describe('MCPServiceCard', () => {
describe('Non-Manager User', () => {
beforeEach(() => {
mockAppContext = {
// Modify hookState to simulate non-manager user
mockHookState = {
...createDefaultHookState(),
isCurrentWorkspaceManager: false,
isCurrentWorkspaceEditor: true,
}
})
@ -593,7 +491,7 @@ describe('MCPServiceCard', () => {
describe('Non-Editor User', () => {
it('should show disabled styling for non-editor switch', () => {
mockHookState = {
...mockHookState,
...createDefaultHookState(),
toggleDisabled: true,
}
@ -690,24 +588,31 @@ describe('MCPServiceCard', () => {
})
it('should deactivate switch when modal closes without previous activation', async () => {
mockServerDetail = undefined // Unpublished server
// Simulate unpublished server state
mockHookState = {
...createDefaultHookState(),
serverPublished: false,
serverActivated: false,
detail: undefined,
showMCPServerModal: true,
}
const appInfo = createMockAppInfo()
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
const switchElement = screen.getByRole('switch')
fireEvent.click(switchElement)
// Modal should be visible
const modal = screen.getByTestId('mcp-server-modal')
expect(modal).toBeInTheDocument()
const closeBtn = screen.getByTestId('close-modal-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
const modal = screen.queryByTestId('mcp-server-modal')
if (modal) {
expect(modal).toBeInTheDocument()
const closeBtn = screen.getByTestId('close-modal-btn')
fireEvent.click(closeBtn)
}
expect(mockHandleServerModalHide).toHaveBeenCalled()
})
// Switch should be off after closing modal without activation
const switchElement = screen.getByRole('switch')
expect(switchElement).toBeInTheDocument()
})
})
@ -715,7 +620,7 @@ describe('MCPServiceCard', () => {
describe('Unpublished App', () => {
it('should show minimal state for unpublished app', () => {
mockHookState = {
...mockHookState,
...createDefaultHookState(),
appUnpublished: true,
isMinimalState: true,
}
@ -728,7 +633,7 @@ describe('MCPServiceCard', () => {
it('should show disabled styling for unpublished app switch', () => {
mockHookState = {
...mockHookState,
...createDefaultHookState(),
appUnpublished: true,
toggleDisabled: true,
}
@ -746,7 +651,7 @@ describe('MCPServiceCard', () => {
describe('Workflow App Without Start Node', () => {
it('should show minimal state for workflow without start node', () => {
mockHookState = {
...mockHookState,
...createDefaultHookState(),
missingStartNode: true,
isMinimalState: true,
}
@ -759,7 +664,7 @@ describe('MCPServiceCard', () => {
it('should show disabled styling for workflow without start node', () => {
mockHookState = {
...mockHookState,
...createDefaultHookState(),
missingStartNode: true,
toggleDisabled: true,
}
@ -777,7 +682,7 @@ describe('MCPServiceCard', () => {
describe('Loading State', () => {
it('should return null when isLoading is true', () => {
mockHookState = {
...mockHookState,
...createDefaultHookState(),
isLoading: true,
}
@ -790,7 +695,7 @@ describe('MCPServiceCard', () => {
it('should render content when isLoading is false', () => {
mockHookState = {
...mockHookState,
...createDefaultHookState(),
isLoading: false,
}
@ -829,10 +734,10 @@ describe('MCPServiceCard', () => {
it('should call handleStatusChange with false when turning off', async () => {
// Start with server activated
mockHookState = {
...mockHookState,
...createDefaultHookState(),
serverActivated: true,
}
mockHandleStatusChange = vi.fn().mockResolvedValue({ activated: false })
mockHandleStatusChange.mockResolvedValue({ activated: false })
const appInfo = createMockAppInfo()
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
@ -850,10 +755,10 @@ describe('MCPServiceCard', () => {
it('should call handleStatusChange with true when turning on', async () => {
// Start with server deactivated
mockHookState = {
...mockHookState,
...createDefaultHookState(),
serverActivated: false,
}
mockHandleStatusChange = vi.fn().mockResolvedValue({ activated: true })
mockHandleStatusChange.mockResolvedValue({ activated: true })
const appInfo = createMockAppInfo()
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
@ -871,12 +776,12 @@ describe('MCPServiceCard', () => {
it('should set local activated to false when handleStatusChange returns activated: false and state is true', async () => {
// Simulate unpublished server scenario where enabling opens modal
mockHookState = {
...mockHookState,
...createDefaultHookState(),
serverActivated: false,
serverPublished: false,
}
// Handler returns activated: false (modal opened instead)
mockHandleStatusChange = vi.fn().mockResolvedValue({ activated: false })
mockHandleStatusChange.mockResolvedValue({ activated: false })
const appInfo = createMockAppInfo()
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
@ -899,11 +804,11 @@ describe('MCPServiceCard', () => {
it('should deactivate when handleServerModalHide returns shouldDeactivate: true', async () => {
// Set up to show modal
mockHookState = {
...mockHookState,
...createDefaultHookState(),
showMCPServerModal: true,
serverActivated: false, // Server was not activated
}
mockHandleServerModalHide = vi.fn().mockReturnValue({ shouldDeactivate: true })
mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: true })
const appInfo = createMockAppInfo()
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
@ -919,11 +824,11 @@ describe('MCPServiceCard', () => {
it('should not deactivate when handleServerModalHide returns shouldDeactivate: false', async () => {
mockHookState = {
...mockHookState,
...createDefaultHookState(),
showMCPServerModal: true,
serverActivated: true, // Server was already activated
}
mockHandleServerModalHide = vi.fn().mockReturnValue({ shouldDeactivate: false })
mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: false })
const appInfo = createMockAppInfo()
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
@ -942,7 +847,7 @@ describe('MCPServiceCard', () => {
it('should call handleGenCode and closeConfirmDelete when confirm is clicked', async () => {
// Set up to show confirm dialog
mockHookState = {
...mockHookState,
...createDefaultHookState(),
showConfirmDelete: true,
}
@ -965,7 +870,7 @@ describe('MCPServiceCard', () => {
it('should call closeConfirmDelete when cancel is clicked', async () => {
mockHookState = {
...mockHookState,
...createDefaultHookState(),
showConfirmDelete: true,
}
@ -984,7 +889,13 @@ describe('MCPServiceCard', () => {
describe('getTooltipContent Function', () => {
it('should show publish tip when app is unpublished', () => {
mockBasicAppConfig = {} // Unpublished
// Modify hookState to simulate unpublished app
mockHookState = {
...createDefaultHookState(),
appUnpublished: true,
toggleDisabled: true,
isMinimalState: true,
}
const appInfo = createMockAppInfo()
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
@ -994,10 +905,12 @@ describe('MCPServiceCard', () => {
})
it('should show missing start node tooltip for workflow without start node', () => {
mockWorkflowData = {
graph: {
nodes: [{ data: { type: 'end' } }],
},
// Modify hookState to simulate missing start node
mockHookState = {
...createDefaultHookState(),
missingStartNode: true,
toggleDisabled: true,
isMinimalState: true,
}
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)

View File

@ -187,27 +187,23 @@ const MCPServiceCard: FC<IAppCardProps> = ({
openServerModal,
} = useMCPServiceCardState(appInfo, triggerModeDisabled)
// Local UI state for optimistic update
const [activated, setActivated] = useState(serverActivated)
// Sync with server state when it changes
if (activated !== serverActivated && !showMCPServerModal) {
setActivated(serverActivated)
}
// Pending status for optimistic updates (null means use server state)
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
const activated = pendingStatus ?? serverActivated
const onChangeStatus = async (state: boolean) => {
setActivated(state)
setPendingStatus(state)
const result = await handleStatusChange(state)
if (!result.activated && state) {
// Server modal was opened instead, keep local state false
setActivated(false)
// Server modal was opened instead, clear pending status
setPendingStatus(null)
}
}
const onServerModalHide = () => {
const result = handleServerModalHide(serverActivated)
if (result.shouldDeactivate)
setActivated(false)
handleServerModalHide(serverActivated)
// Clear pending status when modal closes to sync with server state
setPendingStatus(null)
}
const onConfirmRegenerate = () => {