diff --git a/web/app/components/app/configuration/config-var/index.spec.tsx b/web/app/components/app/configuration/config-var/index.spec.tsx index b5015ed079..69a2fb6f9e 100644 --- a/web/app/components/app/configuration/config-var/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/index.spec.tsx @@ -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 = {}): PromptVa } } +function setupUseAppDetailMock() { + mockUseAppDetail.mockReturnValue({ + data: { + id: 'test-app-id', + mode: AppModeEnum.CHAT, + } as App, + isLoading: false, + isPending: false, + error: null, + } as ReturnType) +} + const renderConfigVar = (props: Partial = {}, debugOverrides: Partial = {}) => { 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 () => { diff --git a/web/app/components/app/switch-app-modal/index.spec.tsx b/web/app/components/app/switch-app-modal/index.spec.tsx index 9ff6801243..f119630cc2 100644 --- a/web/app/components/app/switch-app-modal/index.spec.tsx +++ b/web/app/components/app/switch-app-modal/index.spec.tsx @@ -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 () => { diff --git a/web/app/components/app/workflow-log/index.spec.tsx b/web/app/components/app/workflow-log/index.spec.tsx index f8e3f16e25..ba3df6abe8 100644 --- a/web/app/components/app/workflow-log/index.spec.tsx +++ b/web/app/components/app/workflow-log/index.spec.tsx @@ -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: (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', () => ({ diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index dc2513ac05..5b8878207b 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -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) => `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), + ) }) }) }) diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx index 757e7c8a97..3d18741064 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx @@ -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() - - // Act - await user.click(screen.getByRole('button', { name: 'publisher-publish' })) - - // Assert - await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalled() - }) - consoleErrorSpy.mockRestore() - }) }) }) diff --git a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx index 9308f54ce1..98b9e5e0cd 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx @@ -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 => ({ id: 'app-id', @@ -39,19 +39,24 @@ const createMockApp = (overrides: Partial = {}): App => ({ ...overrides, }) -let appDetail: App - const mockAppStore = (overrides: Partial = {}) => { - 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', () => ({ diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index d5a4604de3..32fab4a6c7 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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