diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index 55203cb7e1..fa8607b9de 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -332,58 +332,62 @@ describe('CalendarButton', () => { }) }) - describe('long title overflow handling', () => { - it('remains accessible with very long event titles', () => { - const longTitle = - 'This Is A Very Long Event Title That Extends Beyond Normal Length With Additional Description' - render( - - ) + describe('hover state', () => { + it('toggles icon on hover - shows FaCalendarPlus when hovering', async () => { + render() const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - expect(button).toHaveAttribute('aria-label', `Add ${longTitle} to Calendar`) + + // Initially should show FaCalendar (not hovered) + const initialIconMarkup = button.querySelector('svg')?.outerHTML + + // Simulate mouse enter + fireEvent.mouseEnter(button) + await waitFor(() => { + // After hover, FaCalendarPlus should be shown (different SVG) + const hoveredIconMarkup = button.querySelector('svg')?.outerHTML + expect(hoveredIconMarkup).not.toBe(initialIconMarkup) + }) }) - it('maintains visibility with flex-shrink-0 class', () => { - render( - - ) + it('reverts to FaCalendar icon when mouse leaves', async () => { + render() const button = screen.getByRole('button') - expect(button).toHaveClass('flex-shrink-0') - expect(button).toBeVisible() + + // Capture initial icon (FaCalendar) + const initialIconHtml = button.innerHTML + + // Mouse enter - hover state true + fireEvent.mouseEnter(button) + + await waitFor(() => { + // Icon should change to FaCalendarPlus + expect(button.innerHTML).not.toEqual(initialIconHtml) + }) + + // Mouse leave - hover state false + fireEvent.mouseLeave(button) + + await waitFor(() => { + // Icon should revert to FaCalendar + expect(button.innerHTML).toEqual(initialIconHtml) + }) }) - it('works correctly in flex container with long text sibling', () => { - const { container } = render( -
- - -
- ) - const button = container.querySelector('button[aria-label="Add Event to Calendar"]') - expect(button).toBeInTheDocument() - expect(button).toHaveClass('flex-shrink-0') + it('maintains button functionality during hover state transitions', async () => { + render() + const button = screen.getByRole('button') + + fireEvent.mouseEnter(button) + fireEvent.mouseLeave(button) + + expect(button).not.toBeDisabled() + + fireEvent.click(button) + expect(button).toBeDisabled() + + await waitFor(() => { + expect(button).not.toBeDisabled() + }) }) }) }) diff --git a/frontend/__tests__/unit/components/Card.test.tsx b/frontend/__tests__/unit/components/Card.test.tsx index d527a4bed9..89a2ba8455 100644 --- a/frontend/__tests__/unit/components/Card.test.tsx +++ b/frontend/__tests__/unit/components/Card.test.tsx @@ -647,4 +647,87 @@ describe('Card', () => { expect(screen.queryByTestId('label-more')).not.toBeInTheDocument() expect(screen.getAllByTestId('label')).toHaveLength(5) }) + + describe('timeline rendering', () => { + it('renders timeline when both start and end dates are provided', () => { + const propsWithTimeline = { + ...baseProps, + timeline: { + start: '2024-01-01', + end: '2024-12-31', + }, + } + render() + expect(screen.getByTestId('calendar-icon')).toBeInTheDocument() + }) + + it('does not render timeline when start date is empty string', () => { + const propsWithEmptyStart = { + ...baseProps, + timeline: { + start: '', + end: '2024-12-31', + }, + } + render() + expect(screen.queryByTestId('calendar-icon')).not.toBeInTheDocument() + }) + + it('does not render timeline when end date is empty string', () => { + const propsWithEmptyEnd = { + ...baseProps, + timeline: { + start: '2024-01-01', + end: '', + }, + } + render() + expect(screen.queryByTestId('calendar-icon')).not.toBeInTheDocument() + }) + + it('does not render timeline when timeline is undefined', () => { + render() + expect(screen.queryByTestId('calendar-icon')).not.toBeInTheDocument() + }) + }) + + describe('social media aria-label fallback', () => { + it('uses item title as aria-label when provided', () => { + const propsWithSocialTitle = { + ...baseProps, + social: [ + { title: 'GitHub Profile', url: 'https://github.com/test', icon: MockIcon as IconType }, + ], + } + render() + const socialLink = screen.getByRole('link', { name: 'GitHub Profile' }) + expect(socialLink).toHaveAttribute('aria-label', 'GitHub Profile') + }) + + it('uses fallback aria-label when item title is empty', () => { + const propsWithEmptySocialTitle = { + ...baseProps, + social: [{ title: '', url: 'https://github.com/test', icon: MockIcon as IconType }], + } + render() + const socialLink = screen.getByRole('link', { name: 'Social media link' }) + expect(socialLink).toHaveAttribute('aria-label', 'Social media link') + }) + + it('uses fallback aria-label when item title is undefined', () => { + const propsWithUndefinedSocialTitle = { + ...baseProps, + social: [ + { + title: undefined as unknown as string, + url: 'https://github.com/test', + icon: MockIcon as IconType, + }, + ], + } + render() + const socialLink = screen.getByRole('link', { name: 'Social media link' }) + expect(socialLink).toHaveAttribute('aria-label', 'Social media link') + }) + }) }) diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx index 8b11320acb..4eaecf58fe 100644 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx @@ -43,6 +43,33 @@ jest.mock('utils/env.client', () => ({ IS_PROJECT_HEALTH_ENABLED: true, })) +jest.mock('next-auth/react', () => { + return { + useSession: jest.fn(() => ({ + data: null, + status: 'unauthenticated', + })), + SessionProvider: ({ children }: { children: React.ReactNode }) => children, + } +}) + +jest.mock('utils/scrollToAnchor', () => ({ + scrollToAnchor: jest.fn(), +})) + +jest.mock('utils/dateFormatter', () => ({ + formatDate: (date: string | number) => { + if (typeof date === 'string') return date + return new Date(date).toISOString().split('T')[0] + }, +})) + +jest.mock('utils/urlFormatter', () => ({ + getMemberUrl: (login: string) => `/members/${login}`, + getMenteeUrl: (programKey: string, entityKey: string, login: string) => + `/programs/${programKey}/mentees/${login}`, +})) + jest.mock('utils/urlIconMappings', () => ({ getSocialIcon: (url: string) => { const safe = encodeURIComponent(url) @@ -355,12 +382,14 @@ jest.mock('components/ToggleableList', () => ({ icon: _icon, label, entityKey: _entityKey, + isDisabled: _isDisabled, ...props }: { items: string[] _icon: unknown label: React.ReactNode entityKey: string + isDisabled?: boolean [key: string]: unknown }) => (
@@ -394,6 +423,136 @@ jest.mock('components/ContributorsList', () => ({ ), })) +jest.mock('components/EntityActions', () => ({ + __esModule: true, + default: ({ + type, + programKey, + moduleKey, + status: _status, + setStatus: _setStatus, + ...props + }: { + type: string + programKey?: string + moduleKey?: string + status?: string + setStatus?: (status: string) => void + [key: string]: unknown + }) => ( +
+ EntityActions: type={type}, programKey={programKey}, moduleKey={moduleKey} +
+ ), +})) + +jest.mock('components/Leaders', () => { + return { + __esModule: true, + default: ({ users, ...props }: { users: unknown[]; [key: string]: unknown }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const usersList = users as any[] + return ( +
+

Leaders

+ {Array.isArray(usersList) && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + usersList.map((user: any, index: number) => { + const uniqueKey = `leader-${index}-${user.login || 'unknown'}` + return ( +
+
{user.member?.name || user.memberName || 'Unknown'}
+
{user.description || ''}
+
+ ) + })} +
+ ) + }, + } +}) + +jest.mock('components/StatusBadge', () => ({ + __esModule: true, + default: ({ + status, + _size, + ...props + }: { + status: string + _size?: string + [key: string]: unknown + }) => ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ), +})) + +jest.mock('components/MarkdownWrapper', () => ({ + __esModule: true, + default: ({ content, ...props }: { content: string; [key: string]: unknown }) => ( +
+ {content} +
+ ), +})) + +jest.mock('components/ModuleCard', () => ({ + __esModule: true, + default: ({ + modules, + accessLevel: _accessLevel, + admins: _admins, + ...props + }: { + modules: unknown[] + accessLevel: string + admins?: unknown[] + [key: string]: unknown + }) => ( +
+ ModuleCard ({modules?.length || 0} modules) +
+ ), +})) + +jest.mock('components/ShowMoreButton', () => { + function ShowMoreButtonMock({ + onToggle, + ...props + }: Readonly<{ + onToggle: () => void + [key: string]: unknown + }>) { + const [isExpanded, setIsExpanded] = React.useState(false) + return ( + + ) + } + return { + __esModule: true, + default: ShowMoreButtonMock, + } +}) + +jest.mock('components/TruncatedText', () => ({ + __esModule: true, + TruncatedText: ({ text }: { text: string }) => {text}, +})) + describe('CardDetailsPage', () => { const createMalformedData = >( validData: T, @@ -663,7 +822,7 @@ describe('CardDetailsPage', () => { render() expect(screen.getByText('Inactive')).toBeInTheDocument() - // Updated classes for consistent badge styling + // Updated classes for consistent badge styling. expect(screen.getByText('Inactive')).toHaveClass('bg-red-50', 'text-red-800') }) @@ -809,6 +968,23 @@ describe('CardDetailsPage', () => { expect(screen.getByTestId('metrics-score-circle')).toBeInTheDocument() }) + it('calls scrollToAnchor when MetricsScoreCircle is clicked', () => { + const { scrollToAnchor } = jest.requireMock('utils/scrollToAnchor') + + render( + + ) + + const healthButton = screen.getByRole('button') + fireEvent.click(healthButton) + + expect(scrollToAnchor).toHaveBeenCalledWith('issues-trend') + }) + it('renders social links with correct hrefs and target attributes', () => { const socialLinks = ['https://github.com/test', 'https://twitter.com/test'] render() @@ -1822,6 +1998,131 @@ describe('CardDetailsPage', () => { expect(screen.getByText('Milestone 4')).toBeInTheDocument() expect(screen.queryByText(/Show more/i)).not.toBeInTheDocument() }) + + it('renders milestone author avatar when showAvatar is true and author data is complete', () => { + const milestonesWithAuthor = [ + { + author: { + login: 'author-user', + name: 'Author User', + avatarUrl: 'https://example.com/author-avatar.jpg', + }, + body: 'Milestone with author', + closedIssuesCount: 3, + createdAt: new Date(Date.now() - 10000000).toISOString(), + openIssuesCount: 1, + repositoryName: 'test-repo', + organizationName: 'test-org', + state: 'open', + title: 'Milestone With Author', + url: 'https://github.com/test/project/milestone/1', + }, + ] + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + recentMilestones: milestonesWithAuthor, + showAvatar: true, + modules: [], + } + + render() + + expect(screen.getByText('Milestone With Author')).toBeInTheDocument() + // The avatar image should be rendered + const avatarImg = screen.getByAltText("Author User's avatar") + expect(avatarImg).toBeInTheDocument() + expect(avatarImg).toHaveAttribute('src', 'https://example.com/author-avatar.jpg') + }) + + it('renders milestone without author avatar when author data is missing', () => { + const milestonesWithoutAuthor = [ + { + author: null, + body: 'Milestone without author', + closedIssuesCount: 3, + createdAt: new Date(Date.now() - 10000000).toISOString(), + openIssuesCount: 1, + repositoryName: 'test-repo', + organizationName: 'test-org', + state: 'open', + title: 'Milestone No Author', + url: 'https://github.com/test/project/milestone/1', + }, + ] + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + recentMilestones: milestonesWithoutAuthor, + showAvatar: true, + modules: [], + } + + render() + + expect(screen.getByText('Milestone No Author')).toBeInTheDocument() + }) + + it('renders milestone title without link when URL is missing', () => { + const milestonesWithoutUrl = [ + { + author: mockUser, + body: 'Milestone without URL', + closedIssuesCount: 3, + createdAt: new Date(Date.now() - 10000000).toISOString(), + openIssuesCount: 1, + repositoryName: 'test-repo', + organizationName: 'test-org', + state: 'open', + title: 'Milestone No URL', + url: null, + }, + ] + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + recentMilestones: milestonesWithoutUrl, + modules: [], + } + + render() + + expect(screen.getByText('Milestone No URL')).toBeInTheDocument() + // The title should not be a link + const title = screen.getByText('Milestone No URL') + expect(title.closest('a')).toBeNull() + }) + + it('renders milestone without repository link when repositoryName or organizationName is missing', () => { + const milestonesWithoutRepo = [ + { + author: mockUser, + body: 'Milestone without repo', + closedIssuesCount: 3, + createdAt: new Date(Date.now() - 10000000).toISOString(), + openIssuesCount: 1, + repositoryName: null, + organizationName: null, + state: 'open', + title: 'Milestone No Repo', + url: 'https://github.com/test/project/milestone/1', + }, + ] + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + recentMilestones: milestonesWithoutRepo, + modules: [], + } + + render() + + expect(screen.getByText('Milestone No Repo')).toBeInTheDocument() + }) }) describe('Module Pull Requests Display', () => { @@ -1918,4 +2219,529 @@ describe('CardDetailsPage', () => { expect(screen.queryByText(/Show more/i)).not.toBeInTheDocument() }) }) + + describe('Module Admin EntityActions and Mentees', () => { + it('renders EntityActions for module type when user is an admin', () => { + const { useSession } = jest.requireMock('next-auth/react') + useSession.mockReturnValue({ + data: { + user: { + login: 'admin-user', + name: 'Admin User', + email: 'admin@example.com', + }, + }, + }) + + const adminUser = { + id: 'admin-id', + login: 'admin-user', + name: 'Admin User', + avatarUrl: 'https://example.com/admin-avatar.jpg', + } + + const moduleProps: DetailsCardProps = { + ...defaultProps, + type: 'module' as const, + accessLevel: 'admin', + admins: [adminUser], + programKey: 'test-program', + entityKey: 'test-module', + modules: [], + } + + render() + + expect(screen.getByTestId('entity-actions')).toBeInTheDocument() + expect(screen.getByTestId('entity-actions')).toHaveTextContent('type=module') + }) + + it('does not render EntityActions for module type when user is not an admin', () => { + const { useSession } = jest.requireMock('next-auth/react') + useSession.mockReturnValue({ + data: { + user: { + login: 'regular-user', + name: 'Regular User', + email: 'user@example.com', + }, + }, + }) + + const adminUser = { + id: 'admin-id', + login: 'admin-user', + name: 'Admin User', + avatarUrl: 'https://example.com/admin-avatar.jpg', + } + + const moduleProps: DetailsCardProps = { + ...defaultProps, + type: 'module' as const, + accessLevel: 'admin', + admins: [adminUser], + programKey: 'test-program', + entityKey: 'test-module', + modules: [], + } + + render() + + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('renders mentees section when mentees are provided', () => { + const mentees = [ + { + id: 'mentee-1', + login: 'mentee_user1', + name: 'Mentee User 1', + avatarUrl: 'https://example.com/mentee1.jpg', + }, + { + id: 'mentee-2', + login: 'mentee_user2', + name: 'Mentee User 2', + avatarUrl: 'https://example.com/mentee2.jpg', + }, + ] + + const propsWithMentees: DetailsCardProps = { + ...defaultProps, + mentees, + programKey: 'test-program', + entityKey: 'test-entity', + } + + render() + + const allContributorsLists = screen.getAllByTestId('contributors-list') + const menteesSection = allContributorsLists.find((el) => el.textContent?.includes('Mentees')) + expect(menteesSection).toHaveTextContent('Mentees (2 items, max display: 6)') + }) + + it('does not render mentees section when no mentees are provided', () => { + const propsWithoutMentees: DetailsCardProps = { + ...defaultProps, + mentees: [], + } + render() + // Make sure mentees section is not rendered + const allContributorsLists = screen.queryAllByTestId('contributors-list') + const menteesList = allContributorsLists.find((el) => el.textContent?.includes('Mentees')) + expect(menteesList).toBeUndefined() + }) + + it('renders mentees with custom URL formatter', () => { + const mentees = [ + { + id: 'mentee-1', + login: 'test_mentee', + name: 'Test Mentee', + avatarUrl: 'https://example.com/mentee.jpg', + }, + ] + + const propsWithMentees: DetailsCardProps = { + ...defaultProps, + mentees, + programKey: 'program-key-123', + entityKey: 'entity-key-456', + } + + render() + + const allContributorsLists = screen.getAllByTestId('contributors-list') + const menteesSection = allContributorsLists.find((el) => el.textContent?.includes('Mentees')) + expect(menteesSection).toHaveTextContent('Mentees (1 items, max display: 6)') + }) + + it('handles null/undefined mentees array gracefully', () => { + const propsWithNullMentees: DetailsCardProps = { + ...defaultProps, + mentees: null, + } + + expect(() => render()).not.toThrow() + }) + + it('renders program EntityActions when type is program with appropriate access', () => { + const { useSession } = jest.requireMock('next-auth/react') + useSession.mockReturnValue({ + data: { + user: { + login: 'program-admin', + name: 'Program Admin', + email: 'admin@example.com', + }, + }, + }) + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + accessLevel: 'admin', + canUpdateStatus: true, + status: 'active', + setStatus: jest.fn(), + programKey: 'test-program', + modules: [], + } + + render() + + expect(screen.getByTestId('entity-actions')).toBeInTheDocument() + expect(screen.getByTestId('entity-actions')).toHaveTextContent('type=program') + }) + + it('does not render program EntityActions when canUpdateStatus is false', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + accessLevel: 'admin', + canUpdateStatus: false, + status: 'active', + setStatus: jest.fn(), + programKey: 'test-program', + modules: [], + } + + render() + + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('does not render program EntityActions when accessLevel is not admin', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + accessLevel: 'user', + canUpdateStatus: true, + status: 'active', + setStatus: jest.fn(), + programKey: 'test-program', + modules: [], + } + + render() + + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + }) + + describe('Program and Module Tags, Domains, and Labels', () => { + it('renders tags for program type', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + tags: ['tag1', 'tag2', 'tag3'], + modules: [], + } + + render() + + expect(screen.getByText(/Tags/)).toBeInTheDocument() + expect(screen.getByText(/tag1, tag2, tag3/)).toBeInTheDocument() + }) + + it('renders domains for program type', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + domains: ['domain1', 'domain2'], + modules: [], + } + + render() + + expect(screen.getByText(/Domains/)).toBeInTheDocument() + expect(screen.getByText(/domain1, domain2/)).toBeInTheDocument() + }) + + it('renders labels for program type', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + labels: ['label1', 'label2'], + modules: [], + } + + render() + + expect(screen.getByText(/Labels/)).toBeInTheDocument() + expect(screen.getByText(/label1, label2/)).toBeInTheDocument() + }) + + it('renders tags and domains in same row for module type', () => { + const moduleProps: DetailsCardProps = { + ...defaultProps, + type: 'module' as const, + tags: ['moduleTag1'], + domains: ['moduleDomain1'], + modules: [], + } + + render() + + expect(screen.getByText(/Tags/)).toBeInTheDocument() + expect(screen.getByText(/Domains/)).toBeInTheDocument() + }) + + it('does not render tags section when tags array is empty', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + tags: [], + domains: ['domain1'], + modules: [], + } + + render() + + expect(screen.queryByText(/Tags:/)).not.toBeInTheDocument() + expect(screen.getByText(/Domains/)).toBeInTheDocument() + }) + + it('does not render domains section when domains array is empty', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + tags: ['tag1'], + domains: [], + modules: [], + } + + render() + + expect(screen.getByText(/Tags/)).toBeInTheDocument() + expect(screen.queryByText(/Domains:/)).not.toBeInTheDocument() + }) + }) + + describe('Program Module Rendering', () => { + const mockModules = [ + { + id: 'module-1-id', + key: 'module-1', + name: 'Module 1', + description: 'First module', + endedAt: new Date(Date.now() + 86400000).toISOString(), + startedAt: new Date(Date.now() - 86400000).toISOString(), + experienceLevel: 'BEGINNER', + mentors: [], + }, + { + id: 'module-2-id', + key: 'module-2', + name: 'Module 2', + description: 'Second module', + endedAt: new Date(Date.now() + 86400000).toISOString(), + startedAt: new Date(Date.now() - 86400000).toISOString(), + experienceLevel: 'INTERMEDIATE', + mentors: [], + }, + ] as DetailsCardProps['modules'] + + it('renders single module without SecondaryCard wrapper', () => { + const singleModuleProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + modules: [mockModules![0]], + } + + render() + + expect(screen.getByTestId('module-card')).toBeInTheDocument() + // Single module should not have "Modules" title + expect(screen.queryByText('Modules')).not.toBeInTheDocument() + }) + + it('renders multiple modules with SecondaryCard wrapper and title', () => { + const multiModuleProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + modules: mockModules, + } + + render() + + expect(screen.getByTestId('module-card')).toBeInTheDocument() + expect(screen.getByText('Modules')).toBeInTheDocument() + }) + }) + + describe('Mentors and Admins Lists', () => { + const mockMentors = [ + { + id: 'mentor-1', + login: 'mentor_user1', + name: 'Mentor User 1', + avatarUrl: 'https://example.com/mentor1.jpg', + }, + { + id: 'mentor-2', + login: 'mentor_user2', + name: 'Mentor User 2', + avatarUrl: 'https://example.com/mentor2.jpg', + }, + ] + + const mockAdmins = [ + { + id: 'admin-1', + login: 'admin_user1', + name: 'Admin User 1', + avatarUrl: 'https://example.com/admin1.jpg', + }, + ] + + it('renders mentors section when mentors are provided', () => { + const propsWithMentors: DetailsCardProps = { + ...defaultProps, + mentors: mockMentors, + } + + render() + + const allContributorsLists = screen.getAllByTestId('contributors-list') + const mentorsSection = allContributorsLists.find((el) => el.textContent?.includes('Mentors')) + expect(mentorsSection).toHaveTextContent('Mentors (2 items, max display: 6)') + }) + + it('does not render mentors section when mentors array is empty', () => { + const propsWithoutMentors: DetailsCardProps = { + ...defaultProps, + mentors: [], + } + + render() + + // Mentors section should not be rendered + const allContributorsLists = screen.queryAllByTestId('contributors-list') + const mentorsSection = allContributorsLists.find((el) => el.textContent?.includes('Mentors')) + expect(mentorsSection).toBeUndefined() + }) + + it('renders admins section when type is program and admins are provided', () => { + const propsWithAdmins: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + admins: mockAdmins, + modules: [], + } + + render() + + const allContributorsLists = screen.getAllByTestId('contributors-list') + const adminsSection = allContributorsLists.find((el) => el.textContent?.includes('Admins')) + expect(adminsSection).toHaveTextContent('Admins (1 items, max display: 6)') + }) + + it('does not render admins section for non-program types', () => { + const propsWithAdmins: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + admins: mockAdmins, + } + + render() + + const allContributorsLists = screen.queryAllByTestId('contributors-list') + const adminsSection = allContributorsLists.find((el) => el.textContent?.includes('Admins')) + expect(adminsSection).toBeUndefined() + }) + }) + + describe('Repository Rendering for Different Types', () => { + it('renders repositories for user type', () => { + const userProps: DetailsCardProps = { + ...defaultProps, + type: 'user' as const, + repositories: mockRepositories, + } + + render() + + expect(screen.getByText('Repositories')).toBeInTheDocument() + expect(screen.getByTestId('repositories-card')).toBeInTheDocument() + }) + + it('renders repositories for organization type', () => { + const orgProps: DetailsCardProps = { + ...defaultProps, + type: 'organization' as const, + repositories: mockRepositories, + } + + render() + + expect(screen.getByText('Repositories')).toBeInTheDocument() + expect(screen.getByTestId('repositories-card')).toBeInTheDocument() + }) + + it('does not render repositories for chapter type', () => { + const chapterProps: DetailsCardProps = { + ...defaultProps, + type: 'chapter' as const, + repositories: mockRepositories, + } + + render() + + expect(screen.queryByText('Repositories')).not.toBeInTheDocument() + }) + }) + + describe('Sponsor Card Rendering', () => { + it('renders sponsor card for chapter type', () => { + const chapterProps: DetailsCardProps = { + ...defaultProps, + type: 'chapter' as const, + entityKey: 'test-chapter', + } + + render() + + expect(screen.getByTestId('sponsor-card')).toBeInTheDocument() + expect(screen.getByTestId('sponsor-card')).toHaveTextContent('Type: chapter') + }) + + it('renders sponsor card for repository type', () => { + const repoProps: DetailsCardProps = { + ...defaultProps, + type: 'repository' as const, + entityKey: 'test-repo', + } + + render() + + expect(screen.getByTestId('sponsor-card')).toBeInTheDocument() + expect(screen.getByTestId('sponsor-card')).toHaveTextContent('Type: project') + }) + + it('uses projectName as title when provided', () => { + const projectProps: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + entityKey: 'test-project', + projectName: 'Custom Project Name', + } + + render() + + expect(screen.getByTestId('sponsor-card')).toHaveTextContent('Title: Custom Project Name') + }) + + it('does not render sponsor card when entityKey is missing', () => { + const propsWithoutKey: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + entityKey: undefined, + } + + render() + + expect(screen.queryByTestId('sponsor-card')).not.toBeInTheDocument() + }) + }) }) diff --git a/frontend/__tests__/unit/components/ChapterMapWrapper.test.tsx b/frontend/__tests__/unit/components/ChapterMapWrapper.test.tsx new file mode 100644 index 0000000000..35c1daa972 --- /dev/null +++ b/frontend/__tests__/unit/components/ChapterMapWrapper.test.tsx @@ -0,0 +1,319 @@ +import { render, waitFor, fireEvent } from '@testing-library/react' +import React, { JSX } from 'react' +import { Chapter } from 'types/chapter' +import * as geolocationUtils from 'utils/geolocationUtils' + +// Mock next/dynamic +jest.mock('next/dynamic', () => { + return function mockDynamic( + importFn: () => Promise<{ default: React.ComponentType }>, + options?: { ssr?: boolean } + ) { + // Ignore options for SSR: false — reference it without using `void` + if (options) { + /* intentionally unused */ + } + // Return a component that resolves the import synchronously for testing + const Component = React.lazy(importFn) + return function DynamicComponent(props: Record): JSX.Element { + return ( + Loading...
}> + + + ) + } + } +}) + +// Mock ChapterMap component +const mockOnShareLocation = jest.fn() +jest.mock('components/ChapterMap', () => { + return function MockChapterMap(props: { + geoLocData: Chapter[] + showLocal: boolean + style: React.CSSProperties + userLocation?: { latitude: number; longitude: number } | null + onShareLocation?: () => void + }): JSX.Element { + // Capture the onShareLocation prop for testing + if (props.onShareLocation) { + mockOnShareLocation.mockImplementation(props.onShareLocation) + } + return ( +
+ {props.geoLocData.length} + {String(props.showLocal)} + {props.userLocation && ( + + {props.userLocation.latitude},{props.userLocation.longitude} + + )} + {props.onShareLocation && ( + + )} +
+ ) + } +}) + +// Mock geolocation utilities +jest.mock('utils/geolocationUtils', () => ({ + getUserLocationFromBrowser: jest.fn(), + sortChaptersByDistance: jest.fn(), +})) + +describe('ChapterMapWrapper', () => { + const mockChapterData: Chapter[] = [ + { + _geoloc: { lat: 40.7128, lng: -74.006 }, + key: 'new-york', + name: 'New York Chapter', + } as Chapter, + { + geoLocation: { lat: 51.5074, lng: -0.1278 }, + key: 'london', + name: 'London Chapter', + } as Chapter, + ] + + const defaultProps = { + geoLocData: mockChapterData, + showLocal: false, + style: { width: '100%', height: '400px' }, + } + + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + // We need to import the component after mocks are set up + const getChapterMapWrapper = async () => { + const chapterModule = await import('components/ChapterMapWrapper') + return chapterModule.default + } + + describe('when showLocationSharing is false or undefined', () => { + it('renders ChapterMap directly without wrapper when showLocationSharing is false', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('chapter-map')).toBeInTheDocument() + }) + // Should not have share location button (no onShareLocation passed) + expect(getByTestId('geo-loc-data-length')).toHaveTextContent('2') + }) + + it('renders ChapterMap directly without wrapper when showLocationSharing is undefined', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render() + + await waitFor(() => { + expect(getByTestId('chapter-map')).toBeInTheDocument() + }) + expect(getByTestId('geo-loc-data-length')).toHaveTextContent('2') + }) + }) + + describe('when showLocationSharing is true', () => { + it('renders ChapterMap with wrapping div and onShareLocation handler', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId, container } = render( + + ) + + await waitFor(() => { + expect(getByTestId('chapter-map')).toBeInTheDocument() + }) + + // Check for the wrapper div with h-full w-full classes + const wrapper = container.querySelector('.h-full.w-full') + expect(wrapper).toBeInTheDocument() + + // Should have share location button + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + it('uses original geoLocData when sortedData is null', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('geo-loc-data-length')).toHaveTextContent('2') + }) + }) + }) + + describe('handleShareLocation function', () => { + it('clears user location when location is already set (toggle off)', async () => { + const mockLocation = { latitude: 40.7128, longitude: -74.006 } + const mockSortedChapters = [ + { ...mockChapterData[0], _distance: 0 }, + { ...mockChapterData[1], _distance: 100 }, + ] + + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockResolvedValue(mockLocation) + ;(geolocationUtils.sortChaptersByDistance as jest.Mock).mockReturnValue(mockSortedChapters) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId, queryByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + // First click - enable location sharing + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(getByTestId('user-location')).toHaveTextContent('40.7128,-74.006') + }) + + // Second click - disable location sharing (toggle off) + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(queryByTestId('user-location')).not.toBeInTheDocument() + }) + }) + + it('fetches and sets user location on successful geolocation', async () => { + const mockLocation = { latitude: 51.5074, longitude: -0.1278 } + const mockSortedChapters = [ + { ...mockChapterData[1], _distance: 0 }, + { ...mockChapterData[0], _distance: 5000 }, + ] + + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockResolvedValue(mockLocation) + ;(geolocationUtils.sortChaptersByDistance as jest.Mock).mockReturnValue(mockSortedChapters) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(geolocationUtils.getUserLocationFromBrowser).toHaveBeenCalled() + expect(geolocationUtils.sortChaptersByDistance).toHaveBeenCalledWith( + mockChapterData, + mockLocation + ) + expect(getByTestId('user-location')).toHaveTextContent('51.5074,-0.1278') + }) + }) + + it('does nothing when geolocation returns null', async () => { + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockResolvedValue(null) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId, queryByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(geolocationUtils.getUserLocationFromBrowser).toHaveBeenCalled() + expect(geolocationUtils.sortChaptersByDistance).not.toHaveBeenCalled() + expect(queryByTestId('user-location')).not.toBeInTheDocument() + }) + }) + + it('logs error when geolocation throws an error', async () => { + const mockError = new Error('Geolocation permission denied') + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockRejectedValue(mockError) + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId, queryByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Error detecting location:', mockError) + expect(queryByTestId('user-location')).not.toBeInTheDocument() + }) + }) + + it('correctly maps sorted data removing _distance property', async () => { + const mockLocation = { latitude: 40.7128, longitude: -74.006 } + const mockSortedChapters = [ + { ...mockChapterData[0], _distance: 0 }, + { ...mockChapterData[1], _distance: 5000 }, + ] + + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockResolvedValue(mockLocation) + ;(geolocationUtils.sortChaptersByDistance as jest.Mock).mockReturnValue(mockSortedChapters) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + // The component should still have 2 chapters after sorting + expect(getByTestId('geo-loc-data-length')).toHaveTextContent('2') + }) + }) + }) + + describe('props forwarding', () => { + it('forwards showLocal prop to ChapterMap', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('show-local')).toHaveTextContent('true') + }) + }) + + it('forwards style prop to ChapterMap', async () => { + const customStyle = { width: '500px', height: '300px' } + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('chapter-map')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx index dbddc2d84d..7a55210905 100644 --- a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -170,6 +170,84 @@ describe('ContributionHeatmap', () => { ) expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() }) + + it('handles missing startDate by using default dates', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + // Should render with default date range (1 year) + expect(screen.getByTestId('mock-heatmap-chart')).toHaveAttribute('data-series-length', '7') + }) + + it('handles missing endDate by using default dates', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + expect(screen.getByTestId('mock-heatmap-chart')).toHaveAttribute('data-series-length', '7') + }) + + it('handles both missing startDate and endDate', () => { + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles invalid startDate by using default dates', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles invalid endDate by using default dates', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles both invalid startDate and endDate', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles swapped dates (startDate > endDate) by swapping them', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + expect(screen.getByTestId('mock-heatmap-chart')).toHaveAttribute('data-series-length', '7') + }) + + it('handles startDate after endDate and swaps them correctly', () => { + const data = { + '2024-01-15': 5, + '2024-01-20': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) }) describe('Theme & Styling', () => { diff --git a/frontend/__tests__/unit/components/ContributorAvatar.test.tsx b/frontend/__tests__/unit/components/ContributorAvatar.test.tsx index e59736edd9..dc169408f7 100644 --- a/frontend/__tests__/unit/components/ContributorAvatar.test.tsx +++ b/frontend/__tests__/unit/components/ContributorAvatar.test.tsx @@ -241,4 +241,120 @@ describe('ContributorAvatar', () => { it('is properly memoized with displayName', () => { expect(ContributorAvatar.displayName).toBe('ContributorAvatar') }) + + describe('branch coverage for isAlgoliaContributor', () => { + it('handles contributor without avatarUrl property (non-Algolia path)', () => { + // Test with a contributor that doesn't have avatarUrl to hit the false branch + const nonAlgoliaContributor = { + id: 'contributor-non-algolia', + login: 'arkid15r', + name: 'Kateryna User', + projectKey: 'test-key', + projectName: 'Test-Project', + contributionsCount: 10, + } as unknown as Contributor + + // We need to mock the component behavior by providing avatarUrl separately + // Since the component always expects avatarUrl, we test the edge case + render() + expect(screen.getByTestId('contributor-avatar')).toBeInTheDocument() + }) + + it('shows repository info in tooltip when projectName is present and has contributions', () => { + const contributorWithProject: Contributor = { + id: 'contributor-with-project', + login: 'projectuser', + name: 'Project User', + avatarUrl: 'https://github.com/projectuser.png', + contributionsCount: 5, + projectKey: 'test-key', + projectName: 'My-Project', + } + render( + + ) + const tooltip = screen.getByTestId('avatar-tooltip-projectuser-project-info-test') + // All contributors are treated as Algolia, so projectName is not included + expect(tooltip).toHaveAttribute('title', '5 contributions by Project User') + }) + + it('shows repository info in tooltip when projectName is present without contributions', () => { + const contributorWithProjectNoContrib: Contributor = { + id: 'contributor-project-no-contrib', + login: 'projectuser2', + name: 'Project User 2', + avatarUrl: 'https://github.com/projectuser2.png', + projectKey: 'test-key', + projectName: 'Another-Project', + } + render( + + ) + const tooltip = screen.getByTestId('avatar-tooltip-projectuser2-project-no-contrib-test') + expect(tooltip).toHaveAttribute('title', 'Project User 2') + }) + + it('handles contributor with null name falling back to login', () => { + const contributorWithNullName: Contributor = { + id: 'contributor-null-name', + login: 'loginonly', + name: null as unknown as string, + avatarUrl: 'https://github.com/loginonly.png', + contributionsCount: 3, + projectKey: 'test-key', + } + render() + const tooltip = screen.getByTestId('avatar-tooltip-loginonly-null-name-test') + expect(tooltip).toHaveAttribute('title', '3 contributions by loginonly') + }) + + it('uses login as displayName when name is undefined', () => { + const contributorNoName = { + id: 'contributor-no-name', + login: 'usernameonly', + avatarUrl: 'https://github.com/usernameonly.png', + projectKey: 'test-key', + } as Contributor + render() + const tooltip = screen.getByTestId('avatar-tooltip-usernameonly-no-name-test') + expect(tooltip).toHaveAttribute('title', 'usernameonly') + }) + + it('renders avatar without &s=60 suffix when treated as non-Algolia', () => { + // This tests the false branch of isAlgolia check in src line 51 + // However, since all Contributor objects have avatarUrl, isAlgolia is always true + // We verify the positive case explicitly + const algoliaContributor: Contributor = { + id: 'contributor-algolia-check', + login: 'ahmedxgouda', + name: 'Ahmed User', + avatarUrl: 'https://algolia.com/avatar.png', + contributionsCount: 20, + projectKey: 'test-key', + } + render() + const avatar = screen.getByTestId('contributor-avatar') + expect(avatar).toHaveAttribute('src', 'https://algolia.com/avatar.png&s=60') + }) + + it('handles contributor object with extra properties', () => { + const contributorWithExtras = { + id: 'contributor-extras', + login: 'extrauser', + name: 'Extra User', + avatarUrl: 'https://github.com/extrauser.png', + contributionsCount: 7, + projectKey: 'test-key', + extraField: 'should be ignored', + anotherField: 123, + } as unknown as Contributor + render() + expect(screen.getByTestId('contributor-avatar')).toBeInTheDocument() + const tooltip = screen.getByTestId('avatar-tooltip-extrauser-extras-test') + expect(tooltip).toHaveAttribute('title', '7 contributions by Extra User') + }) + }) }) diff --git a/frontend/__tests__/unit/components/DashboardWrapper.test.tsx b/frontend/__tests__/unit/components/DashboardWrapper.test.tsx new file mode 100644 index 0000000000..28676a5e5a --- /dev/null +++ b/frontend/__tests__/unit/components/DashboardWrapper.test.tsx @@ -0,0 +1,240 @@ +import { render, screen } from '@testing-library/react' +import { useDjangoSession } from 'hooks/useDjangoSession' +import { notFound } from 'next/navigation' +import DashboardWrapper from 'components/DashboardWrapper' + +jest.mock('hooks/useDjangoSession') + +jest.mock('next/navigation', () => ({ + notFound: jest.fn(() => { + throw new Error('notFound') + }), +})) + +jest.mock('components/LoadingSpinner', () => { + return function MockLoadingSpinner() { + return
Loading...
+ } +}) + +describe('', () => { + const mockUseDjangoSession = useDjangoSession as jest.MockedFunction + const mockNotFound = notFound as jest.MockedFunction + + const mockSession = { + user: { + id: '1', + name: 'Test User', + email: 'test@example.com', + image: 'https://example.com/image.jpg', + isOwaspStaff: true, + }, + accessToken: 'test-token', + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders LoadingSpinner when isSyncing is true', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: true, + session: mockSession, + status: 'authenticated', + }) + + render( + +
Dashboard Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + expect(screen.queryByText('Dashboard Content')).not.toBeInTheDocument() + }) + + it('renders children when isSyncing is false and user is OWASP staff', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: mockSession, + status: 'authenticated', + }) + + render( + +
Dashboard Content
+
+ ) + + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + expect(screen.getByText('Dashboard Content')).toBeInTheDocument() + expect(mockNotFound).not.toHaveBeenCalled() + }) + + it('calls notFound when user is not OWASP staff', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { + ...mockSession, + user: { + ...mockSession.user, + isOwaspStaff: false, + }, + }, + status: 'authenticated', + }) + + expect(() => { + render( + +
Dashboard Content
+
+ ) + }).toThrow('notFound') + expect(mockNotFound).toHaveBeenCalled() + }) + + it('calls notFound when session is undefined', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: undefined, + status: 'unauthenticated', + }) + + expect(() => { + render( + +
Dashboard Content
+
+ ) + }).toThrow('notFound') + expect(mockNotFound).toHaveBeenCalled() + }) + + it('prioritizes loading state over authorization check', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: true, + session: { + ...mockSession, + user: { + ...mockSession.user, + isOwaspStaff: false, + }, + }, + status: 'authenticated', + }) + + render( + +
Dashboard Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + expect(mockNotFound).not.toHaveBeenCalled() + }) + + it('handles status transitions correctly', () => { + // Initially syncing + mockUseDjangoSession.mockReturnValue({ + isSyncing: true, + session: mockSession, + status: 'loading', + }) + + const { rerender } = render( + +
Dashboard Content
+
+ ) + + rerender( + +
Dashboard Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + + // After sync completes + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: mockSession, + status: 'authenticated', + }) + + rerender( + +
Dashboard Content
+
+ ) + + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + expect(screen.getByText('Dashboard Content')).toBeInTheDocument() + }) + + it('handles authorization changes when session is updated', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: mockSession, + status: 'authenticated', + }) + + const { rerender } = render( + +
Dashboard Content
+
+ ) + + expect(screen.getByText('Dashboard Content')).toBeInTheDocument() + mockNotFound.mockClear() + + // Update to non-staff user + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { + ...mockSession, + user: { + ...mockSession.user, + isOwaspStaff: false, + }, + }, + status: 'authenticated', + }) + + expect(() => { + rerender( + +
Dashboard Content
+
+ ) + }).toThrow('notFound') + + expect(mockNotFound).toHaveBeenCalled() + }) + + it('renders children without extra wrapper', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: mockSession, + status: 'authenticated', + }) + + const { container } = render( + +
Test 1
+
Test 2
+
+ ) + + expect(screen.getByText('Test 1')).toBeInTheDocument() + expect(screen.getByText('Test 2')).toBeInTheDocument() + + const divElements = container.querySelectorAll('div') + const testDivs = Array.from(divElements).filter( + (div) => div.textContent === 'Test 1' || div.textContent === 'Test 2' + ) + expect(testDivs.length).toBe(2) + }) +}) diff --git a/frontend/__tests__/unit/components/FontLoaderWrapper.test.tsx b/frontend/__tests__/unit/components/FontLoaderWrapper.test.tsx new file mode 100644 index 0000000000..b07980c86d --- /dev/null +++ b/frontend/__tests__/unit/components/FontLoaderWrapper.test.tsx @@ -0,0 +1,206 @@ +import { render, screen, waitFor } from '@testing-library/react' +import FontLoaderWrapper from 'components/FontLoaderWrapper' + +jest.mock('components/LoadingSpinner', () => { + return function MockLoadingSpinner() { + return
Loading fonts...
+ } +}) + +describe('', () => { + let fontsReadyResolve: (() => void) | null = null + + beforeEach(() => { + const fontsReadyPromise = new Promise((resolve) => { + fontsReadyResolve = resolve + }) + + Object.defineProperty(document, 'fonts', { + value: { + ready: fontsReadyPromise, + }, + configurable: true, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + fontsReadyResolve = null + }) + + it('renders LoadingSpinner initially while fonts are loading', () => { + render( + +
Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + expect(screen.queryByText('Content')).not.toBeInTheDocument() + }) + + it('renders children after fonts are loaded', async () => { + render( + +
Dashboard Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Dashboard Content')).toBeInTheDocument() + }) + }) + + it('hides LoadingSpinner when fonts are loaded', async () => { + render( + +
Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + }) + + it('renders multiple children correctly', async () => { + render( + +
Child 1
+
Child 2
+
Child 3
+
+ ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Child 1')).toBeInTheDocument() + expect(screen.getByText('Child 2')).toBeInTheDocument() + expect(screen.getByText('Child 3')).toBeInTheDocument() + }) + }) + + it('updates children when data prop changes', async () => { + const { rerender } = render( + +
Initial Content
+
+ ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Initial Content')).toBeInTheDocument() + }) + + rerender( + +
Updated Content
+
+ ) + + expect(screen.getByText('Updated Content')).toBeInTheDocument() + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + + it('maintains fontsLoaded state after rerender', async () => { + const { rerender } = render( + +
Content 1
+
+ ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + rerender( + +
Content 2
+
+ ) + + expect(screen.getByText('Content 2')).toBeInTheDocument() + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + + it('renders children after fonts promise resolves', async () => { + render( + +
Content
+
+ ) + + // Component calls .then() on document.fonts.ready and renders content once resolved + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Content')).toBeInTheDocument() + }) + }) + + it('handles empty children', async () => { + render( + + <> + + ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + }) + + it('renders children as fragment without extra wrapper', async () => { + render( + +

Title

+

Paragraph

+
+ ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Paragraph')).toBeInTheDocument() + + const h1 = screen.getByText('Title') as HTMLElement + const p = screen.getByText('Paragraph') as HTMLElement + + expect(h1.tagName).toBe('H1') + expect(p.tagName).toBe('P') + }) + }) + + it('does not obscure interactive elements after fonts load', async () => { + render( + + + + ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/components/InfoItem.test.tsx b/frontend/__tests__/unit/components/InfoItem.test.tsx index b3d7dad063..b743372ec5 100644 --- a/frontend/__tests__/unit/components/InfoItem.test.tsx +++ b/frontend/__tests__/unit/components/InfoItem.test.tsx @@ -3,7 +3,7 @@ import millify from 'millify' import React from 'react' import { FaUser } from 'react-icons/fa' import { pluralize } from 'utils/pluralize' -import InfoItem from 'components/InfoItem' +import InfoItem, { TextInfoItem } from 'components/InfoItem' jest.mock('millify', () => jest.fn()) jest.mock('utils/pluralize', () => ({ @@ -118,3 +118,48 @@ describe('InfoItem', () => { expect(screen.getByText('0')).toBeInTheDocument() }) }) + +describe('TextInfoItem', () => { + it('renders successfully with required props', () => { + render() + + expect(screen.getByText('Author:')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(document.querySelector('svg')).toBeInTheDocument() + }) + + it('applies correct DOM structure and classes', () => { + render() + + const container = screen.getByText('Role:').closest('div') + expect(container).toHaveClass( + 'flex', + 'items-center', + 'gap-2', + 'text-sm', + 'text-gray-600', + 'dark:text-gray-300' + ) + + const labelSpan = screen.getByText('Role:') + expect(labelSpan).toHaveClass('font-medium') + + const icon = document.querySelector('svg') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass('text-xs') + }) + + it('handles empty value string', () => { + render() + + expect(screen.getByText('Status:')).toBeInTheDocument() + }) + + it('handles long value strings', () => { + const longValue = 'This is a very long value string that might be displayed' + render() + + expect(screen.getByText('Description:')).toBeInTheDocument() + expect(screen.getByText(longValue)).toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/components/Leaders.test.tsx b/frontend/__tests__/unit/components/Leaders.test.tsx index e43a8d5e1d..00dcb4b92f 100644 --- a/frontend/__tests__/unit/components/Leaders.test.tsx +++ b/frontend/__tests__/unit/components/Leaders.test.tsx @@ -54,4 +54,20 @@ describe('Leaders Component', () => { expect(push).toHaveBeenCalledWith('/members/alice') }) + + it('navigates to search page when user.member is undefined', () => { + const userWithoutMember = [ + { + memberName: 'John Doe', + description: 'Team Lead', + member: undefined, + }, + ] + render() + + const viewProfileButton = screen.getByText('View Profile') + fireEvent.click(viewProfileButton) + + expect(push).toHaveBeenCalledWith('/members?q=John%20Doe') + }) }) diff --git a/frontend/__tests__/unit/components/MentorshipPullRequest.test.tsx b/frontend/__tests__/unit/components/MentorshipPullRequest.test.tsx new file mode 100644 index 0000000000..cc8dd149ed --- /dev/null +++ b/frontend/__tests__/unit/components/MentorshipPullRequest.test.tsx @@ -0,0 +1,162 @@ +import { render, screen } from 'wrappers/testUtil' +import type { PullRequest } from 'types/pullRequest' +import MentorshipPullRequest, { getPRStatus } from 'components/MentorshipPullRequest' + +describe('MentorshipPullRequest Component', () => { + const mockPullRequestOpen: PullRequest = { + id: '1', + title: 'Add new feature to dashboard', + state: 'open', + url: 'https://github.com/test/repo/pull/1', + createdAt: '2024-01-15', + mergedAt: null, + author: { + login: 'testuser', + avatarUrl: 'https://avatars.githubusercontent.com/u/12345?v=4', + }, + } + + const mockPullRequestMerged: PullRequest = { + id: '2', + title: 'Fix critical bug in authentication', + state: 'closed', + url: 'https://github.com/test/repo/pull/2', + createdAt: '2024-01-10', + mergedAt: '2024-01-12', + author: { + login: 'Golovanova', + avatarUrl: 'https://avatars.githubusercontent.com/u/54321?v=4', + }, + } + + const mockPullRequestClosed: PullRequest = { + id: '3', + title: 'Rejected feature proposal', + state: 'closed', + url: 'https://github.com/test/repo/pull/3', + createdAt: '2024-01-05', + mergedAt: null, + author: { + login: 'Oleksiuk', + avatarUrl: 'https://avatars.githubusercontent.com/u/99999?v=4', + }, + } + + const mockPullRequestNoAuthor: PullRequest = { + id: '4', + title: 'Unknown author PR', + state: 'open', + url: 'https://github.com/test/repo/pull/4', + createdAt: '2024-01-20', + mergedAt: null, + author: { + login: null, + avatarUrl: null, + }, + } + + describe('getPRStatus function', () => { + test('returns correct status for merged PR', () => { + const status = getPRStatus(mockPullRequestMerged) + expect(status.label).toBe('Merged') + expect(status.backgroundColor).toBe('#8657E5') + }) + + test('returns correct status for closed PR', () => { + const status = getPRStatus(mockPullRequestClosed) + expect(status.label).toBe('Closed') + expect(status.backgroundColor).toBe('#DA3633') + }) + + test('returns correct status for open PR', () => { + const status = getPRStatus(mockPullRequestOpen) + expect(status.label).toBe('Open') + expect(status.backgroundColor).toBe('#238636') + }) + }) + + describe('MentorshipPullRequest component rendering', () => { + test('renders open PR with all details', () => { + render() + expect(screen.getByText('Add new feature to dashboard')).toBeInTheDocument() + expect(screen.getByText(/by testuser/)).toBeInTheDocument() + expect(screen.getByText('Open')).toBeInTheDocument() + }) + + test('renders merged PR with merged status', () => { + render() + expect(screen.getByText('Fix critical bug in authentication')).toBeInTheDocument() + expect(screen.getByText(/by Golovanova/)).toBeInTheDocument() + expect(screen.getByText('Merged')).toBeInTheDocument() + }) + + test('renders closed PR with closed status', () => { + render() + expect(screen.getByText('Rejected feature proposal')).toBeInTheDocument() + expect(screen.getByText(/by Oleksiuk/)).toBeInTheDocument() + expect(screen.getByText('Closed')).toBeInTheDocument() + }) + + test('renders PR with author avatar', () => { + render() + const avatar = screen.getByAltText('testuser') + expect(avatar).toBeInTheDocument() + expect(avatar).toHaveAttribute('src') + }) + + test('renders placeholder when author has no avatar URL', () => { + const { container } = render() + // When no avatar URL, a div placeholder should be rendered instead of Image + const images = container.querySelectorAll('img') + // Only the TruncatedText link should have an image, not the avatar + expect(images.length).toBeLessThan(2) + }) + + test('renders Unknown when author login is null', () => { + render() + expect(screen.getByText(/by Unknown/)).toBeInTheDocument() + }) + + test('renders PR title as a link with correct href', () => { + render() + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', mockPullRequestOpen.url) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + test('renders date in correct format', () => { + render() + const dateStr = new Date('2024-01-15').toLocaleDateString() + expect(screen.getByText(new RegExp(dateStr))).toBeInTheDocument() + }) + + test('applies correct styling to status badge for open PR', () => { + render() + const badge = screen.getByText('Open') + expect(badge).toHaveStyle('backgroundColor: #238636') + expect(badge).toHaveClass('text-white') + expect(badge).toHaveClass('text-xs') + expect(badge).toHaveClass('font-medium') + }) + + test('applies correct styling to status badge for merged PR', () => { + render() + const badge = screen.getByText('Merged') + expect(badge).toHaveStyle('backgroundColor: #8657E5') + }) + + test('applies correct styling to status badge for closed PR', () => { + render() + const badge = screen.getByText('Closed') + expect(badge).toHaveStyle('backgroundColor: #DA3633') + }) + + test('renders with PR link that opens in new tab', () => { + render() + const links = screen.getAllByRole('link') + expect(links[0]).toHaveAttribute('target', '_blank') + expect(links[0]).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) +}) diff --git a/frontend/__tests__/unit/components/MetricsPDFButton.test.tsx b/frontend/__tests__/unit/components/MetricsPDFButton.test.tsx new file mode 100644 index 0000000000..4d99916e5f --- /dev/null +++ b/frontend/__tests__/unit/components/MetricsPDFButton.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { fetchMetricsPDF } from 'server/fetchMetricsPDF' +import MetricsPDFButton from 'components/MetricsPDFButton' + +jest.mock('server/fetchMetricsPDF', () => ({ + fetchMetricsPDF: jest.fn(), +})) + +jest.mock('@heroui/tooltip', () => ({ + Tooltip: ({ children, content }: { children: React.ReactNode; content: string }) => ( +
+ {children} +
+ ), +})) + +describe('MetricsPDFButton', () => { + const mockFetchMetricsPDF = fetchMetricsPDF as jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders the download icon with tooltip', () => { + render() + + const tooltip = screen.getByTestId('tooltip') + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute('title', 'Download as PDF') + }) + + it('calls fetchMetricsPDF when icon is clicked', async () => { + mockFetchMetricsPDF.mockResolvedValueOnce(undefined) + + render() + + const icon = screen.getByTestId('tooltip').querySelector('svg') + expect(icon).toBeInTheDocument() + + fireEvent.click(icon!) + + await waitFor(() => { + expect(mockFetchMetricsPDF).toHaveBeenCalledWith('/api/metrics', 'metrics.pdf') + }) + }) + + it('passes correct path and fileName props', async () => { + mockFetchMetricsPDF.mockResolvedValueOnce(undefined) + + render() + + const icon = screen.getByTestId('tooltip').querySelector('svg') + fireEvent.click(icon!) + + await waitFor(() => { + expect(mockFetchMetricsPDF).toHaveBeenCalledWith('/custom/path', 'custom-file.pdf') + }) + }) +}) diff --git a/frontend/__tests__/unit/components/ModuleCard.test.tsx b/frontend/__tests__/unit/components/ModuleCard.test.tsx new file mode 100644 index 0000000000..000f93a8a7 --- /dev/null +++ b/frontend/__tests__/unit/components/ModuleCard.test.tsx @@ -0,0 +1,731 @@ +/** + * @file Complete unit tests for the ModuleCard component + * Targeting 90-95% code coverage. + */ +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' +import { ExperienceLevelEnum } from 'types/__generated__/graphql' +import type { Module } from 'types/mentorship' +import ModuleCard, { getSimpleDuration } from 'components/ModuleCard' + +// Mock next/navigation +const mockPathname = jest.fn() +jest.mock('next/navigation', () => ({ + usePathname: () => mockPathname(), +})) + +// Mock next/image +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ + src, + alt, + title, + height, + width, + className, + }: { + src: string + alt: string + title?: string + height?: number + width?: number + className?: string + }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})) + +// Mock next/link +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ + children, + href, + className, + }: { + children: React.ReactNode + href: string + className?: string + }) => ( + + {children} + + ), +})) + +// Mock react-icons +jest.mock('react-icons/fa6', () => ({ + FaChevronDown: (props: React.SVGProps) => ( + + ), + FaChevronUp: (props: React.SVGProps) => ( + + ), + FaTurnUp: (props: React.SVGProps) => , + FaCalendar: (props: React.SVGProps) => ( + + ), + FaHourglassHalf: (props: React.SVGProps) => ( + + ), +})) + +// Mock lodash capitalize +jest.mock('lodash', () => ({ + capitalize: (str: string) => + str ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : '', +})) + +// Mock SingleModuleCard +jest.mock('components/SingleModuleCard', () => ({ + __esModule: true, + default: ({ + module, + accessLevel, + admins, + }: { + module: Module + accessLevel?: string + admins?: { login: string }[] + }) => ( +
+ Single Module: {module.name} + {admins && {admins.length}} +
+ ), +})) + +// Mock formatDate utility +jest.mock('utils/dateFormatter', () => ({ + formatDate: (date: string | number) => { + if (!date) return 'N/A' + const d = typeof date === 'number' ? new Date(date * 1000) : new Date(date) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + }, +})) + +// Mock components +jest.mock('components/InfoItem', () => ({ + TextInfoItem: ({ + icon: Icon, + label, + value, + }: { + icon: React.ComponentType<{ className?: string }> + label: string + value: string + }) => ( +
+ + {label}: + {value} +
+ ), +})) + +jest.mock('components/TruncatedText', () => ({ + TruncatedText: ({ text }: { text: string }) => {text}, +})) + +describe('ModuleCard', () => { + const createMockModule = (overrides: Partial = {}): Module => ({ + id: '1', + key: 'test-module', + name: 'Test Module', + description: 'Test description', + experienceLevel: ExperienceLevelEnum.Beginner, + startedAt: '2024-01-01T00:00:00Z', + endedAt: '2024-03-01T00:00:00Z', + mentors: [], + mentees: [], + ...overrides, + }) + + const createMockContributor = (login: string, avatarUrl?: string, name?: string) => ({ + id: `id-${login}`, + login, + name: name || login, + avatarUrl: avatarUrl || `https://github.com/${login}.png`, + }) + + beforeEach(() => { + jest.clearAllMocks() + mockPathname.mockReturnValue('/my/mentorship/programs/test-program') + }) + + describe('Single Module Rendering', () => { + it('renders SingleModuleCard when given exactly one module', () => { + const modules = [createMockModule()] + + render() + + expect(screen.getByTestId('single-module-card')).toBeInTheDocument() + expect(screen.getByText('Single Module: Test Module')).toBeInTheDocument() + }) + + it('passes accessLevel to SingleModuleCard', () => { + const modules = [createMockModule()] + + render() + + const singleModuleCard = screen.getByTestId('single-module-card') + expect(singleModuleCard).toHaveAttribute('data-access-level', 'admin') + }) + + it('passes admins to SingleModuleCard', () => { + const modules = [createMockModule()] + const admins = [{ login: 'admin1' }, { login: 'admin2' }] + + render() + + expect(screen.getByTestId('admins-count')).toHaveTextContent('2') + }) + }) + + describe('Multiple Modules Rendering', () => { + it('renders multiple modules in a grid', () => { + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + expect(screen.queryByTestId('single-module-card')).not.toBeInTheDocument() + expect(screen.getByText('Module 1')).toBeInTheDocument() + expect(screen.getByText('Module 2')).toBeInTheDocument() + }) + + it('shows only first 4 modules initially when more than 4 modules', () => { + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + createMockModule({ key: 'mod3', name: 'Module 3' }), + createMockModule({ key: 'mod4', name: 'Module 4' }), + createMockModule({ key: 'mod5', name: 'Module 5' }), + createMockModule({ key: 'mod6', name: 'Module 6' }), + ] + + render() + + expect(screen.getByText('Module 1')).toBeInTheDocument() + expect(screen.getByText('Module 2')).toBeInTheDocument() + expect(screen.getByText('Module 3')).toBeInTheDocument() + expect(screen.getByText('Module 4')).toBeInTheDocument() + expect(screen.queryByText('Module 5')).not.toBeInTheDocument() + expect(screen.queryByText('Module 6')).not.toBeInTheDocument() + }) + + it('shows "Show more" button when more than 4 modules', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + expect(screen.getByText('Show more')).toBeInTheDocument() + expect(screen.getByTestId('chevron-down')).toBeInTheDocument() + }) + + it('does not show "Show more" button when 4 or fewer modules', () => { + const modules = Array.from({ length: 4 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + expect(screen.queryByText('Show more')).not.toBeInTheDocument() + }) + + it('toggles between showing all and showing limited modules on click', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + // Verify initial state + expect(screen.queryByText('Module 5')).not.toBeInTheDocument() + + // Click to show more + const showMoreButton = screen.getByText('Show more') + fireEvent.click(showMoreButton) + + // All modules should be visible + expect(screen.getByText('Module 5')).toBeInTheDocument() + expect(screen.getByText('Module 6')).toBeInTheDocument() + expect(screen.getByText('Show less')).toBeInTheDocument() + expect(screen.getByTestId('chevron-up')).toBeInTheDocument() + + // Click to show less + const showLessButton = screen.getByText('Show less') + fireEvent.click(showLessButton) + + // Should be back to initial state + expect(screen.queryByText('Module 5')).not.toBeInTheDocument() + expect(screen.getByText('Show more')).toBeInTheDocument() + }) + + it('handles keyboard navigation with Enter key', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + const showMoreButton = screen.getByRole('button') + fireEvent.keyDown(showMoreButton, { key: 'Enter', preventDefault: jest.fn() }) + + expect(screen.getByText('Module 5')).toBeInTheDocument() + expect(screen.getByText('Show less')).toBeInTheDocument() + }) + + it('handles keyboard navigation with Space key', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + const showMoreButton = screen.getByRole('button') + fireEvent.keyDown(showMoreButton, { key: ' ', preventDefault: jest.fn() }) + + expect(screen.getByText('Module 5')).toBeInTheDocument() + expect(screen.getByText('Show less')).toBeInTheDocument() + }) + + it('ignores other keyboard keys', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + const showMoreButton = screen.getByRole('button') + fireEvent.keyDown(showMoreButton, { key: 'Tab' }) + + // Should still show initial state + expect(screen.queryByText('Module 5')).not.toBeInTheDocument() + expect(screen.getByText('Show more')).toBeInTheDocument() + }) + + it('uses module.key for unique key if available, otherwise uses module.id', () => { + const modules = [ + createMockModule({ key: 'mod-key-1', id: 'mod-id-1', name: 'Module With Key' }), + createMockModule({ key: '', id: 'mod-id-2', name: 'Module Without Key' }), + ] + + render() + + expect(screen.getByText('Module With Key')).toBeInTheDocument() + expect(screen.getByText('Module Without Key')).toBeInTheDocument() + }) + }) + + describe('ModuleItem Component', () => { + it('renders module name with link to module details page', () => { + const modules = [ + createMockModule({ key: 'test-mod', name: 'Test Module' }), + createMockModule({ key: 'test-mod2', name: 'Test Module 2' }), + ] + + render() + + const links = screen.getAllByTestId('next-link') + const moduleLink = links.find((link) => + link.getAttribute('href')?.includes('/modules/test-mod') + ) + expect(moduleLink).toBeInTheDocument() + }) + + it('renders experience level info item', () => { + const modules = [ + createMockModule({ experienceLevel: ExperienceLevelEnum.Intermediate }), + createMockModule({ key: 'mod2', experienceLevel: ExperienceLevelEnum.Beginner }), + ] + + render() + + const levelItems = screen.getAllByTestId('info-item-level') + expect(levelItems.length).toBeGreaterThan(0) + expect(levelItems[0]).toHaveTextContent('Intermediate') + }) + + it('renders start date info item', () => { + const modules = [createMockModule(), createMockModule({ key: 'mod2' })] + + render() + + const startItems = screen.getAllByTestId('info-item-start') + expect(startItems.length).toBeGreaterThan(0) + }) + + it('renders duration info item', () => { + const modules = [createMockModule(), createMockModule({ key: 'mod2' })] + + render() + + const durationItems = screen.getAllByTestId('info-item-duration') + expect(durationItems.length).toBeGreaterThan(0) + }) + }) + + describe('Mentors and Mentees Display', () => { + it('does not render mentors/mentees section when both are empty', () => { + const modules = [ + createMockModule({ mentors: [], mentees: [] }), + createMockModule({ key: 'mod2', mentors: [], mentees: [] }), + ] + + render() + + expect(screen.queryByText('Mentors')).not.toBeInTheDocument() + expect(screen.queryByText('Mentees')).not.toBeInTheDocument() + }) + + it('renders mentors section when mentors have avatars', () => { + const modules = [ + createMockModule({ + mentors: [ + createMockContributor('mentor1', 'https://example.com/avatar1.png'), + createMockContributor('mentor2', 'https://example.com/avatar2.png'), + ], + }), + createMockModule({ key: 'mod2' }), + ] + + render() + + expect(screen.getByText('Mentors')).toBeInTheDocument() + }) + + it('renders mentees section when mentees have avatars', () => { + const modules = [ + createMockModule({ + mentees: [ + createMockContributor('mentee1', 'https://example.com/avatar1.png'), + createMockContributor('mentee2', 'https://example.com/avatar2.png'), + ], + }), + createMockModule({ key: 'mod2' }), + ] + + render() + + expect(screen.getByText('Mentees')).toBeInTheDocument() + }) + + it('shows only first 4 mentor avatars and +N for remaining', () => { + const mentors = Array.from({ length: 6 }, (_, i) => + createMockContributor(`mentor${i + 1}`, `https://example.com/avatar${i + 1}.png`) + ) + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + // Check for +2 indicator (6 mentors - 4 shown = 2 remaining) + expect(screen.getByText('+2')).toBeInTheDocument() + }) + + it('shows only first 4 mentee avatars and +N for remaining', () => { + const mentees = Array.from({ length: 7 }, (_, i) => + createMockContributor(`mentee${i + 1}`, `https://example.com/avatar${i + 1}.png`) + ) + const modules = [createMockModule({ mentees }), createMockModule({ key: 'mod2' })] + + render() + + // Check for +3 indicator (7 mentees - 4 shown = 3 remaining) + expect(screen.getByText('+3')).toBeInTheDocument() + }) + + it('does not show +N when 4 or fewer mentors', () => { + const mentors = Array.from({ length: 4 }, (_, i) => + createMockContributor(`mentor${i + 1}`, `https://example.com/avatar${i + 1}.png`) + ) + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument() + }) + + it('filters out mentors without avatar URLs', () => { + const mentors = [ + createMockContributor('mentor1', 'https://example.com/avatar1.png'), + { id: 'id-mentor2', login: 'mentor2', name: 'Mentor 2', avatarUrl: '' }, // No avatar + { + id: 'id-mentor3', + login: 'mentor3', + name: 'Mentor 3', + avatarUrl: undefined as unknown as string, + }, // Undefined avatar + ] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + // Should only show mentors with valid avatars + const images = screen.getAllByTestId('next-image') + expect(images.length).toBe(1) // Only mentor1 has avatar + }) + + it('displays mentor avatar with proper size parameter in URL', () => { + const mentors = [createMockContributor('mentor1', 'https://example.com/avatar1.png')] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const images = screen.getAllByTestId('next-image') + const mentorImage = images[0] + expect(mentorImage.getAttribute('src')).toContain('s=60') + }) + + it('handles avatar URL with existing query parameters', () => { + const mentors = [createMockContributor('mentor1', 'https://example.com/avatar1.png?v=2')] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const images = screen.getAllByTestId('next-image') + const mentorImage = images[0] + expect(mentorImage.getAttribute('src')).toContain('s=60') + }) + + it('handles invalid avatar URL gracefully', () => { + const mentors = [createMockContributor('mentor1', 'invalid-url')] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const images = screen.getAllByTestId('next-image') + // Should fall back to appending ?s=60 + expect(images[0].getAttribute('src')).toContain('s=60') + }) + + it('links mentee to member page when not on /my/mentorship path', () => { + mockPathname.mockReturnValue('/mentorship/programs/test-program') + const mentees = [createMockContributor('mentee1', 'https://example.com/avatar1.png')] + const modules = [createMockModule({ mentees }), createMockModule({ key: 'mod2' })] + + render() + + const links = screen.getAllByTestId('next-link') + const menteeLink = links.find((link) => + link.getAttribute('href')?.includes('/members/mentee1') + ) + expect(menteeLink).toBeInTheDocument() + }) + + it('links mentee to mentorship details page when on /my/mentorship path', () => { + mockPathname.mockReturnValue('/my/mentorship/programs/test-program') + const mentees = [createMockContributor('mentee1', 'https://example.com/avatar1.png')] + const modules = [ + createMockModule({ key: 'test-module', mentees }), + createMockModule({ key: 'mod2' }), + ] + + render() + + const links = screen.getAllByTestId('next-link') + const menteeLink = links.find((link) => + link + .getAttribute('href') + ?.includes('/my/mentorship/programs/test-program/modules/test-module/mentees/mentee1') + ) + expect(menteeLink).toBeInTheDocument() + }) + + it('links mentor to member page regardless of path', () => { + mockPathname.mockReturnValue('/my/mentorship/programs/test-program') + const mentors = [createMockContributor('mentor1', 'https://example.com/avatar1.png')] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const links = screen.getAllByTestId('next-link') + const mentorLink = links.find((link) => + link.getAttribute('href')?.includes('/members/mentor1') + ) + expect(mentorLink).toBeInTheDocument() + }) + + it('renders both mentors and mentees sections with border separator when both exist', () => { + const modules = [ + createMockModule({ + mentors: [createMockContributor('mentor1', 'https://example.com/avatar1.png')], + mentees: [createMockContributor('mentee1', 'https://example.com/avatar2.png')], + }), + createMockModule({ key: 'mod2' }), + ] + + render() + + expect(screen.getByText('Mentors')).toBeInTheDocument() + expect(screen.getByText('Mentees')).toBeInTheDocument() + }) + + it('uses contributor name for avatar alt and title when available', () => { + const mentors = [ + createMockContributor('mentor1', 'https://example.com/avatar1.png', 'John Doe'), + ] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const image = screen.getAllByTestId('next-image')[0] + expect(image.getAttribute('alt')).toBe('John Doe') + expect(image.getAttribute('title')).toBe('John Doe') + }) + + it('falls back to login for avatar alt and title when name is not available', () => { + const mentors = [ + { + id: 'id-mentor1', + login: 'mentor1', + name: '', + avatarUrl: 'https://example.com/avatar1.png', + }, + ] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const image = screen.getAllByTestId('next-image')[0] + expect(image.getAttribute('alt')).toBe('mentor1') + expect(image.getAttribute('title')).toBe('mentor1') + }) + }) + + describe('Path Handling', () => { + it('extracts programKey from pathname correctly', () => { + mockPathname.mockReturnValue('/my/mentorship/programs/program-123/modules') + const modules = [ + createMockModule({ + key: 'mod1', + mentees: [createMockContributor('mentee1', 'https://example.com/avatar.png')], + }), + createMockModule({ key: 'mod2' }), + ] + + render() + + const links = screen.getAllByTestId('next-link') + const menteeLink = links.find((link) => + link.getAttribute('href')?.includes('/programs/program-123/modules/') + ) + expect(menteeLink).toBeInTheDocument() + }) + + it('handles undefined pathname gracefully', () => { + mockPathname.mockReturnValue(undefined) + const modules = [createMockModule(), createMockModule({ key: 'mod2' })] + + // Should not throw + expect(() => render()).not.toThrow() + }) + + it('handles pathname without /programs/ segment', () => { + mockPathname.mockReturnValue('/some/other/path') + const modules = [createMockModule(), createMockModule({ key: 'mod2' })] + + render() + + const moduleElements = screen.getAllByText('Test Module') + expect(moduleElements.length).toBe(2) + }) + }) +}) + +describe('getSimpleDuration', () => { + it('returns N/A when start is missing', () => { + expect(getSimpleDuration('', '2024-03-01T00:00:00Z')).toBe('N/A') + }) + + it('returns N/A when end is missing', () => { + expect(getSimpleDuration('2024-01-01T00:00:00Z', '')).toBe('N/A') + }) + + it('returns N/A when both start and end are missing', () => { + expect(getSimpleDuration('', '')).toBe('N/A') + }) + + it('returns N/A for falsy numeric values', () => { + expect(getSimpleDuration(0, 1709251200)).toBe('N/A') + }) + + it('calculates duration correctly for string dates', () => { + // 2 months = approximately 8-9 weeks + const result = getSimpleDuration('2024-01-01T00:00:00Z', '2024-03-01T00:00:00Z') + expect(result).toMatch(/^\d+ weeks?$/) + }) + + it('calculates duration correctly for numeric timestamps (Unix seconds)', () => { + // Jan 1, 2024 to Jan 22, 2024 = 3 weeks + const start = 1704067200 // Jan 1, 2024 + const end = 1705881600 // Jan 22, 2024 + const result = getSimpleDuration(start, end) + expect(result).toBe('3 weeks') + }) + + it('returns "1 week" for exactly 7 days', () => { + const start = 1704067200 // Jan 1, 2024 + const end = 1704672000 // Jan 8, 2024 + const result = getSimpleDuration(start, end) + expect(result).toBe('1 week') + }) + + it('rounds up partial weeks', () => { + // 10 days should be 2 weeks (ceil(10/7) = 2) + const start = 1704067200 // Jan 1, 2024 + const end = 1704931200 // Jan 11, 2024 + const result = getSimpleDuration(start, end) + expect(result).toBe('2 weeks') + }) + + it('returns "Invalid duration" for invalid start date string', () => { + expect(getSimpleDuration('invalid-date', '2024-03-01T00:00:00Z')).toBe('Invalid duration') + }) + + it('returns "Invalid duration" for invalid end date string', () => { + expect(getSimpleDuration('2024-01-01T00:00:00Z', 'invalid-date')).toBe('Invalid duration') + }) + + it('returns "Invalid duration" when both dates are invalid', () => { + expect(getSimpleDuration('not-a-date', 'also-not-a-date')).toBe('Invalid duration') + }) + + it('handles mixed string and number inputs', () => { + const result = getSimpleDuration('2024-01-01T00:00:00Z', 1709251200) // String start, number end + expect(result).toMatch(/^\d+ weeks?$/) + }) + + it('handles very short durations (less than a week)', () => { + // 3 days should round up to 1 week + const start = 1704067200 // Jan 1, 2024 + const end = 1704326400 // Jan 4, 2024 + const result = getSimpleDuration(start, end) + expect(result).toBe('1 week') + }) + + it('handles negative duration (end before start)', () => { + const result = getSimpleDuration('2024-03-01T00:00:00Z', '2024-01-01T00:00:00Z') + // When end date is before start date, function returns negative weeks + expect(result).toBe('-8 weeks') + }) +}) diff --git a/frontend/__tests__/unit/components/ModuleForm.test.tsx b/frontend/__tests__/unit/components/ModuleForm.test.tsx new file mode 100644 index 0000000000..fb21e6f46a --- /dev/null +++ b/frontend/__tests__/unit/components/ModuleForm.test.tsx @@ -0,0 +1,843 @@ +/** + * @file Comprehensive unit tests for the ModuleForm component + * Targeting 90-95% code coverage. + */ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' +import ModuleForm, { ProjectSelector } from 'components/ModuleForm' + +// Mock next/navigation +const mockBack = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + back: mockBack, + }), +})) + +// Mock apollo client hooks +const mockQuery = jest.fn() +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useApolloClient: () => ({ + query: mockQuery, + }), +})) + +// Mock heroui components +jest.mock('@heroui/react', () => ({ + Autocomplete: ({ + children, + inputValue, + _selectedKey, + onInputChange, + onSelectionChange, + isInvalid, + errorMessage, + isLoading, + label, + id, + }: { + children: React.ReactNode + inputValue?: string + _selectedKey?: string | null + onInputChange?: (value: string) => void + onSelectionChange?: (key: React.Key | Set | 'all') => void + isInvalid?: boolean + errorMessage?: string + isLoading?: boolean + label?: string + id?: string + }) => ( +
+ + onInputChange?.(e.target.value)} + data-loading={isLoading} + data-invalid={isInvalid} + /> + {errorMessage && {errorMessage}} +
{children}
+ + + + +
+ ), + AutocompleteItem: ({ + children, + textValue, + }: { + children: React.ReactNode + textValue?: string + }) => ( +
+ {children} +
+ ), +})) + +jest.mock('@heroui/select', () => ({ + Select: ({ + children, + selectedKeys, + onSelectionChange, + isInvalid, + errorMessage, + label, + id, + }: { + children: React.ReactNode + selectedKeys?: Set + onSelectionChange?: (keys: React.Key | Set | 'all') => void + isInvalid?: boolean + errorMessage?: string + label?: string + id?: string + }) => ( +
+ + + {errorMessage && {errorMessage}} + + + +
+ ), + SelectItem: ({ children }: { children: React.ReactNode }) => ( + + ), +})) + +// Mock form components +jest.mock('components/forms/shared/FormButtons', () => ({ + FormButtons: ({ loading, submitText }: { loading: boolean; submitText?: string }) => ( +
+ + +
+ ), +})) + +jest.mock('components/forms/shared/FormDateInput', () => ({ + FormDateInput: ({ + id, + label, + value, + onValueChange, + error, + touched, + }: { + id: string + label: string + value: string + onValueChange: (value: string) => void + error?: string + touched?: boolean + }) => ( +
+ + onValueChange(e.target.value)} + data-error={error} + data-touched={touched} + /> + {touched && error && {error}} +
+ ), +})) + +jest.mock('components/forms/shared/FormTextarea', () => ({ + FormTextarea: ({ + id, + label, + value, + onChange, + error, + touched, + }: { + id: string + label: string + value: string + onChange: (e: React.ChangeEvent) => void + error?: string + touched?: boolean + }) => ( +
+ +