import { render, screen, waitFor } from '@testing-library/react' import { AppModeEnum } from '@/types/app' import { AccessMode } from '@/models/access-control' // Mock external dependencies BEFORE imports jest.mock('use-context-selector', () => ({ useContext: jest.fn(), createContext: jest.fn(() => ({})), })) jest.mock('@/context/web-app-context', () => ({ useWebAppStore: jest.fn(), })) jest.mock('@/service/access-control', () => ({ useGetUserCanAccessApp: jest.fn(), })) jest.mock('@/service/use-explore', () => ({ useGetInstalledAppAccessModeByAppId: jest.fn(), useGetInstalledAppParams: jest.fn(), useGetInstalledAppMeta: jest.fn(), })) import { useContext } from 'use-context-selector' import InstalledApp from './index' import { useWebAppStore } from '@/context/web-app-context' import { useGetUserCanAccessApp } from '@/service/access-control' import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' import type { InstalledApp as InstalledAppType } from '@/models/explore' /** * Mock child components for unit testing * * RATIONALE FOR MOCKING: * - TextGenerationApp: 648 lines, complex batch processing, task management, file uploads * - ChatWithHistory: 576-line custom hook, complex conversation/history management, 30+ context values * * These components are too complex to test as real components. Using real components would: * 1. Require mocking dozens of their dependencies (services, contexts, hooks) * 2. Make tests fragile and coupled to child component implementation details * 3. Violate the principle of testing one component in isolation * * For a container component like InstalledApp, its responsibility is to: * - Correctly route to the appropriate child component based on app mode * - Pass the correct props to child components * - Handle loading/error states before rendering children * * The internal logic of ChatWithHistory and TextGenerationApp should be tested * in their own dedicated test files. */ jest.mock('@/app/components/share/text-generation', () => ({ __esModule: true, default: ({ isInstalledApp, installedAppInfo, isWorkflow }: { isInstalledApp?: boolean installedAppInfo?: InstalledAppType isWorkflow?: boolean }) => (
Text Generation App {isWorkflow && ' (Workflow)'} {isInstalledApp && ` - ${installedAppInfo?.id}`}
), })) jest.mock('@/app/components/base/chat/chat-with-history', () => ({ __esModule: true, default: ({ installedAppInfo, className }: { installedAppInfo?: InstalledAppType className?: string }) => (
Chat With History - {installedAppInfo?.id}
), })) describe('InstalledApp', () => { const mockUpdateAppInfo = jest.fn() const mockUpdateWebAppAccessMode = jest.fn() const mockUpdateAppParams = jest.fn() const mockUpdateWebAppMeta = jest.fn() const mockUpdateUserCanAccessApp = jest.fn() const mockInstalledApp = { id: 'installed-app-123', app: { id: 'app-123', name: 'Test App', mode: AppModeEnum.CHAT, icon_type: 'emoji' as const, icon: '🚀', icon_background: '#FFFFFF', icon_url: '', description: 'Test description', use_icon_as_answer_icon: false, }, uninstallable: true, is_pinned: false, } const mockAppParams = { user_input_form: [], file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } }, system_parameters: {}, } const mockAppMeta = { tool_icons: {}, } const mockWebAppAccessMode = { accessMode: AccessMode.PUBLIC, } const mockUserCanAccessApp = { result: true, } beforeEach(() => { jest.clearAllMocks() // Mock useContext ;(useContext as jest.Mock).mockReturnValue({ installedApps: [mockInstalledApp], isFetchingInstalledApps: false, }) // Mock useWebAppStore ;(useWebAppStore as unknown as jest.Mock).mockImplementation(( selector: (state: { updateAppInfo: jest.Mock updateWebAppAccessMode: jest.Mock updateAppParams: jest.Mock updateWebAppMeta: jest.Mock updateUserCanAccessApp: jest.Mock }) => unknown, ) => { const state = { updateAppInfo: mockUpdateAppInfo, updateWebAppAccessMode: mockUpdateWebAppAccessMode, updateAppParams: mockUpdateAppParams, updateWebAppMeta: mockUpdateWebAppMeta, updateUserCanAccessApp: mockUpdateUserCanAccessApp, } return selector(state) }) // Mock service hooks with default success states ;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({ isFetching: false, data: mockWebAppAccessMode, error: null, }) ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({ isFetching: false, data: mockAppParams, error: null, }) ;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({ isFetching: false, data: mockAppMeta, error: null, }) ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({ data: mockUserCanAccessApp, error: null, }) }) describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() }) it('should render loading state when fetching app params', () => { ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({ isFetching: true, data: null, error: null, }) const { container } = render() const svg = container.querySelector('svg.spin-animation') expect(svg).toBeInTheDocument() }) it('should render loading state when fetching app meta', () => { ;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({ isFetching: true, data: null, error: null, }) const { container } = render() const svg = container.querySelector('svg.spin-animation') expect(svg).toBeInTheDocument() }) it('should render loading state when fetching web app access mode', () => { ;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({ isFetching: true, data: null, error: null, }) const { container } = render() const svg = container.querySelector('svg.spin-animation') expect(svg).toBeInTheDocument() }) it('should render loading state when fetching installed apps', () => { ;(useContext as jest.Mock).mockReturnValue({ installedApps: [mockInstalledApp], isFetchingInstalledApps: true, }) const { container } = render() const svg = container.querySelector('svg.spin-animation') expect(svg).toBeInTheDocument() }) it('should render app not found (404) when installedApp does not exist', () => { ;(useContext as jest.Mock).mockReturnValue({ installedApps: [], isFetchingInstalledApps: false, }) render() expect(screen.getByText(/404/)).toBeInTheDocument() }) }) describe('Error States', () => { it('should render error when app params fails to load', () => { const error = new Error('Failed to load app params') ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({ isFetching: false, data: null, error, }) render() expect(screen.getByText(/Failed to load app params/)).toBeInTheDocument() }) it('should render error when app meta fails to load', () => { const error = new Error('Failed to load app meta') ;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({ isFetching: false, data: null, error, }) render() expect(screen.getByText(/Failed to load app meta/)).toBeInTheDocument() }) it('should render error when web app access mode fails to load', () => { const error = new Error('Failed to load access mode') ;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({ isFetching: false, data: null, error, }) render() expect(screen.getByText(/Failed to load access mode/)).toBeInTheDocument() }) it('should render error when user access check fails', () => { const error = new Error('Failed to check user access') ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({ data: null, error, }) render() expect(screen.getByText(/Failed to check user access/)).toBeInTheDocument() }) it('should render no permission (403) when user cannot access app', () => { ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({ data: { result: false }, error: null, }) render() expect(screen.getByText(/403/)).toBeInTheDocument() expect(screen.getByText(/no permission/i)).toBeInTheDocument() }) }) describe('App Mode Rendering', () => { it('should render ChatWithHistory for CHAT mode', () => { render() expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument() }) it('should render ChatWithHistory for ADVANCED_CHAT mode', () => { const advancedChatApp = { ...mockInstalledApp, app: { ...mockInstalledApp.app, mode: AppModeEnum.ADVANCED_CHAT, }, } ;(useContext as jest.Mock).mockReturnValue({ installedApps: [advancedChatApp], isFetchingInstalledApps: false, }) render() expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument() }) it('should render ChatWithHistory for AGENT_CHAT mode', () => { const agentChatApp = { ...mockInstalledApp, app: { ...mockInstalledApp.app, mode: AppModeEnum.AGENT_CHAT, }, } ;(useContext as jest.Mock).mockReturnValue({ installedApps: [agentChatApp], isFetchingInstalledApps: false, }) render() expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument() }) it('should render TextGenerationApp for COMPLETION mode', () => { const completionApp = { ...mockInstalledApp, app: { ...mockInstalledApp.app, mode: AppModeEnum.COMPLETION, }, } ;(useContext as jest.Mock).mockReturnValue({ installedApps: [completionApp], isFetchingInstalledApps: false, }) render() expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() expect(screen.getByText(/Text Generation App/)).toBeInTheDocument() expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument() }) it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => { const workflowApp = { ...mockInstalledApp, app: { ...mockInstalledApp.app, mode: AppModeEnum.WORKFLOW, }, } ;(useContext as jest.Mock).mockReturnValue({ installedApps: [workflowApp], isFetchingInstalledApps: false, }) render() expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() expect(screen.getByText(/Workflow/)).toBeInTheDocument() }) }) describe('Props', () => { it('should use id prop to find installed app', () => { const app1 = { ...mockInstalledApp, id: 'app-1' } const app2 = { ...mockInstalledApp, id: 'app-2' } ;(useContext as jest.Mock).mockReturnValue({ installedApps: [app1, app2], isFetchingInstalledApps: false, }) render() expect(screen.getByText(/app-2/)).toBeInTheDocument() }) it('should handle id that does not match any installed app', () => { render() expect(screen.getByText(/404/)).toBeInTheDocument() }) }) describe('Effects', () => { it('should update app info when installedApp is available', async () => { render() await waitFor(() => { expect(mockUpdateAppInfo).toHaveBeenCalledWith( expect.objectContaining({ app_id: 'installed-app-123', site: expect.objectContaining({ title: 'Test App', icon_type: 'emoji', icon: '🚀', icon_background: '#FFFFFF', icon_url: '', prompt_public: false, copyright: '', show_workflow_steps: true, use_icon_as_answer_icon: false, }), plan: 'basic', custom_config: null, }), ) }) }) it('should update app info to null when installedApp is not found', async () => { ;(useContext as jest.Mock).mockReturnValue({ installedApps: [], isFetchingInstalledApps: false, }) render() await waitFor(() => { expect(mockUpdateAppInfo).toHaveBeenCalledWith(null) }) }) it('should update app params when data is available', async () => { render() await waitFor(() => { expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams) }) }) it('should update app meta when data is available', async () => { render() await waitFor(() => { expect(mockUpdateWebAppMeta).toHaveBeenCalledWith(mockAppMeta) }) }) it('should update web app access mode when data is available', async () => { render() await waitFor(() => { expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC) }) }) it('should update user can access app when data is available', async () => { render() await waitFor(() => { expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true) }) }) it('should update user can access app to false when result is false', async () => { ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({ data: { result: false }, error: null, }) render() await waitFor(() => { expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false) }) }) it('should update user can access app to false when data is null', async () => { ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({ data: null, error: null, }) render() await waitFor(() => { expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false) }) }) it('should not update app params when data is null', async () => { ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({ isFetching: false, data: null, error: null, }) render() await waitFor(() => { expect(mockUpdateAppInfo).toHaveBeenCalled() }) expect(mockUpdateAppParams).not.toHaveBeenCalled() }) it('should not update app meta when data is null', async () => { ;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({ isFetching: false, data: null, error: null, }) render() await waitFor(() => { expect(mockUpdateAppInfo).toHaveBeenCalled() }) expect(mockUpdateWebAppMeta).not.toHaveBeenCalled() }) it('should not update access mode when data is null', async () => { ;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({ isFetching: false, data: null, error: null, }) render() await waitFor(() => { expect(mockUpdateAppInfo).toHaveBeenCalled() }) expect(mockUpdateWebAppAccessMode).not.toHaveBeenCalled() }) }) describe('Edge Cases', () => { it('should handle empty installedApps array', () => { ;(useContext as jest.Mock).mockReturnValue({ installedApps: [], isFetchingInstalledApps: false, }) render() expect(screen.getByText(/404/)).toBeInTheDocument() }) it('should handle multiple installed apps and find the correct one', () => { const otherApp = { ...mockInstalledApp, id: 'other-app-id', app: { ...mockInstalledApp.app, name: 'Other App', }, } ;(useContext as jest.Mock).mockReturnValue({ installedApps: [otherApp, mockInstalledApp], isFetchingInstalledApps: false, }) render() // Should find and render the correct app expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() expect(screen.getByText(/installed-app-123/)).toBeInTheDocument() }) it('should apply correct CSS classes to container', () => { const { container } = render() const mainDiv = container.firstChild as HTMLElement expect(mainDiv).toHaveClass('h-full', 'bg-background-default', 'py-2', 'pl-0', 'pr-2', 'sm:p-2') }) it('should apply correct CSS classes to ChatWithHistory', () => { render() const chatComponent = screen.getByTestId('chat-with-history') expect(chatComponent).toHaveClass('overflow-hidden', 'rounded-2xl', 'shadow-md') }) it('should handle rapid id prop changes', async () => { const app1 = { ...mockInstalledApp, id: 'app-1' } const app2 = { ...mockInstalledApp, id: 'app-2' } ;(useContext as jest.Mock).mockReturnValue({ installedApps: [app1, app2], isFetchingInstalledApps: false, }) const { rerender } = render() expect(screen.getByText(/app-1/)).toBeInTheDocument() rerender() expect(screen.getByText(/app-2/)).toBeInTheDocument() }) it('should call service hooks with correct appId', () => { render() expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith('installed-app-123') expect(useGetInstalledAppParams).toHaveBeenCalledWith('installed-app-123') expect(useGetInstalledAppMeta).toHaveBeenCalledWith('installed-app-123') expect(useGetUserCanAccessApp).toHaveBeenCalledWith({ appId: 'app-123', isInstalledApp: true, }) }) it('should call service hooks with null when installedApp is not found', () => { ;(useContext as jest.Mock).mockReturnValue({ installedApps: [], isFetchingInstalledApps: false, }) render() expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith(null) expect(useGetInstalledAppParams).toHaveBeenCalledWith(null) expect(useGetInstalledAppMeta).toHaveBeenCalledWith(null) expect(useGetUserCanAccessApp).toHaveBeenCalledWith({ appId: undefined, isInstalledApp: true, }) }) }) describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { // React.memo wraps the component with a special $$typeof symbol const componentType = (InstalledApp as React.MemoExoticComponent).$$typeof expect(componentType).toBeDefined() }) it('should re-render when props change', () => { const { rerender } = render() expect(screen.getByText(/installed-app-123/)).toBeInTheDocument() // Change to a different app const differentApp = { ...mockInstalledApp, id: 'different-app-456', app: { ...mockInstalledApp.app, name: 'Different App', }, } ;(useContext as jest.Mock).mockReturnValue({ installedApps: [differentApp], isFetchingInstalledApps: false, }) rerender() expect(screen.getByText(/different-app-456/)).toBeInTheDocument() }) it('should maintain component stability across re-renders with same props', () => { const { rerender } = render() const initialCallCount = mockUpdateAppInfo.mock.calls.length // Rerender with same props - useEffect may still run due to dependencies rerender() // Component should render successfully expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() // Mock calls might increase due to useEffect, but component should be stable expect(mockUpdateAppInfo.mock.calls.length).toBeGreaterThanOrEqual(initialCallCount) }) }) describe('Render Priority', () => { it('should show error before loading state', () => { ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({ isFetching: true, data: null, error: new Error('Some error'), }) render() // Error should take precedence over loading expect(screen.getByText(/Some error/)).toBeInTheDocument() }) it('should show error before permission check', () => { ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({ isFetching: false, data: null, error: new Error('Params error'), }) ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({ data: { result: false }, error: null, }) render() // Error should take precedence over permission expect(screen.getByText(/Params error/)).toBeInTheDocument() expect(screen.queryByText(/403/)).not.toBeInTheDocument() }) it('should show permission error before 404', () => { ;(useContext as jest.Mock).mockReturnValue({ installedApps: [], isFetchingInstalledApps: false, }) ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({ data: { result: false }, error: null, }) render() // Permission should take precedence over 404 expect(screen.getByText(/403/)).toBeInTheDocument() expect(screen.queryByText(/404/)).not.toBeInTheDocument() }) it('should show loading before 404', () => { ;(useContext as jest.Mock).mockReturnValue({ installedApps: [], isFetchingInstalledApps: false, }) ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({ isFetching: true, data: null, error: null, }) const { container } = render() // Loading should take precedence over 404 const svg = container.querySelector('svg.spin-animation') expect(svg).toBeInTheDocument() expect(screen.queryByText(/404/)).not.toBeInTheDocument() }) }) })