fix: update test files to use TanStack Query pattern for appDetail

Update test files to reflect the appDetail migration from Zustand to TanStack Query:
- Replace setAppDetail mocks with useInvalidateAppDetail
- Add useParams mock from next/navigation
- Add useAppDetail mock from @/service/use-apps
- Remove deprecated fetchAppDetail + setAppDetail patterns
- Fix marketplace test mock data types
This commit is contained in:
yyh
2026-01-18 23:46:14 +08:00
parent b3acb74331
commit 1fe46ce0b8
7 changed files with 165 additions and 107 deletions

View File

@ -2,11 +2,13 @@ import type { ReactNode } from 'react'
import type { IConfigVarProps } from './index'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import type { App } from '@/types/app'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import DebugConfigurationContext from '@/context/debug-configuration'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index'
@ -38,6 +40,15 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('next/navigation', () => ({
useParams: () => ({
appId: 'test-app-id',
}),
}))
vi.mock('@/service/use-apps')
const mockUseAppDetail = vi.mocked(useAppDetail)
type SortableItem = {
id: string
variable: PromptVariable
@ -85,6 +96,18 @@ const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVa
}
}
function setupUseAppDetailMock() {
mockUseAppDetail.mockReturnValue({
data: {
id: 'test-app-id',
mode: AppModeEnum.CHAT,
} as App,
isLoading: false,
isPending: false,
error: null,
} as ReturnType<typeof useAppDetail>)
}
const renderConfigVar = (props: Partial<IConfigVarProps> = {}, debugOverrides: Partial<DebugConfigurationState> = {}) => {
const defaultProps: IConfigVarProps = {
promptVariables: [],
@ -219,6 +242,7 @@ describe('ConfigVar', () => {
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
setupUseAppDetailMock()
})
it('should save updates when editing a basic variable', async () => {

View File

@ -15,11 +15,12 @@ vi.mock('next/navigation', () => ({
push: mockPush,
replace: mockReplace,
}),
useParams: () => ({ appId: 'app-123' }),
}))
const mockSetAppDetail = vi.fn()
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: any) => unknown) => selector({ setAppDetail: mockSetAppDetail }),
const mockInvalidateAppDetail = vi.fn()
vi.mock('@/service/use-apps', () => ({
useInvalidateAppDetail: () => mockInvalidateAppDetail,
}))
const mockSwitchApp = vi.fn()
@ -275,7 +276,7 @@ describe('SwitchAppModal', () => {
})
expect(mockReplace).toHaveBeenCalledWith('/app/new-app-002/workflow')
expect(mockPush).not.toHaveBeenCalled()
expect(mockSetAppDetail).toHaveBeenCalledTimes(1)
expect(mockInvalidateAppDetail).toHaveBeenCalledTimes(1)
})
it('should notify error when switch app fails', async () => {

View File

@ -34,6 +34,18 @@ import Logs from './index'
vi.mock('@/service/use-log')
vi.mock('@/service/use-apps', () => ({
useAppDetail: () => ({
data: {
id: 'test-app-id',
name: 'Test App',
mode: 'workflow',
},
isLoading: false,
error: null,
}),
}))
vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value,
useDebounceFn: (fn: (value: string) => void) => ({ run: fn }),
@ -51,6 +63,9 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
useParams: () => ({
appId: 'test-app-id',
}),
}))
vi.mock('next/link', () => ({

View File

@ -1,7 +1,7 @@
import type { MarketplaceCollection } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { act, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
// ================================
@ -157,6 +157,45 @@ vi.mock('@/config', () => ({
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
// Mock service/client - configurable mock for testing
const {
mockMarketplaceCollections,
mockMarketplaceCollectionPlugins,
mockMarketplaceSearchAdvanced,
} = vi.hoisted(() => {
const mockMarketplaceCollections = vi.fn(() => Promise.resolve({
data: {
collections: [
{ name: 'test-collection', label: 'Test Collection' },
],
},
}))
const mockMarketplaceCollectionPlugins = vi.fn(() => Promise.resolve({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
},
}))
const mockMarketplaceSearchAdvanced = vi.fn(() => Promise.resolve({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
bundles: [],
total: 1,
},
}))
return { mockMarketplaceCollections, mockMarketplaceCollectionPlugins, mockMarketplaceSearchAdvanced }
})
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: mockMarketplaceCollections,
collectionPlugins: mockMarketplaceCollectionPlugins,
searchAdvanced: mockMarketplaceSearchAdvanced,
},
}))
// Mock var utils
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string, _params?: Record<string, string | undefined>) => `https://marketplace.dify.ai${path}`,
@ -199,7 +238,7 @@ vi.mock('@/i18n-config/language', () => ({
}))
// Mock global fetch for utils testing
const originalFetch = globalThis.fetch
const _originalFetch = globalThis.fetch
// Mock useTags hook
const mockTags = [
@ -1477,25 +1516,33 @@ describe('flatMap Coverage', () => {
describe('Async Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
globalThis.fetch = originalFetch
// Reset mocks to default behavior
mockMarketplaceCollections.mockImplementation(() => Promise.resolve({
data: {
collections: [
{ name: 'test-collection', label: 'Test Collection' },
],
},
}))
mockMarketplaceCollectionPlugins.mockImplementation(() => Promise.resolve({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
},
}))
})
describe('getMarketplacePluginsByCollectionId', () => {
it('should fetch plugins by collection id successfully', async () => {
const mockPlugins = [
{ type: 'plugin', org: 'test', name: 'plugin1' },
{ type: 'plugin', org: 'test', name: 'plugin2' },
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
]
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { plugins: mockPlugins } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
mockMarketplaceCollectionPlugins.mockResolvedValue({
data: { plugins: mockPlugins },
})
const { getMarketplacePluginsByCollectionId } = await import('./utils')
const result = await getMarketplacePluginsByCollectionId('test-collection', {
@ -1504,12 +1551,12 @@ describe('Async Utils', () => {
type: 'plugin',
})
expect(globalThis.fetch).toHaveBeenCalled()
expect(mockMarketplaceCollectionPlugins).toHaveBeenCalled()
expect(result).toHaveLength(2)
})
it('should handle fetch error and return empty array', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
mockMarketplaceCollectionPlugins.mockRejectedValue(new Error('Network error'))
const { getMarketplacePluginsByCollectionId } = await import('./utils')
const result = await getMarketplacePluginsByCollectionId('test-collection')
@ -1518,53 +1565,39 @@ describe('Async Utils', () => {
})
it('should pass abort signal when provided', async () => {
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }]
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { plugins: mockPlugins } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1', tags: [] }]
mockMarketplaceCollectionPlugins.mockResolvedValue({
data: { plugins: mockPlugins },
})
const controller = new AbortController()
const { getMarketplacePluginsByCollectionId } = await import('./utils')
await getMarketplacePluginsByCollectionId('test-collection', {}, { signal: controller.signal })
// oRPC uses Request objects, so check that fetch was called with a Request containing the right URL
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(Request),
expect.any(Object),
// Check that collectionPlugins was called with the correct params including signal
expect(mockMarketplaceCollectionPlugins).toHaveBeenCalledWith(
expect.objectContaining({
params: { collectionId: 'test-collection' },
}),
expect.objectContaining({
signal: controller.signal,
}),
)
const call = vi.mocked(globalThis.fetch).mock.calls[0]
const request = call[0] as Request
expect(request.url).toContain('test-collection')
})
})
describe('getMarketplaceCollectionsAndPlugins', () => {
it('should fetch collections and plugins successfully', async () => {
const mockCollections = [
{ name: 'collection1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
{ name: 'collection1', label: 'Collection 1' },
]
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }]
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1', tags: [] }]
let callCount = 0
globalThis.fetch = vi.fn().mockImplementation(() => {
callCount++
if (callCount === 1) {
return Promise.resolve(
new Response(JSON.stringify({ data: { collections: mockCollections } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
}
return Promise.resolve(
new Response(JSON.stringify({ data: { plugins: mockPlugins } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
mockMarketplaceCollections.mockResolvedValue({
data: { collections: mockCollections },
})
mockMarketplaceCollectionPlugins.mockResolvedValue({
data: { plugins: mockPlugins },
})
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
@ -1578,7 +1611,7 @@ describe('Async Utils', () => {
})
it('should handle fetch error and return empty data', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
mockMarketplaceCollections.mockRejectedValue(new Error('Network error'))
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
const result = await getMarketplaceCollectionsAndPlugins()
@ -1588,12 +1621,9 @@ describe('Async Utils', () => {
})
it('should append condition and type to URL when provided', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { collections: [] } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
mockMarketplaceCollections.mockResolvedValue({
data: { collections: [] },
})
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
await getMarketplaceCollectionsAndPlugins({
@ -1601,11 +1631,16 @@ describe('Async Utils', () => {
type: 'bundle',
})
// oRPC uses Request objects, so check that fetch was called with a Request containing the right URL
expect(globalThis.fetch).toHaveBeenCalled()
const call = vi.mocked(globalThis.fetch).mock.calls[0]
const request = call[0] as Request
expect(request.url).toContain('condition=category%3Dtool')
// Check that collections was called with the correct query params
expect(mockMarketplaceCollections).toHaveBeenCalledWith(
expect.objectContaining({
query: expect.objectContaining({
condition: 'category=tool',
type: 'bundle',
}),
}),
expect.any(Object),
)
})
})
})

View File

@ -7,6 +7,10 @@ import { Plan } from '@/app/components/billing/type'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import FeaturesTrigger from './features-trigger'
vi.mock('next/navigation', () => ({
useParams: () => ({ appId: 'app-id' }),
}))
const mockUseIsChatMode = vi.fn()
const mockUseTheme = vi.fn()
const mockUseNodesReadOnly = vi.fn()
@ -26,8 +30,7 @@ const mockPublishWorkflow = vi.fn()
const mockUpdatePublishedWorkflow = vi.fn()
const mockResetWorkflowVersionHistory = vi.fn()
const mockInvalidateAppTriggers = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockSetAppDetail = vi.fn()
const mockInvalidateAppDetail = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetLastPublishedHasUserInput = vi.fn()
@ -126,8 +129,8 @@ vi.mock('@/service/use-tools', () => ({
useInvalidateAppTriggers: () => mockInvalidateAppTriggers,
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
vi.mock('@/service/use-apps', () => ({
useInvalidateAppDetail: () => mockInvalidateAppDetail,
}))
vi.mock('@/hooks/use-theme', () => ({
@ -135,7 +138,7 @@ vi.mock('@/hooks/use-theme', () => ({
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail?: { id: string }, setAppDetail: typeof mockSetAppDetail }) => unknown) => mockUseAppStoreSelector(selector),
useStore: (selector: (state: { appDetail?: { id: string } }) => unknown) => mockUseAppStoreSelector(selector),
}))
const createProviderContext = ({
@ -178,8 +181,7 @@ describe('FeaturesTrigger', () => {
mockUseProviderContext.mockReturnValue(createProviderContext({}))
mockUseNodes.mockReturnValue([])
mockUseEdges.mockReturnValue([])
mockUseAppStoreSelector.mockImplementation(selector => selector({ appDetail: { id: 'app-id' }, setAppDetail: mockSetAppDetail }))
mockFetchAppDetail.mockResolvedValue({ id: 'app-id' })
mockUseAppStoreSelector.mockImplementation(selector => selector({ appDetail: { id: 'app-id' } }))
mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
})
@ -423,8 +425,7 @@ describe('FeaturesTrigger', () => {
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
expect(mockSetAppDetail).toHaveBeenCalled()
expect(mockInvalidateAppDetail).toHaveBeenCalledWith('app-id')
})
})
@ -445,23 +446,5 @@ describe('FeaturesTrigger', () => {
})
})
})
it('should log error when app detail refresh fails after publish', async () => {
// Arrange
const user = userEvent.setup()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchAppDetail.mockRejectedValue(new Error('fetch failed'))
renderWithToast(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled()
})
consoleErrorSpy.mockRestore()
})
})
})

View File

@ -4,10 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import WorkflowHeader from './index'
const mockUseAppStoreSelector = vi.fn()
const mockSetCurrentLogItem = vi.fn()
const mockSetShowMessageLogModal = vi.fn()
const mockResetWorkflowVersionHistory = vi.fn()
const mockUseAppDetail = vi.fn()
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-id',
@ -39,19 +39,24 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
...overrides,
})
let appDetail: App
const mockAppStore = (overrides: Partial<App> = {}) => {
appDetail = createMockApp(overrides)
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
const appDetail = createMockApp(overrides)
mockUseAppDetail.mockReturnValue({ data: appDetail })
}
vi.mock('next/navigation', () => ({
useParams: () => ({ appId: 'app-id' }),
}))
vi.mock('@/service/use-apps', () => ({
useAppDetail: (...args: unknown[]) => mockUseAppDetail(...args),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail?: App, setCurrentLogItem: typeof mockSetCurrentLogItem, setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector),
useStore: (selector: (state: { setCurrentLogItem: typeof mockSetCurrentLogItem, setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => selector({
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}),
}))
vi.mock('@/app/components/workflow/header', () => ({

View File

@ -608,11 +608,6 @@
"count": 1
}
},
"app/components/app/switch-app-modal/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/app/switch-app-modal/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1