diff --git a/frontend/__tests__/unit/components/EntityActions.test.tsx b/frontend/__tests__/unit/components/EntityActions.test.tsx new file mode 100644 index 0000000000..dac23e12be --- /dev/null +++ b/frontend/__tests__/unit/components/EntityActions.test.tsx @@ -0,0 +1,487 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { useRouter } from 'next/navigation' +import { ProgramStatusEnum } from 'types/__generated__/graphql' +import EntityActions from 'components/EntityActions' + +// Mock next/navigation +const mockPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})) + +describe('EntityActions', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + }) + }) + + describe('Program Actions - Create Module', () => { + it('navigates to create module page when Add Module is clicked', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const addModuleButton = screen.getByText('Add Module') + fireEvent.click(addModuleButton) + + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program/modules/create') + }) + + it('closes dropdown after clicking Add Module', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const addModuleButton = screen.getByText('Add Module') + fireEvent.click(addModuleButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Module Actions - Edit Module', () => { + it('navigates to edit module page when Edit is clicked with moduleKey', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + + const editButton = screen.getByText('Edit') + fireEvent.click(editButton) + + expect(mockPush).toHaveBeenCalledWith( + '/my/mentorship/programs/test-program/modules/test-module/edit' + ) + }) + + it('does not navigate when moduleKey is missing for edit action', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + + const editButton = screen.getByText('Edit') + fireEvent.click(editButton) + + expect(mockPush).not.toHaveBeenCalled() + }) + + it('closes dropdown after clicking Edit', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const editButton = screen.getByText('Edit') + fireEvent.click(editButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Module Actions - View Issues', () => { + it('navigates to view issues page when View Issues is clicked with moduleKey', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + + const viewIssuesButton = screen.getByText('View Issues') + fireEvent.click(viewIssuesButton) + + expect(mockPush).toHaveBeenCalledWith( + '/my/mentorship/programs/test-program/modules/test-module/issues' + ) + }) + + it('does not navigate when moduleKey is missing for view issues action', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + + const viewIssuesButton = screen.getByText('View Issues') + fireEvent.click(viewIssuesButton) + + expect(mockPush).not.toHaveBeenCalled() + }) + + it('closes dropdown after clicking View Issues', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const viewIssuesButton = screen.getByText('View Issues') + fireEvent.click(viewIssuesButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Program Status Changes - Publish', () => { + it('calls setStatus with PUBLISHED when Publish is clicked', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const publishButton = screen.getByText('Publish') + fireEvent.click(publishButton) + + expect(mockSetStatus).toHaveBeenCalledWith(ProgramStatusEnum.Published) + }) + + it('shows Publish option only when status is DRAFT', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.getByText('Publish')).toBeInTheDocument() + }) + + it('does not show Publish option when status is PUBLISHED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.queryByText('Publish')).not.toBeInTheDocument() + }) + + it('closes dropdown after clicking Publish', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const publishButton = screen.getByText('Publish') + fireEvent.click(publishButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Program Status Changes - Draft (Unpublish)', () => { + it('calls setStatus with DRAFT when Unpublish is clicked from PUBLISHED', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const unpublishButton = screen.getByText('Unpublish') + fireEvent.click(unpublishButton) + + expect(mockSetStatus).toHaveBeenCalledWith(ProgramStatusEnum.Draft) + }) + + it('calls setStatus with DRAFT when Unpublish is clicked from COMPLETED', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const unpublishButton = screen.getByText('Unpublish') + fireEvent.click(unpublishButton) + + expect(mockSetStatus).toHaveBeenCalledWith(ProgramStatusEnum.Draft) + }) + + it('shows Unpublish option when status is PUBLISHED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.getByText('Unpublish')).toBeInTheDocument() + }) + + it('shows Unpublish option when status is COMPLETED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.getByText('Unpublish')).toBeInTheDocument() + }) + + it('does not show Unpublish option when status is DRAFT', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.queryByText('Unpublish')).not.toBeInTheDocument() + }) + + it('closes dropdown after clicking Unpublish', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const unpublishButton = screen.getByText('Unpublish') + fireEvent.click(unpublishButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Program Status Changes - Completed', () => { + it('calls setStatus with COMPLETED when Mark as Completed is clicked', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const completedButton = screen.getByText('Mark as Completed') + fireEvent.click(completedButton) + + expect(mockSetStatus).toHaveBeenCalledWith(ProgramStatusEnum.Completed) + }) + + it('shows Mark as Completed option only when status is PUBLISHED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.getByText('Mark as Completed')).toBeInTheDocument() + }) + + it('does not show Mark as Completed when status is DRAFT', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.queryByText('Mark as Completed')).not.toBeInTheDocument() + }) + + it('does not show Mark as Completed when status is COMPLETED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.queryByText('Mark as Completed')).not.toBeInTheDocument() + }) + + it('closes dropdown after clicking Mark as Completed', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const completedButton = screen.getByText('Mark as Completed') + fireEvent.click(completedButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Click Outside Behavior', () => { + it('closes dropdown when clicking outside', async () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + fireEvent.mouseDown(document.body) + + await waitFor(() => { + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + it('does not close dropdown when clicking inside', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + fireEvent.mouseDown(button) + + expect(button).toHaveAttribute('aria-expanded', 'true') + }) + + it('cleans up event listener on unmount', () => { + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener') + const { unmount } = render() + + unmount() + + expect(removeEventListenerSpy.mock.calls).toEqual( + expect.arrayContaining([['mousedown', expect.any(Function)]]) + ) + removeEventListenerSpy.mockRestore() + }) + }) + + describe('Edge Cases and Error Handling', () => { + it('handles undefined setStatus gracefully when clicking Publish', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const publishButton = screen.getByText('Publish') + // Should not throw error even without setStatus + expect(() => fireEvent.click(publishButton)).not.toThrow() + }) + + it('handles undefined setStatus gracefully when clicking Unpublish', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const unpublishButton = screen.getByText('Unpublish') + expect(() => fireEvent.click(unpublishButton)).not.toThrow() + }) + + it('handles undefined setStatus gracefully when clicking Mark as Completed', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const completedButton = screen.getByText('Mark as Completed') + expect(() => fireEvent.click(completedButton)).not.toThrow() + }) + + it('handles undefined status gracefully', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + // Should still show Edit and Add Module + expect(screen.getByText('Edit')).toBeInTheDocument() + expect(screen.getByText('Add Module')).toBeInTheDocument() + }) + }) + + describe('Event Propagation', () => { + it('prevents event propagation on button click', () => { + const mockParentClick = jest.fn() + render( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ +
+ ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(mockParentClick).not.toHaveBeenCalled() + }) + + it('prevents event propagation on menu item click', () => { + const mockParentClick = jest.fn() + render( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ +
+ ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const editButton = screen.getByText('Edit') + fireEvent.click(editButton) + + expect(mockParentClick).not.toHaveBeenCalled() + }) + }) +})