Skip to content
26 changes: 21 additions & 5 deletions frontend/__tests__/unit/components/ProgramCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ jest.mock('components/ActionButton', () => ({
),
}))

jest.mock('@heroui/tooltip', () => ({
Tooltip: ({ children, content }: { children: React.ReactNode; content: string }) => (
<div data-testid="tooltip" title={content}>
{children}
</div>
),
}))

describe('ProgramCard', () => {
const mockOnEdit = jest.fn()
const mockOnView = jest.fn()
Expand Down Expand Up @@ -178,23 +186,31 @@ describe('ProgramCard', () => {
})

describe('Description Handling', () => {
it('truncates long descriptions to 100 characters', () => {
const longDescription = 'A'.repeat(150)
it('renders long descriptions with line-clamp-6 CSS class', () => {
const longDescription = 'A'.repeat(300) // Long enough to trigger line clamping
const longDescProgram = { ...baseMockProgram, description: longDescription }

render(<ProgramCard program={longDescProgram} onView={mockOnView} accessLevel="user" />)

const expectedText = 'A'.repeat(100) + '...'
expect(screen.getByText(expectedText)).toBeInTheDocument()
// Check that the full description is rendered (CSS handles the visual truncation)
expect(screen.getByText(longDescription)).toBeInTheDocument()

// Check that the paragraph has the line-clamp-6 class
const descriptionElement = screen.getByText(longDescription)
expect(descriptionElement).toHaveClass('line-clamp-6')
})

it('shows full description when under 100 characters', () => {
it('shows full description when short', () => {
const shortDescription = 'Short description'
const shortDescProgram = { ...baseMockProgram, description: shortDescription }

render(<ProgramCard program={shortDescProgram} onView={mockOnView} accessLevel="user" />)

expect(screen.getByText('Short description')).toBeInTheDocument()

// Check that it still has line-clamp-6 class for consistency
const descriptionElement = screen.getByText('Short description')
expect(descriptionElement).toHaveClass('line-clamp-6')
})

it('shows fallback text when description is empty', () => {
Expand Down
213 changes: 31 additions & 182 deletions frontend/__tests__/unit/components/SingleModuleCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { faUsers } from '@fortawesome/free-solid-svg-icons'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import React from 'react'
import { render } from 'wrappers/testUtil'
import type { ExtendedSession } from 'types/auth'
import type { Module } from 'types/mentorship'
import { ExperienceLevelEnum, ProgramStatusEnum } from 'types/mentorship'
import SingleModuleCard from 'components/SingleModuleCard'
Expand Down Expand Up @@ -111,16 +110,6 @@ const mockModule: Module = {

const mockAdmins = [{ login: 'admin1' }, { login: 'admin2' }]

const mockSessionData: ExtendedSession = {
user: {
login: 'admin1',
isLeader: true,
email: '[email protected]',
image: 'https://example.com/admin-avatar.jpg',
},
expires: '2024-12-31T23:59:59Z',
}

describe('SingleModuleCard', () => {
beforeEach(() => {
jest.clearAllMocks()
Expand All @@ -146,7 +135,6 @@ describe('SingleModuleCard', () => {
expect(screen.getByText('Test Module')).toBeInTheDocument()
expect(screen.getByText('This is a test module description')).toBeInTheDocument()
expect(screen.getByTestId('icon-users')).toBeInTheDocument()
expect(screen.getByTestId('icon-ellipsis')).toBeInTheDocument()
})

it('renders module details correctly', () => {
Expand Down Expand Up @@ -183,128 +171,31 @@ describe('SingleModuleCard', () => {
})
})

describe('Dropdown Menu', () => {
it('opens dropdown when ellipsis button is clicked', () => {
render(<SingleModuleCard module={mockModule} />)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)

expect(screen.getByText('View Module')).toBeInTheDocument()
})

it('closes dropdown when clicking outside', async () => {
render(<SingleModuleCard module={mockModule} />)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)

expect(screen.getByText('View Module')).toBeInTheDocument()

// Click outside the dropdown
fireEvent.mouseDown(document.body)

await waitFor(() => {
expect(screen.queryByText('View Module')).not.toBeInTheDocument()
})
})

it('navigates to view module when View Module is clicked', () => {
describe('Simplified Interface', () => {
it('focuses on content display only', () => {
render(<SingleModuleCard module={mockModule} />)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)

const viewButton = screen.getByText('View Module')
fireEvent.click(viewButton)

expect(mockPush).toHaveBeenCalledWith('//modules/test-module')
})

it('shows only View Module option for non-admin users', () => {
render(
<SingleModuleCard
module={mockModule}
showEdit={true}
accessLevel="user"
admins={mockAdmins}
/>
)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)
// Should display core content
expect(screen.getByText('Test Module')).toBeInTheDocument()
expect(screen.getByText('This is a test module description')).toBeInTheDocument()
expect(screen.getByText('Experience Level:')).toBeInTheDocument()

expect(screen.getByText('View Module')).toBeInTheDocument()
expect(screen.queryByText('Edit Module')).not.toBeInTheDocument()
expect(screen.queryByText('Create Module')).not.toBeInTheDocument()
// Should have clickable title for navigation
const moduleLink = screen.getByTestId('module-link')
expect(moduleLink).toHaveAttribute('href', '//modules/test-module')
})
})

describe('Admin Functionality', () => {
beforeEach(() => {
mockUseSession.mockReturnValue({
data: mockSessionData,
status: 'authenticated',
update: jest.fn(),
})
})

it('shows Edit Module option for admin users when showEdit is true', () => {
render(
<SingleModuleCard
module={mockModule}
showEdit={true}
accessLevel="admin"
admins={mockAdmins}
/>
)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)

expect(screen.getByText('View Module')).toBeInTheDocument()
expect(screen.getByText('Edit Module')).toBeInTheDocument()
expect(screen.getByText('Create Module')).toBeInTheDocument()
})

it('does not show Edit Module option when showEdit is false', () => {
render(
<SingleModuleCard
module={mockModule}
showEdit={false}
accessLevel="admin"
admins={mockAdmins}
/>
)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)

expect(screen.getByText('View Module')).toBeInTheDocument()
expect(screen.queryByText('Edit Module')).not.toBeInTheDocument()
expect(screen.getByText('Create Module')).toBeInTheDocument()
})

it('navigates to edit module when Edit Module is clicked', () => {
render(
<SingleModuleCard
module={mockModule}
showEdit={true}
accessLevel="admin"
admins={mockAdmins}
/>
)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)

const editButton = screen.getByText('Edit Module')
fireEvent.click(editButton)
describe('Props Handling', () => {
it('renders correctly with minimal props', () => {
render(<SingleModuleCard module={mockModule} />)

expect(mockPush).toHaveBeenCalledWith('//modules/test-module/edit')
expect(screen.getByText('Test Module')).toBeInTheDocument()
expect(screen.getByText('This is a test module description')).toBeInTheDocument()
})

it('navigates to create module when Create Module is clicked', () => {
it('ignores admin-related props since menu is removed', () => {
// These props are now ignored but should not cause errors
render(
<SingleModuleCard
module={mockModule}
Expand All @@ -314,13 +205,7 @@ describe('SingleModuleCard', () => {
/>
)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)

const createButton = screen.getByText('Create Module')
fireEvent.click(createButton)

expect(mockPush).toHaveBeenCalledWith('//modules/create')
expect(screen.getByText('Test Module')).toBeInTheDocument()
})
})

Expand All @@ -338,67 +223,31 @@ describe('SingleModuleCard', () => {
expect(screen.queryByTestId('top-contributors-list')).not.toBeInTheDocument()
})

it('handles undefined admins array', () => {
it('handles undefined admins array gracefully', () => {
render(<SingleModuleCard module={mockModule} showEdit={true} accessLevel="admin" />)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)

expect(screen.getByText('View Module')).toBeInTheDocument()
expect(screen.queryByText('Edit Module')).not.toBeInTheDocument()
})

it('handles null session data', () => {
mockUseSession.mockReturnValue({
data: null,
status: 'unauthenticated',
update: jest.fn(),
})

render(
<SingleModuleCard
module={mockModule}
showEdit={true}
accessLevel="admin"
admins={mockAdmins}
/>
)

const ellipsisButton = screen.getByRole('button')
fireEvent.click(ellipsisButton)

expect(screen.getByText('View Module')).toBeInTheDocument()
expect(screen.queryByText('Edit Module')).not.toBeInTheDocument()
// Should render without errors even with admin props
expect(screen.getByText('Test Module')).toBeInTheDocument()
})
})

describe('Accessibility', () => {
it('has proper button roles and interactions', () => {
it('has accessible link for module navigation', () => {
render(<SingleModuleCard module={mockModule} />)

const ellipsisButton = screen.getByRole('button')
expect(ellipsisButton).toBeInTheDocument()

fireEvent.click(ellipsisButton)

const viewButton = screen.getByText('View Module')
expect(viewButton.closest('button')).toBeInTheDocument()
const moduleLink = screen.getByTestId('module-link')
expect(moduleLink).toBeInTheDocument()
expect(moduleLink).toHaveAttribute('href', '//modules/test-module')
expect(moduleLink).toHaveAttribute('target', '_blank')
expect(moduleLink).toHaveAttribute('rel', 'noopener noreferrer')
})

it('supports keyboard navigation', () => {
it('has proper heading structure', () => {
render(<SingleModuleCard module={mockModule} />)

const ellipsisButton = screen.getByRole('button')

// Focus the button
ellipsisButton.focus()
expect(ellipsisButton).toHaveFocus()

// Press Enter to open dropdown
fireEvent.keyDown(ellipsisButton, { key: 'Enter', code: 'Enter' })
fireEvent.click(ellipsisButton) // Simulate the click that would happen

expect(screen.getByText('View Module')).toBeInTheDocument()
const moduleTitle = screen.getByRole('heading', { level: 1 })
expect(moduleTitle).toBeInTheDocument()
expect(moduleTitle).toHaveTextContent('Test Module')
})
})

Expand Down
2 changes: 1 addition & 1 deletion frontend/__tests__/unit/pages/ProgramDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('ProgramDetailsPage', () => {
expect(screen.getByText('Jan 1, 2025')).toBeInTheDocument()
expect(screen.getByText('Dec 31, 2025')).toBeInTheDocument()
expect(screen.getByText('20')).toBeInTheDocument()
expect(screen.getByText('beginner, intermediate')).toBeInTheDocument()
expect(screen.getByText('Beginner, Intermediate')).toBeInTheDocument()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('ProgramDetailsPage', () => {
expect(screen.getByText('Jan 1, 2025')).toBeInTheDocument()
expect(screen.getByText('Dec 31, 2025')).toBeInTheDocument()
expect(screen.getByText('20')).toBeInTheDocument()
expect(screen.getByText('beginner, intermediate')).toBeInTheDocument()
expect(screen.getByText('Beginner, Intermediate')).toBeInTheDocument()
})
})
})
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use client'

import { useQuery } from '@apollo/client'
import upperFirst from 'lodash/upperFirst'
import { useParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { ErrorDisplay, handleAppError } from 'app/global-error'
import { GET_PROGRAM_ADMINS_AND_MODULES } from 'server/queries/moduleQueries'
import type { Module } from 'types/mentorship'
import { titleCaseWord } from 'utils/capitalize'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Import path alias check for utils/capitalize

Using utils/capitalize assumes a baseUrl or alias; this can break in isolated runners (Jest/Storybook) if not configured. Verify aliasing or switch to the project’s standard alias/relative path.

Run:


🏁 Script executed:

#!/bin/bash
# Confirm baseUrl/paths config
rg -n -C2 '"baseUrl"|"paths"' frontend/tsconfig*.json

# See if this alias is used elsewhere consistently
rg -nP -C2 "from ['\"]utils/capitalize['\"]" frontend/src

Length of output: 2094


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== frontend/tsconfig*.json files ==="
ls -la frontend/tsconfig*.json 2>/dev/null || true
echo

echo "=== frontend/tsconfig*.json: baseUrl / paths / extends ==="
rg -n -C2 '"baseUrl"|"paths"|"extends"' frontend/tsconfig*.json || true
echo

echo "=== repo-wide tsconfig paths occurrences ==="
rg -n -C2 '"paths"\s*:' || true
echo

echo "=== frontend/package.json (first 200 lines) ==="
if [ -f frontend/package.json ]; then
  sed -n '1,200p' frontend/package.json
else
  echo "no frontend/package.json"
fi
echo

echo "=== Search for Jest/Vitest/moduleNameMapper/tsconfig-paths/module-alias/module-resolver ==="
rg -n -C3 "moduleNameMapper|module-alias|tsconfig-paths|module-resolver|vitest|jest.config|jest\\.config" || true
echo

echo "=== Search for Storybook config (.storybook) and webpack resolve/alias usage ==="
rg -n -C3 "\\.storybook|webpackFinal|resolve\\s*:\\s*\\{|resolve\\.|alias\\s*:" || true
echo

echo "=== Search for next.config.js or Next webpack customizations ==="
rg -n -C3 "next.config|nextConfig|webpack\\s*:\\s*\\(" || true
echo

echo "=== Look for definitions/locations of titleCaseWord / capitalize file ==="
rg -n -C3 "titleCaseWord|utils/capitalize" || true
echo

echo "=== Search for any explicit 'utils/' path mappings in configs or code ==="
rg -n -C3 "\"utils/|\\butils\\/" || true
echo

Length of output: 23641


Resolve 'utils/capitalize' import — use project alias or add path mapping

frontend/tsconfig.json has baseUrl "." and no "utils/" path mapping; jest only maps "^@/(.)$" → "/src/$1". The file exists at frontend/src/utils/capitalize.ts but imports use "utils/capitalize" in:

  • frontend/src/app/mentorship/programs/[programKey]/page.tsx
  • frontend/src/app/my/mentorship/programs/[programKey]/page.tsx
  • frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx

Either:

  • Change imports to the project alias: '@/utils/capitalize', or
  • Add "utils/": ["src/utils/"] to frontend/tsconfig.json and add a matching entry in frontend/jest.config.ts moduleNameMapper.
🤖 Prompt for AI Agents
frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx
around line 9: the import "utils/capitalize" is unresolved because
frontend/tsconfig.json has no "utils/*" path mapping; replace the import with
the project alias import "@/utils/capitalize" (and update the same pattern in
the other files listed) so the path resolves correctly, or alternatively add
"utils/*": ["src/utils/*"] to frontend/tsconfig.json and add the corresponding
moduleNameMapper entry in frontend/jest.config.ts to keep Jest consistent.

import { formatDate } from 'utils/dateFormatter'
import DetailsCard from 'components/CardDetailsPage'
import LoadingSpinner from 'components/LoadingSpinner'
Expand Down Expand Up @@ -49,7 +49,7 @@ const ModuleDetailsPage = () => {
}

const moduleDetails = [
{ label: 'Experience Level', value: upperFirst(module.experienceLevel) },
{ label: 'Experience Level', value: titleCaseWord(module.experienceLevel) },
{ label: 'Start Date', value: formatDate(module.startedAt) },
{ label: 'End Date', value: formatDate(module.endedAt) },
{
Expand Down
Loading