diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx index 834749cfa1..a59eaab5ff 100644 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx @@ -1283,6 +1283,13 @@ describe('CardDetailsPage', () => { expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Languages: JavaScript') expect(screen.queryByText('Topics:')).not.toBeInTheDocument() }) + + it('handles conditional rendering when languages empty but topics present', () => { + render() + + expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Topics: web') + expect(screen.queryByText('Languages:')).not.toBeInTheDocument() + }) }) describe('Accessibility and Semantic HTML', () => { @@ -1611,6 +1618,25 @@ describe('CardDetailsPage', () => { expect(screen.getByText('Statistics')).toBeInTheDocument() }) + it('handles undefined topics/languages in grid layout', () => { + render() + expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Languages: JavaScript') + cleanup() + render() + expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Topics: web') + }) + + it('renders Leaders with Unknown when value is null', () => { + const propsWithNullLeader = { + ...defaultProps, + type: 'chapter', + details: [{ label: 'Leaders', value: null }], + } + render() + expect(screen.getByText('Leaders:')).toBeInTheDocument() + expect(screen.getByText('Unknown')).toBeInTheDocument() + }) + it('handles mixed valid and invalid data in arrays', () => { const mixedValidInvalidData = { ...defaultProps, @@ -2083,6 +2109,39 @@ describe('CardDetailsPage', () => { expect(screen.getByText('Milestone No Author')).toBeInTheDocument() }) + it('renders milestone author tooltip using login when name is missing', () => { + const milestonesWithAuthorNoName = [ + { + author: { + login: 'author-login-only', + name: undefined, + avatarUrl: 'https://example.com/author-avatar.jpg', + }, + body: 'Milestone with author no name', + closedIssuesCount: 3, + createdAt: new Date(Date.now() - 10000000).toISOString(), + openIssuesCount: 1, + repositoryName: 'test-repo', + organizationName: 'test-org', + state: 'open', + title: 'Milestone Author No Name', + url: 'https://github.com/test/project/milestone/1', + }, + ] + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + recentMilestones: milestonesWithAuthorNoName, + showAvatar: true, + modules: [], + } + + render() + expect(screen.getByTestId('mock-tooltip')).toHaveAttribute('title', 'author-login-only') + expect(screen.getByAltText("author-login-only's avatar")).toBeInTheDocument() + }) + it('renders milestone title without link when URL is missing', () => { const milestonesWithoutUrl = [ { @@ -2274,6 +2333,52 @@ describe('CardDetailsPage', () => { expect(screen.getByTestId('entity-actions')).toHaveTextContent('type=module') }) + it('renders EntityActions for module type when user is a mentor but not admin', () => { + const { useSession } = jest.requireMock('next-auth/react') + useSession.mockReturnValue({ + data: { + user: { + login: 'mentor-user', + name: 'Mentor User', + email: 'mentor@example.com', + }, + }, + }) + + const adminUser = { + id: 'admin-id', + login: 'admin-user', + name: 'Admin User', + avatarUrl: 'https://example.com/admin-avatar.jpg', + } + + const mentorUser = { + id: 'mentor-id', + login: 'mentor-user', + name: 'Mentor User', + avatarUrl: 'https://example.com/mentor-avatar.jpg', + } + + const moduleProps: DetailsCardProps = { + ...defaultProps, + type: 'module' as const, + accessLevel: 'admin', + admins: [adminUser], + mentors: [mentorUser], + programKey: 'test-program', + entityKey: 'test-module', + modules: [], + } + + render() + + const entityActions = screen.getByTestId('entity-actions') + expect(entityActions).toBeInTheDocument() + expect(entityActions).toHaveTextContent('type=module') + // isAdmin should be undefined (user is not in admins list) + expect(entityActions).not.toHaveAttribute('data-isadmin', 'true') + }) + it('does not render EntityActions for module type when user is not an admin', () => { const { useSession } = jest.requireMock('next-auth/react') useSession.mockReturnValue({ diff --git a/frontend/__tests__/unit/components/ChapterMap.test.tsx b/frontend/__tests__/unit/components/ChapterMap.test.tsx index 727bfc4e96..40c88eceb8 100644 --- a/frontend/__tests__/unit/components/ChapterMap.test.tsx +++ b/frontend/__tests__/unit/components/ChapterMap.test.tsx @@ -219,6 +219,30 @@ describe('ChapterMap Refactored Tests', () => { }) }) + it('removes existing zoom control before adding a new one on re-activation', async () => { + const { getByText, container } = render() + + fireEvent.click(getByText('Unlock map').closest('button')) + await waitFor(() => { + expect(L.control.zoom).toHaveBeenCalledTimes(1) + expect(mockZoomControl.addTo).toHaveBeenCalledTimes(1) + }) + + fireEvent.mouseLeave(container.firstChild as HTMLElement) + await waitFor(() => { + expect(mockZoomControl.remove).toHaveBeenCalled() + expect(getByText('Unlock map')).toBeInTheDocument() + }) + + jest.clearAllMocks() + + fireEvent.click(getByText('Unlock map').closest('button')) + await waitFor(() => { + expect(L.control.zoom).toHaveBeenCalledTimes(1) + expect(mockZoomControl.addTo).toHaveBeenCalledTimes(1) + }) + }) + it('locks map again on mouse leave', async () => { const { getByText, container } = render() @@ -266,6 +290,32 @@ describe('ChapterMap Refactored Tests', () => { expect(mockMap.setView).toHaveBeenCalledWith([35.6762, 139.6503], 7) }) }) + + it('handles zero container height (aspect ratio 1)', async () => { + mockMap.getContainer.mockReturnValueOnce({ + clientWidth: 800, + clientHeight: 0, + }) + + render() + await waitFor(() => { + expect(mockMap.setMinZoom).toHaveBeenCalledWith(2) + expect(mockMap.setView).toHaveBeenCalledWith([20, 0], 2) + }) + }) + + it('adjusts minZoom for wide aspect ratio (> 2)', async () => { + mockMap.getContainer.mockReturnValueOnce({ + clientWidth: 1600, + clientHeight: 600, + }) + + render() + await waitFor(() => { + expect(mockMap.setMinZoom).toHaveBeenCalledWith(1) + expect(mockMap.setView).toHaveBeenCalledWith([20, 0], 2) + }) + }) }) describe('Share Location Button', () => { diff --git a/frontend/__tests__/unit/components/EntityActions.test.tsx b/frontend/__tests__/unit/components/EntityActions.test.tsx index 8cb46bf144..9b1b427512 100644 --- a/frontend/__tests__/unit/components/EntityActions.test.tsx +++ b/frontend/__tests__/unit/components/EntityActions.test.tsx @@ -1024,5 +1024,64 @@ describe('EntityActions', () => { ) }) }) + + it('skips cache update when getProgramModules is not in cache', async () => { + const mockCache = { + readQuery: jest.fn().mockReturnValue(null), + writeQuery: jest.fn(), + } + + mockDeleteMutation.mockImplementationOnce(({ update }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (update) update(mockCache as any) + return Promise.resolve({ data: { deleteModule: true } }) + }) + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => { + expect(mockCache.readQuery).toHaveBeenCalled() + expect(mockCache.writeQuery).not.toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program') + }) + }) + + it('handles non-Error thrown values gracefully', async () => { + mockDeleteMutation.mockRejectedValueOnce('string error') + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Error', + color: 'danger', + description: 'Failed to delete module. Please try again.', + }) + ) + }) + }) }) }) diff --git a/frontend/__tests__/unit/components/ModuleForm.test.tsx b/frontend/__tests__/unit/components/ModuleForm.test.tsx index fb21e6f46a..0aa788d2ff 100644 --- a/frontend/__tests__/unit/components/ModuleForm.test.tsx +++ b/frontend/__tests__/unit/components/ModuleForm.test.tsx @@ -79,7 +79,7 @@ jest.mock('@heroui/react', () => ({ @@ -278,11 +278,16 @@ describe('ModuleForm', () => { beforeEach(() => { jest.clearAllMocks() + jest.useFakeTimers() mockQuery.mockResolvedValue({ data: { searchProjects: [{ id: 'project-1', name: 'Test Project' }] }, }) }) + afterEach(() => { + jest.useRealTimers() + }) + const renderModuleForm = (props = {}) => { const defaultProps = { formData: defaultFormData, @@ -396,6 +401,51 @@ describe('ModuleForm', () => { } expect(mockSetFormData).toHaveBeenCalled() }) + + it('updates project field when ProjectSelector changes', async () => { + renderModuleForm() + const input = screen.getByTestId('autocomplete-input') + await act(async () => { + fireEvent.change(input, { target: { value: 'Test' } }) + jest.advanceTimersByTime(350) + }) + await waitFor(() => expect(mockQuery).toHaveBeenCalled()) + const selectButton = screen.getByTestId('autocomplete-select-single') + await act(async () => { + fireEvent.click(selectButton) + }) + expect(mockSetFormData).toHaveBeenCalled() + const setterFn = mockSetFormData.mock.calls[mockSetFormData.mock.calls.length - 1][0] + const result = setterFn(defaultFormData) + expect(result).toEqual( + expect.objectContaining({ + projectId: 'project-1', + projectName: 'Test Project', + }) + ) + }) + + it('updates project field when ProjectSelector is cleared', async () => { + const initialFormData = { + ...defaultFormData, + projectId: 'proj-1', + projectName: 'Existing Project', + } + renderModuleForm({ formData: initialFormData }) + const clearButton = screen.getByTestId('autocomplete-clear') + await act(async () => { + fireEvent.click(clearButton) + }) + expect(mockSetFormData).toHaveBeenCalled() + const setterFn = mockSetFormData.mock.calls[mockSetFormData.mock.calls.length - 1][0] + const result = setterFn(initialFormData) + expect(result).toEqual( + expect.objectContaining({ + projectId: '', + projectName: '', + }) + ) + }) }) describe('Experience Level Select - handleSelectChange (lines 74-84)', () => { @@ -690,7 +740,7 @@ describe('ProjectSelector', () => { expect(mockOnProjectChange).toHaveBeenCalledWith('project-1', 'Test Project 1') }) - it('clears selection when empty set is passed (lines 419-420)', async () => { + it('clears selection when clear button is clicked (lines 411-413)', async () => { renderProjectSelector({ value: 'project-1', defaultName: 'Existing Project' }) const clearButton = screen.getByTestId('autocomplete-clear') @@ -698,9 +748,8 @@ describe('ProjectSelector', () => { fireEvent.click(clearButton) }) - // The component's handleSelectionChange doesn't handle empty Set directly - // This test verifies the current behavior where it's not called - expect(mockOnProjectChange).not.toHaveBeenCalled() + // Now expected to call onProjectChange with null + expect(mockOnProjectChange).toHaveBeenCalledWith(null, '') }) }) @@ -778,6 +827,24 @@ describe('ProjectSelector', () => { consoleErrorSpy.mockRestore() }) + + it('handles missing searchProjects in response', async () => { + mockQuery.mockResolvedValue({ data: {} }) + + renderProjectSelector() + const input = screen.getByTestId('autocomplete-input') + + await act(async () => { + fireEvent.change(input, { target: { value: 'Test' } }) + jest.advanceTimersByTime(350) + }) + + await waitFor(() => { + expect(mockQuery).toHaveBeenCalled() + }) + const items = screen.queryAllByTestId('autocomplete-item') + expect(items).toHaveLength(0) + }) }) describe('Validation Display', () => { diff --git a/frontend/__tests__/unit/contexts/BreadcrumbContext.test.tsx b/frontend/__tests__/unit/contexts/BreadcrumbContext.test.tsx index 9773f61293..edf567a540 100644 --- a/frontend/__tests__/unit/contexts/BreadcrumbContext.test.tsx +++ b/frontend/__tests__/unit/contexts/BreadcrumbContext.test.tsx @@ -1,4 +1,8 @@ -import { registerBreadcrumb, getBreadcrumbItems } from 'contexts/BreadcrumbContext' +import { + registerBreadcrumb, + getBreadcrumbItems, + registerBreadcrumbClassName, +} from 'contexts/BreadcrumbContext' describe('BreadcrumbContext', () => { let cleanupFns: (() => void)[] = [] @@ -46,4 +50,16 @@ describe('BreadcrumbContext', () => { expect(items[0].path).toBe('/short') expect(items[1].path).toBe('/very/long/path') }) + + it('registers class name', () => { + const unregister = registerBreadcrumbClassName('test') + cleanupFns.push(unregister) + expect(unregister).toBeDefined() + }) + + it('handles unregistering already removed class name', () => { + const unregister = registerBreadcrumbClassName('double-remove') + unregister() + expect(() => unregister()).not.toThrow() + }) }) diff --git a/frontend/__tests__/unit/pages/EditModule.test.tsx b/frontend/__tests__/unit/pages/EditModule.test.tsx index adb2893b7c..5b4fb2de92 100644 --- a/frontend/__tests__/unit/pages/EditModule.test.tsx +++ b/frontend/__tests__/unit/pages/EditModule.test.tsx @@ -34,6 +34,11 @@ jest.mock('components/forms/shared/formValidationUtils', () => ({ validateEndDate: jest.fn(), })) +jest.mock('app/global-error', () => ({ + ErrorDisplay: ({ title }: { title: string }) =>
{title}
, + handleAppError: jest.fn(), +})) + describe('EditModulePage', () => { const mockPush = jest.fn() const mockReplace = jest.fn() @@ -156,6 +161,7 @@ describe('EditModulePage', () => { timeout: 4000, }) }) + expect(await screen.findByText('Access Denied')).toBeInTheDocument() // Advance timers to trigger the redirect act(() => { @@ -195,11 +201,11 @@ describe('EditModulePage', () => { render() }) - // When denied but formData is null, component shows spinner - expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0) + // When denied, component shows ErrorDisplay + expect(await screen.findByText('Access Denied')).toBeInTheDocument() }) - it('shows loading spinner when user is unauthenticated', async () => { + it('shows Access Denied when user is unauthenticated', async () => { ;(useSession as jest.Mock).mockReturnValue({ data: null, status: 'unauthenticated', @@ -217,8 +223,8 @@ describe('EditModulePage', () => { render() }) - // When denied but formData is null, component shows spinner - expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0) + // When denied, component shows ErrorDisplay + expect(await screen.findByText('Access Denied')).toBeInTheDocument() }) it('handles form submission error gracefully', async () => { @@ -269,6 +275,63 @@ describe('EditModulePage', () => { }) }) + it('shows permission denied error when mutation throws Permission error', async () => { + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'admin-user' } }, + status: 'authenticated', + }) + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + loading: false, + data: { + getProgram: { + admins: [{ login: 'admin-user' }], + }, + getModule: { + name: 'Test Module', + description: 'Description', + experienceLevel: ExperienceLevelEnum.Intermediate, + startedAt: '2025-07-01', + endedAt: '2025-07-31', + domains: ['AI'], + tags: ['graphql'], + projectName: 'Awesome Project', + projectId: '123', + mentors: [{ login: 'mentor1' }], + labels: [], + }, + }, + }) + ;(useMutation as unknown as jest.Mock).mockReturnValue([ + mockUpdateModule.mockRejectedValue( + new Error('Permission denied: You do not have permission to edit this module') + ), + { loading: false }, + ]) + + render() + + await act(async () => { + jest.runAllTimers() + }) + + expect(await screen.findByDisplayValue('Test Module')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Save/i })) + }) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Error', + description: + 'You do not have permission to edit this module. Only program admins and assigned mentors can edit modules.', + color: 'danger', + }) + ) + }) + }) + it('renders form with module having missing optional fields', async () => { ;(useSession as jest.Mock).mockReturnValue({ data: { user: { login: 'admin-user' } }, diff --git a/frontend/__tests__/unit/pages/EditProgram.test.tsx b/frontend/__tests__/unit/pages/EditProgram.test.tsx index 44cb4fa670..810b37ce2c 100644 --- a/frontend/__tests__/unit/pages/EditProgram.test.tsx +++ b/frontend/__tests__/unit/pages/EditProgram.test.tsx @@ -432,4 +432,48 @@ describe('EditProgramPage', () => { ) }) }) + + test('handles null admins when access is initially allowed', async () => { + const validData = { + getProgram: { + name: 'Test', + description: 'Test description', + menteesLimit: 10, + startedAt: '2025-01-01', + endedAt: '2025-12-31', + tags: ['react'], + domains: ['web'], + admins: [{ login: 'admin1' }], + status: ProgramStatusEnum.Draft, + }, + } + + const nullAdminsData = { + getProgram: { + ...validData.getProgram, + admins: null, + }, + } + + let currentData = validData + + jest.clearAllMocks() + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'admin1' } }, + status: 'authenticated', + }) + ;(useQuery as unknown as jest.Mock).mockImplementation(() => ({ + loading: false, + data: currentData, + })) + const { rerender } = render() + await waitFor(async () => { + expect(await screen.findByLabelText('Name')).toBeInTheDocument() + }) + currentData = nullAdminsData + rerender() + await waitFor(async () => { + expect(await screen.findByText('Access Denied')).toBeInTheDocument() + }) + }) }) diff --git a/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx b/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx index 712e528cc7..d4e046f3a1 100644 --- a/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx +++ b/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx @@ -541,4 +541,42 @@ describe('MenteeProfilePage', () => { expect(screen.getAllByText('Open Issue 1').length).toBeGreaterThan(0) expect(screen.getAllByText('Closed Issue 1').length).toBeGreaterThan(0) }) + + it('uses fallback for invalid status filter', () => { + const originalUseState = React.useState + jest.spyOn(React, 'useState').mockImplementation((initialValue) => { + if (initialValue === 'all') { + return ['invalid_filter', jest.fn()] + } + return originalUseState(initialValue) + }) + + try { + mockUseQuery.mockReturnValue({ data: mockMenteeData, loading: false, error: undefined }) + render() + + expect(screen.getAllByText('Open Issue 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Closed Issue 1').length).toBeGreaterThan(0) + } finally { + jest.restoreAllMocks() + } + }) + + it('handles undefined domains and tags', () => { + const dataWithUndefined = { + ...mockMenteeData, + getMenteeDetails: { + ...mockMenteeData.getMenteeDetails, + domains: undefined, + tags: undefined, + }, + } + mockUseQuery.mockReturnValue({ data: dataWithUndefined, loading: false, error: undefined }) + render() + + expect(screen.queryByRole('heading', { name: /Domains/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: /Skills & Technologies/i }) + ).not.toBeInTheDocument() + }) }) diff --git a/frontend/__tests__/unit/pages/ModuleIssueDetailsPage.test.tsx b/frontend/__tests__/unit/pages/ModuleIssueDetailsPage.test.tsx index abbac2f87e..f9eabd0a37 100644 --- a/frontend/__tests__/unit/pages/ModuleIssueDetailsPage.test.tsx +++ b/frontend/__tests__/unit/pages/ModuleIssueDetailsPage.test.tsx @@ -651,4 +651,55 @@ describe('ModuleIssueDetailsPage', () => { fireEvent.change(dateInputEl, { target: { value: '2025-02-02' } }) expect(setTaskDeadlineMutation).not.toHaveBeenCalled() }) + + it('does not trigger mutations when issueId is missing', () => { + mockUseParams.mockReturnValue({ + programKey: 'prog1', + moduleKey: 'mod1', + issueId: '', + }) + const assignIssue = jest.fn() + const baseMocks = (useIssueMutations as jest.Mock)() + + mockUseIssueMutations.mockReturnValue({ + ...baseMocks, + assignIssue, + assigning: false, + }) + + mockUseQuery.mockReturnValue({ data: mockIssueData, loading: false, error: undefined }) + render() + + const interestedUsersHeading = screen.getByRole('heading', { name: /Interested Users/i }) + const userGrid = interestedUsersHeading.nextElementSibling + const assignButton = within(userGrid as HTMLElement).getByRole('button', { name: /Assign/i }) + fireEvent.click(assignButton) + expect(assignIssue).not.toHaveBeenCalled() + }) + + it('does not open date picker when canEditDeadline is false', () => { + const setIsEditingDeadline = jest.fn() + const baseMocks = (useIssueMutations as jest.Mock)() + mockUseIssueMutations.mockReturnValue({ + ...baseMocks, + setIsEditingDeadline, + }) + + const noAssigneesData = { + ...mockIssueData, + getModule: { + ...mockIssueData.getModule, + issueByNumber: { + ...mockIssueData.getModule.issueByNumber, + assignees: [], + }, + }, + } + mockUseQuery.mockReturnValue({ data: noAssigneesData, loading: false, error: undefined }) + render() + + const deadlineButton = screen.getByRole('button', { name: /No deadline set/i }) + fireEvent.click(deadlineButton) + expect(setIsEditingDeadline).not.toHaveBeenCalled() + }) }) diff --git a/frontend/__tests__/unit/pages/MyMentorship.test.tsx b/frontend/__tests__/unit/pages/MyMentorship.test.tsx index d759fc4af6..cee95224f4 100644 --- a/frontend/__tests__/unit/pages/MyMentorship.test.tsx +++ b/frontend/__tests__/unit/pages/MyMentorship.test.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@apollo/client/react' import { addToast } from '@heroui/toast' import { screen, waitFor, fireEvent } from '@testing-library/react' -import { useRouter as useRouterMock } from 'next/navigation' +import { useRouter as useRouterMock, useSearchParams } from 'next/navigation' import { useSession as mockUseSession } from 'next-auth/react' import React from 'react' import { render } from 'wrappers/testUtil' @@ -52,7 +52,7 @@ jest.mock('next/navigation', () => { return { ...actual, useRouter: jest.fn(), - useSearchParams: () => new URLSearchParams(''), + useSearchParams: jest.fn(), } }) @@ -74,6 +74,7 @@ const mockAddToast = addToast as jest.Mock beforeEach(() => { jest.clearAllMocks() ;(useRouterMock as jest.Mock).mockReturnValue({ push: mockPush }) + ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams('')) }) const mockProgramData = { @@ -418,4 +419,46 @@ describe('MyMentorshipPage', () => { jest.useRealTimers() } }) + + it('does not update URL if search params are unchanged', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'User', + email: 'user@example.com', + login: 'user', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + }) + + mockUseQuery.mockReturnValue({ + data: mockProgramData, + loading: false, + error: undefined, + }) + ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams('q=existing')) + globalThis.history.replaceState(null, '', '?q=existing') + + jest.useFakeTimers() + try { + render() + + const searchInput = screen.getByTestId('search-input') + fireEvent.change(searchInput, { target: { value: 'existing' } }) + + React.act(() => { + jest.advanceTimersByTime(500) + }) + + await waitFor(() => { + expect(mockPush).not.toHaveBeenCalled() + }) + } finally { + globalThis.history.replaceState(null, '', '/') + jest.useRealTimers() + } + }) }) diff --git a/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx b/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx index 1a20809d93..fd058b7957 100644 --- a/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx +++ b/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx @@ -392,12 +392,40 @@ describe('SnapshotDetailsPage', () => { }, }, error: null, + loading: false, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('v0.9.2')).toBeInTheDocument() + }) + }) + + test('renders release without id and repositoryName (uses unknown fallback)', async () => { + const releaseWithoutIdAndRepo = { + ...mockSnapshotDetailsData.snapshot.newReleases[0], + id: undefined, + repositoryName: undefined, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: { + snapshot: { + ...mockSnapshotDetailsData.snapshot, + newReleases: [releaseWithoutIdAndRepo], + newChapters: [], + newProjects: [], + }, + }, + error: null, + loading: false, }) render() await waitFor(() => { expect(screen.getByText('v0.9.2')).toBeInTheDocument() + expect(screen.getByText('Unknown repository')).toBeInTheDocument() }) }) }) diff --git a/frontend/__tests__/unit/pages/UserDetails.test.tsx b/frontend/__tests__/unit/pages/UserDetails.test.tsx index d9a512d963..154dbd1f77 100644 --- a/frontend/__tests__/unit/pages/UserDetails.test.tsx +++ b/frontend/__tests__/unit/pages/UserDetails.test.tsx @@ -1104,5 +1104,96 @@ describe('UserDetailsPage', () => { expect(screen.getByText('No Repositories')).toBeInTheDocument() }) }) + test('renders joined date as "Not available" when createdAt is missing', async () => { + const userWithoutCreatedAt = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + createdAt: null, + }, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: userWithoutCreatedAt, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Joined:')).toBeInTheDocument() + expect(screen.getByText('Not available')).toBeInTheDocument() + }) + }) + + test('validates defensive check for endDate', async () => { + const singleDateContribution = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + contributionData: { '2023-01-01': 5 }, + }, + } + + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: singleDateContribution, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('contribution-heatmap')).toBeInTheDocument() + }) + }) + + test('uses user login as fallback for avatar alt text and title when name is missing', async () => { + const userWithoutName = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + name: null, + login: 'fallback-login', + }, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: userWithoutName, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + const avatar = screen.getByAltText('fallback-login') + expect(avatar).toBeInTheDocument() + expect(screen.getByText('@fallback-login')).toBeInTheDocument() + }) + }) + + test('uses "User Avatar" fallback when both name and login are missing', async () => { + const userWithoutNameAndLogin = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + name: null, + login: null, + }, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: userWithoutNameAndLogin, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + const avatar = screen.getByAltText('User Avatar') + expect(avatar).toBeInTheDocument() + expect(screen.queryByText('@null')).not.toBeInTheDocument() + }) + }) }) }) diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx index 5f02f14e1d..f11481e564 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx @@ -118,7 +118,7 @@ const EditProgramPage = () => { menteesLimit: Number(formData.menteesLimit), name: formData.name, startedAt: formData.startedAt, - status: (formData.status as ProgramStatusEnum) || ProgramStatusEnum.Draft, + status: (formData.status ?? ProgramStatusEnum.Draft) as ProgramStatusEnum, tags: parseCommaSeparated(formData.tags), } diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx index 4767957ce4..be32fcf87e 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx @@ -101,7 +101,6 @@ const EditModulePage = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!formData) return try { const currentUserLogin = sessionData?.user?.login @@ -110,22 +109,22 @@ const EditModulePage = () => { ) const input: UpdateModuleInput = { - description: formData.description, - domains: parseCommaSeparated(formData.domains), - endedAt: formData.endedAt || '', - experienceLevel: formData.experienceLevel as ExperienceLevelEnum, + description: formData!.description, + domains: parseCommaSeparated(formData!.domains), + endedAt: formData!.endedAt || '', + experienceLevel: formData!.experienceLevel as ExperienceLevelEnum, key: moduleKey, - labels: parseCommaSeparated(formData.labels), - name: formData.name, + labels: parseCommaSeparated(formData!.labels), + name: formData!.name, programKey: programKey, - projectId: formData.projectId, - projectName: formData.projectName, - startedAt: formData.startedAt || '', - tags: parseCommaSeparated(formData.tags), + projectId: formData!.projectId, + projectName: formData!.projectName, + startedAt: formData!.startedAt || '', + tags: parseCommaSeparated(formData!.tags), } if (isAdmin) { - input.mentorLogins = parseCommaSeparated(formData.mentorLogins) + input.mentorLogins = parseCommaSeparated(formData!.mentorLogins) } const result = await updateModule({ @@ -163,10 +162,6 @@ const EditModulePage = () => { } } - if (accessStatus === 'checking' || !formData) { - return - } - if (accessStatus === 'denied') { return ( { ) } + if (accessStatus === 'checking' || !formData) { + return + } + return ( { type="button" disabled={!canEditDeadline} onClick={() => { - if (canEditDeadline) { - setDeadlineInput( - taskDeadline ? new Date(taskDeadline).toISOString().slice(0, 10) : '' - ) - setIsEditingDeadline(true) - } + setDeadlineInput( + taskDeadline ? new Date(taskDeadline).toISOString().slice(0, 10) : '' + ) + setIsEditingDeadline(true) }} className={`inline-flex items-center gap-2 rounded px-2 py-1 text-left transition-colors ${ canEditDeadline @@ -299,7 +297,6 @@ const ModuleIssueDetailsPage = () => { aria-label={`Unassign @${a.login}`} disabled={!issueId || unassigning} onClick={async () => { - if (!issueId || unassigning) return await unassignIssue({ variables: { programKey, @@ -367,7 +364,6 @@ const ModuleIssueDetailsPage = () => { type="button" disabled={!issueId || assigning} onClick={async () => { - if (!issueId || assigning) return await assignIssue({ variables: { programKey, diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx index e36d5a4187..774d8b018b 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx @@ -116,7 +116,7 @@ const IssuesPage = () => { selectedKeys={new Set([selectedLabel])} onSelectionChange={(keys) => { const [key] = Array.from(keys as Set) - if (key) handleLabelChange(key) + if (key !== undefined) handleLabelChange(key) }} > {[LABEL_ALL, ...allLabels].map((l) => ( diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx index 16fc425c56..d9a8fbaa17 100644 --- a/frontend/src/components/CardDetailsPage.tsx +++ b/frontend/src/components/CardDetailsPage.tsx @@ -216,7 +216,7 @@ const DetailsCard = ({ ) )} {socialLinks && (type === 'chapter' || type === 'committee') && ( - + )} {showStatistics(type) && stats && ( @@ -464,12 +464,7 @@ const DetailsCard = ({ height={24} width={24} src={milestone?.author?.avatarUrl} - alt={ - milestone.author && - (milestone.author.name || milestone.author.login) - ? `${milestone.author.name || milestone.author.login}'s avatar` - : "Author's avatar" - } + alt={`${milestone.author?.name || milestone.author?.login}'s avatar`} className="mr-2 rounded-full" /> @@ -549,7 +544,7 @@ const DetailsCard = ({ export default DetailsCard -export const SocialLinks = ({ urls }: { urls: string[] }) => { +const SocialLinks = ({ urls }: { urls: string[] }) => { if (!urls || urls.length === 0) return null return (
diff --git a/frontend/src/components/ChapterMap.tsx b/frontend/src/components/ChapterMap.tsx index c07b452a67..8bba7658f6 100644 --- a/frontend/src/components/ChapterMap.tsx +++ b/frontend/src/components/ChapterMap.tsx @@ -26,10 +26,9 @@ const MapZoomControl = ({ isMapActive }: { isMapActive: boolean }) => { map.doubleClickZoom.enable() map.keyboard.enable() - if (!zoomControlRef.current) { - zoomControlRef.current = L.control.zoom({ position: 'topleft' }) - zoomControlRef.current.addTo(map) - } + zoomControlRef.current?.remove() + zoomControlRef.current = L.control.zoom({ position: 'topleft' }) + zoomControlRef.current.addTo(map) } else { map.scrollWheelZoom.disable() map.dragging.disable() diff --git a/frontend/src/components/ModuleForm.tsx b/frontend/src/components/ModuleForm.tsx index c95642bf1a..f2b62cf6ee 100644 --- a/frontend/src/components/ModuleForm.tsx +++ b/frontend/src/components/ModuleForm.tsx @@ -399,7 +399,6 @@ export const ProjectSelector = ({ }, [inputValue, fetchSuggestions]) const handleSelectionChange = (keys: React.Key | null) => { - if (keys === null) return const selectedKey = keys as string if (selectedKey) { const selectedProject = items.find((item) => item.id === selectedKey) diff --git a/frontend/src/components/ProgramForm.tsx b/frontend/src/components/ProgramForm.tsx index 57429c6155..c17397133f 100644 --- a/frontend/src/components/ProgramForm.tsx +++ b/frontend/src/components/ProgramForm.tsx @@ -112,10 +112,6 @@ const ProgramForm = ({ const checkNameUniquenessSync = useCallback( async (name: string): Promise => { - if (!name.trim()) { - return undefined - } - try { const { data } = await client.query({ query: GetMyProgramsDocument,