import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useParams } from 'next/navigation' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' import { useInfiniteAppList } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import AppNav from './index' vi.mock('next/navigation', () => ({ useParams: vi.fn(), })) vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), })) vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) vi.mock('@/app/components/app/store', () => ({ useStore: vi.fn(), })) vi.mock('@/service/use-apps', () => ({ useInfiniteAppList: vi.fn(), })) vi.mock('@/app/components/app/create-app-dialog', () => ({ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) => show ? ( ) : null, })) vi.mock('@/app/components/app/create-app-modal', () => ({ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) => show ? ( ) : null, })) vi.mock('@/app/components/app/create-from-dsl-modal', () => ({ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) => show ? ( ) : null, })) vi.mock('../nav', () => ({ default: ({ onCreate, onLoadMore, navigationItems, }: { onCreate: (state: string) => void onLoadMore?: () => void navigationItems?: Array<{ id: string, name: string, link: string }> }) => (
), })) const mockAppData = [ { id: 'app-1', name: 'App 1', mode: AppModeEnum.AGENT_CHAT, icon_type: 'emoji', icon: '🤖', icon_background: null, icon_url: null, }, ] const mockUseParams = vi.mocked(useParams) const mockUseAppContext = vi.mocked(useAppContext) const mockUseAppStore = vi.mocked(useAppStore) const mockUseInfiniteAppList = vi.mocked(useInfiniteAppList) let mockAppDetail: { id: string, name: string } | null = null const setupDefaultMocks = (options?: { hasNextPage?: boolean refetch?: () => void fetchNextPage?: () => void isEditor?: boolean appData?: typeof mockAppData }) => { const refetch = options?.refetch ?? vi.fn() const fetchNextPage = options?.fetchNextPage ?? vi.fn() mockUseParams.mockReturnValue({ appId: 'app-1' } as ReturnType) mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType) mockUseAppStore.mockImplementation((selector: unknown) => (selector as (state: { appDetail: { id: string, name: string } | null }) => unknown)({ appDetail: mockAppDetail })) mockUseInfiniteAppList.mockReturnValue({ data: { pages: [{ data: options?.appData ?? mockAppData }] }, fetchNextPage, hasNextPage: options?.hasNextPage ?? false, isFetchingNextPage: false, refetch, } as ReturnType) return { refetch, fetchNextPage } } describe('AppNav', () => { beforeEach(() => { vi.clearAllMocks() mockAppDetail = null setupDefaultMocks() }) it('should build editor links and update app name when app detail changes', async () => { setupDefaultMocks({ isEditor: true, appData: [ { id: 'app-1', name: 'App 1', mode: AppModeEnum.AGENT_CHAT, icon_type: 'emoji', icon: '🤖', icon_background: null, icon_url: null, }, { id: 'app-2', name: 'App 2', mode: AppModeEnum.WORKFLOW, icon_type: 'emoji', icon: '⚙️', icon_background: null, icon_url: null, }, ], }) const { rerender } = render() expect(screen.getByText('App 1 -> /app/app-1/configuration')).toBeInTheDocument() expect(screen.getByText('App 2 -> /app/app-2/workflow')).toBeInTheDocument() mockAppDetail = { id: 'app-1', name: 'Updated App Name' } rerender() await waitFor(() => { expect(screen.getByText('Updated App Name -> /app/app-1/configuration')).toBeInTheDocument() }) }) it('should open and close create app modal, then refetch', async () => { const user = userEvent.setup() const { refetch } = setupDefaultMocks() render() await user.click(screen.getByTestId('create-blank')) expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() await user.click(screen.getByTestId('create-app-modal')) await waitFor(() => { expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() expect(refetch).toHaveBeenCalledTimes(1) }) }) it('should open and close template modal, then refetch', async () => { const user = userEvent.setup() const { refetch } = setupDefaultMocks() render() await user.click(screen.getByTestId('create-template')) expect(screen.getByTestId('create-app-template-dialog')).toBeInTheDocument() await user.click(screen.getByTestId('create-app-template-dialog')) await waitFor(() => { expect(screen.queryByTestId('create-app-template-dialog')).not.toBeInTheDocument() expect(refetch).toHaveBeenCalledTimes(1) }) }) it('should open and close DSL modal, then refetch', async () => { const user = userEvent.setup() const { refetch } = setupDefaultMocks() render() await user.click(screen.getByTestId('create-dsl')) expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() await user.click(screen.getByTestId('create-from-dsl-modal')) await waitFor(() => { expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument() expect(refetch).toHaveBeenCalledTimes(1) }) }) it('should load more when user clicks load more and more data is available', async () => { const user = userEvent.setup() const { fetchNextPage } = setupDefaultMocks({ hasNextPage: true }) render() await user.click(screen.getByTestId('load-more')) expect(fetchNextPage).toHaveBeenCalledTimes(1) }) it('should not load more when user clicks load more and no data is available', async () => { const user = userEvent.setup() const { fetchNextPage } = setupDefaultMocks({ hasNextPage: false }) render() await user.click(screen.getByTestId('load-more')) expect(fetchNextPage).not.toHaveBeenCalled() }) // Non-editor link path: isCurrentWorkspaceEditor=false → link ends with /overview it('should build overview links when user is not editor', () => { // Arrange setupDefaultMocks({ isEditor: false }) // Act render() // Assert expect(screen.getByText('App 1 -> /app/app-1/overview')).toBeInTheDocument() }) // !!appId false: query disabled, no nav items it('should render no nav items when appId is undefined', () => { // Arrange setupDefaultMocks() mockUseParams.mockReturnValue({} as ReturnType) mockUseInfiniteAppList.mockReturnValue({ data: undefined, fetchNextPage: vi.fn(), hasNextPage: false, isFetchingNextPage: false, refetch: vi.fn(), } as unknown as ReturnType) // Act render() // Assert const navItems = screen.getByTestId('nav-items') expect(navItems.children).toHaveLength(0) }) // ADVANCED_CHAT OR branch: editor + ADVANCED_CHAT mode → link ends with /workflow it('should build workflow link for ADVANCED_CHAT mode when user is editor', () => { // Arrange setupDefaultMocks({ isEditor: true, appData: [ { id: 'app-3', name: 'Chat App', mode: AppModeEnum.ADVANCED_CHAT, icon_type: 'emoji', icon: '💬', icon_background: null, icon_url: null, }, ], }) // Act render() // Assert expect(screen.getByText('Chat App -> /app/app-3/workflow')).toBeInTheDocument() }) // No-match update path: appDetail.id doesn't match any nav item it('should not change nav item names when appDetail id does not match any item', async () => { // Arrange setupDefaultMocks({ isEditor: true }) const { rerender } = render() // Act - set appDetail to a non-matching id mockAppDetail = { id: 'non-existent-id', name: 'Unknown' } rerender() // Assert - original name should be unchanged await waitFor(() => { expect(screen.getByText('App 1 -> /app/app-1/configuration')).toBeInTheDocument() }) }) })