dify/web/__tests__/apps/app-card-operations-flow.test.tsx
yyh d66de8a47b
test(e2e): harden behavior coverage and CI gates (#39043)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-16 07:32:11 +00:00

427 lines
13 KiB
TypeScript

/**
* Integration test: App Card Operations Flow
*
* Tests the end-to-end user flows for app card operations:
* - Editing app info
* - Duplicating an app
* - Deleting an app
* - Exporting app DSL
* - Navigation on card click
* - Access mode icons
*/
import type { App } from '@/types/app'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { AppCard } from '@/app/components/apps/app-card'
import { AccessMode } from '@/models/access-control'
import { exportAppConfig, updateAppInfo } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
let mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
const toastMocks = vi.hoisted(() => ({
mockNotify: vi.fn(),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}))
const mockRouterPush = vi.fn()
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: (message: string, options?: Record<string, unknown>) =>
toastMocks.mockNotify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) =>
toastMocks.mockNotify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) =>
toastMocks.mockNotify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) =>
toastMocks.mockNotify({ type: 'info', message, ...options }),
dismiss: toastMocks.dismiss,
update: toastMocks.update,
promise: toastMocks.promise,
},
}))
const mockOnPlanInfoChanged = vi.fn()
const mockDeleteAppMutation = vi.fn().mockResolvedValue(undefined)
let mockDeleteMutationPending = false
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({
data: [],
}),
}
})
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<React.ComponentType | { default: React.ComponentType }>) => {
let Component: React.ComponentType<Record<string, unknown>> | null = null
loader()
.then((mod) => {
Component = (typeof mod === 'function' ? mod : mod.default) as React.ComponentType<
Record<string, unknown>
>
})
.catch(() => {})
const Wrapper = (props: Record<string, unknown>) => {
if (Component) return <Component {...props} />
return null
}
Wrapper.displayName = 'DynamicWrapper'
return Wrapper
},
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-apps', () => ({
useDeleteAppMutation: () => ({
mutateAsync: mockDeleteAppMutation,
isPending: mockDeleteMutationPending,
}),
useToggleAppStarMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/apps', () => ({
deleteApp: vi.fn().mockResolvedValue({}),
updateAppInfo: vi.fn().mockResolvedValue({}),
copyApp: vi.fn().mockResolvedValue({ id: 'new-app-id', mode: 'chat' }),
exportAppConfig: vi.fn().mockResolvedValue({ data: 'yaml-content' }),
}))
vi.mock('@/service/explore', () => ({
fetchInstalledAppList: vi.fn().mockResolvedValue({ installed_apps: [] }),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
// Mock modals loaded via next/dynamic
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: ({ show, onConfirm, onHide, appName }: Record<string, unknown>) => {
if (!show) return null
return (
<div data-testid="edit-app-modal">
<span data-testid="modal-app-name">{appName as string}</span>
<button
data-testid="confirm-edit"
onClick={() =>
(onConfirm as (data: Record<string, unknown>) => void)({
name: 'Updated App Name',
icon_type: 'emoji',
icon: '🔥',
icon_background: '#fff',
description: 'Updated description',
})
}
>
Confirm
</button>
<button data-testid="cancel-edit" onClick={onHide as () => void}>
Cancel
</button>
</div>
)
},
}))
vi.mock('@/app/components/app/duplicate-modal', () => ({
default: ({ show, onConfirm, onHide }: Record<string, unknown>) => {
if (!show) return null
return (
<div data-testid="duplicate-app-modal">
<button
data-testid="confirm-duplicate"
onClick={() =>
(onConfirm as (data: Record<string, unknown>) => void)({
name: 'Copied App',
icon_type: 'emoji',
icon: '📋',
icon_background: '#fff',
})
}
>
Confirm Duplicate
</button>
<button data-testid="cancel-duplicate" onClick={onHide as () => void}>
Cancel
</button>
</div>
)
},
}))
vi.mock('@/app/components/app/switch-app-modal', () => ({
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
if (!show) return null
return (
<div data-testid="switch-app-modal">
<button data-testid="confirm-switch" onClick={onSuccess as () => void}>
Confirm Switch
</button>
<button data-testid="cancel-switch" onClick={onClose as () => void}>
Cancel
</button>
</div>
)
},
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
<div data-testid="dsl-export-confirm-modal">
<button
data-testid="export-include"
onClick={() => (onConfirm as (include: boolean) => void)(true)}
>
Include
</button>
<button data-testid="export-close" onClick={onClose as () => void}>
Close
</button>
</div>
),
}))
vi.mock('@/app/components/app/app-access-control', () => {
const MockAccessControl = ({ onConfirm, onClose }: Record<string, unknown>) => (
<div data-testid="access-control-modal">
<button data-testid="confirm-access" onClick={onConfirm as () => void}>
Confirm
</button>
<button data-testid="cancel-access" onClick={onClose as () => void}>
Cancel
</button>
</div>
)
return {
default: MockAccessControl,
AccessControl: MockAccessControl,
}
})
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: overrides.id ?? 'app-1',
name: overrides.name ?? 'Test Chat App',
description: overrides.description ?? 'A chat application',
author_name: overrides.author_name ?? 'Test Author',
icon_type: overrides.icon_type ?? 'emoji',
icon: overrides.icon ?? '🤖',
icon_background: overrides.icon_background ?? '#FFEAD5',
icon_url: overrides.icon_url ?? null,
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
mode: overrides.mode ?? AppModeEnum.CHAT,
enable_site: overrides.enable_site ?? true,
enable_api: overrides.enable_api ?? true,
api_rpm: overrides.api_rpm ?? 60,
api_rph: overrides.api_rph ?? 3600,
is_demo: overrides.is_demo ?? false,
model_config: overrides.model_config ?? ({} as App['model_config']),
app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']),
created_at: overrides.created_at ?? 1700000000,
updated_at: overrides.updated_at ?? 1700001000,
site: overrides.site ?? ({} as App['site']),
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
tags: overrides.tags ?? [],
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
max_active_requests: overrides.max_active_requests ?? null,
created_by: overrides.created_by ?? 'user-1',
permission_keys: overrides.permission_keys ?? [
AppACLPermission.Edit,
AppACLPermission.ImportExportDSL,
AppACLPermission.Delete,
AppACLPermission.ReleaseAndVersion,
AppACLPermission.AccessConfig,
],
})
const mockOnRefresh = vi.fn()
const renderAppCard = (app?: Partial<App>) => {
return renderWithSystemFeatures(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />, {
systemFeatures: mockSystemFeatures,
})
}
const openOperationsMenu = async (appName = 'Test Chat App') => {
const user = userEvent.setup()
await user.click(
screen.getByRole('button', {
name: `common.operation.moreActionsFor:{"name":"${appName}"}`,
}),
)
return user
}
describe('App Card Operations Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDeleteMutationPending = false
mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Card Rendering', () => {
it('should render app name and description', () => {
renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' })
expect(screen.getByText('My AI Bot')).toBeInTheDocument()
expect(screen.getByText('An intelligent assistant')).toBeInTheDocument()
})
it('should render author name', () => {
renderAppCard({ author_name: 'John Doe' })
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should navigate to app config page when card is clicked', () => {
renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT })
expect(screen.getByRole('link', { name: 'Test Chat App' })).toHaveAttribute(
'href',
'/app/app-123/configuration',
)
})
it('should navigate to workflow page for workflow apps', () => {
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
expect(screen.getByRole('link', { name: 'WF App' })).toHaveAttribute(
'href',
'/app/app-wf/workflow',
)
})
})
// -- Delete flow --
describe('Delete App Flow', () => {
it('should show delete confirmation and call API on confirm', async () => {
renderAppCard({ id: 'app-to-delete', name: 'Deletable App' })
const user = await openOperationsMenu('Deletable App')
await user.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
await waitFor(() => {
expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument()
})
await user.type(screen.getByRole('textbox'), 'Deletable App')
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete')
})
})
})
// -- Edit flow --
describe('Edit App Flow', () => {
it('should open edit modal and call updateAppInfo on confirm', async () => {
renderAppCard({ id: 'app-edit', name: 'Editable App' })
const user = await openOperationsMenu('Editable App')
await user.click(await screen.findByRole('menuitem', { name: 'app.editApp' }))
await user.click(await screen.findByRole('button', { name: 'Confirm' }))
await waitFor(() => {
expect(updateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
appID: 'app-edit',
name: 'Updated App Name',
}),
)
})
})
})
// -- Export flow --
describe('Export App Flow', () => {
it('should call exportAppConfig for completion apps', async () => {
renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' })
const user = await openOperationsMenu('Export App')
await user.click(await screen.findByRole('menuitem', { name: 'app.export' }))
await waitFor(() => {
expect(exportAppConfig).toHaveBeenCalledWith(
expect.objectContaining({ appID: 'app-export' }),
)
})
})
})
// -- Access mode display --
describe('Access Mode Display', () => {
it('should not render operations menu when user has no app permissions', () => {
renderAppCard({ name: 'Readonly App', created_by: 'another-user', permission_keys: [] })
expect(
screen.queryByRole('button', {
name: /common\.operation\.moreActionsFor/,
}),
).not.toBeInTheDocument()
})
})
// -- Switch mode (only for CHAT/COMPLETION) --
describe('Switch App Mode', () => {
it('should show switch option for chat mode apps', async () => {
renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT })
await openOperationsMenu()
expect(await screen.findByRole('menuitem', { name: 'app.switch' })).toBeVisible()
})
it('should not show switch option for workflow apps', async () => {
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
await openOperationsMenu('WF App')
expect(await screen.findByRole('menu')).toBeVisible()
expect(screen.queryByRole('menuitem', { name: 'app.switch' })).not.toBeInTheDocument()
})
})
})