Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions frontend/__tests__/unit/components/CardDetailsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,13 @@ describe('CardDetailsPage', () => {
expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Languages: JavaScript')
expect(screen.queryByText('Topics:')).not.toBeInTheDocument()
})

it('handles conditional rendering when languages empty but topics present', () => {
render(<CardDetailsPage {...defaultProps} languages={[]} topics={['web']} />)

expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Topics: web')
expect(screen.queryByText('Languages:')).not.toBeInTheDocument()
})
})

describe('Accessibility and Semantic HTML', () => {
Expand Down Expand Up @@ -1611,6 +1618,25 @@ describe('CardDetailsPage', () => {
expect(screen.getByText('Statistics')).toBeInTheDocument()
})

it('handles undefined topics/languages in grid layout', () => {
render(<CardDetailsPage {...defaultProps} languages={['JavaScript']} topics={undefined} />)
expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Languages: JavaScript')
cleanup()
render(<CardDetailsPage {...defaultProps} languages={undefined} topics={['web']} />)
expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Topics: web')
})

it('renders Leaders with Unknown when value is null', () => {
const propsWithNullLeader = {
...defaultProps,
type: 'chapter',
details: [{ label: 'Leaders', value: null }],
}
render(<CardDetailsPage {...propsWithNullLeader} />)
expect(screen.getByText('Leaders:')).toBeInTheDocument()
expect(screen.getByText('Unknown')).toBeInTheDocument()
})

it('handles mixed valid and invalid data in arrays', () => {
const mixedValidInvalidData = {
...defaultProps,
Expand Down Expand Up @@ -2083,6 +2109,39 @@ describe('CardDetailsPage', () => {
expect(screen.getByText('Milestone No Author')).toBeInTheDocument()
})

it('renders milestone author tooltip using login when name is missing', () => {
const milestonesWithAuthorNoName = [
{
author: {
login: 'author-login-only',
name: undefined,
avatarUrl: 'https://example.com/author-avatar.jpg',
},
body: 'Milestone with author no name',
closedIssuesCount: 3,
createdAt: new Date(Date.now() - 10000000).toISOString(),
openIssuesCount: 1,
repositoryName: 'test-repo',
organizationName: 'test-org',
state: 'open',
title: 'Milestone Author No Name',
url: 'https://github.com/test/project/milestone/1',
},
]

const programProps: DetailsCardProps = {
...defaultProps,
type: 'program' as const,
recentMilestones: milestonesWithAuthorNoName,
showAvatar: true,
modules: [],
}

render(<CardDetailsPage {...programProps} />)
expect(screen.getByTestId('mock-tooltip')).toHaveAttribute('title', 'author-login-only')
expect(screen.getByAltText("author-login-only's avatar")).toBeInTheDocument()
})

it('renders milestone title without link when URL is missing', () => {
const milestonesWithoutUrl = [
{
Expand Down Expand Up @@ -2274,6 +2333,52 @@ describe('CardDetailsPage', () => {
expect(screen.getByTestId('entity-actions')).toHaveTextContent('type=module')
})

it('renders EntityActions for module type when user is a mentor but not admin', () => {
const { useSession } = jest.requireMock('next-auth/react')
useSession.mockReturnValue({
data: {
user: {
login: 'mentor-user',
name: 'Mentor User',
email: 'mentor@example.com',
},
},
})

const adminUser = {
id: 'admin-id',
login: 'admin-user',
name: 'Admin User',
avatarUrl: 'https://example.com/admin-avatar.jpg',
}

const mentorUser = {
id: 'mentor-id',
login: 'mentor-user',
name: 'Mentor User',
avatarUrl: 'https://example.com/mentor-avatar.jpg',
}

const moduleProps: DetailsCardProps = {
...defaultProps,
type: 'module' as const,
accessLevel: 'admin',
admins: [adminUser],
mentors: [mentorUser],
programKey: 'test-program',
entityKey: 'test-module',
modules: [],
}

render(<CardDetailsPage {...moduleProps} />)

const entityActions = screen.getByTestId('entity-actions')
expect(entityActions).toBeInTheDocument()
expect(entityActions).toHaveTextContent('type=module')
// isAdmin should be undefined (user is not in admins list)
expect(entityActions).not.toHaveAttribute('data-isadmin', 'true')
})

it('does not render EntityActions for module type when user is not an admin', () => {
const { useSession } = jest.requireMock('next-auth/react')
useSession.mockReturnValue({
Expand Down
50 changes: 50 additions & 0 deletions frontend/__tests__/unit/components/ChapterMap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,30 @@ describe('ChapterMap Refactored Tests', () => {
})
})

it('removes existing zoom control before adding a new one on re-activation', async () => {
const { getByText, container } = render(<ChapterMap {...defaultProps} />)

fireEvent.click(getByText('Unlock map').closest('button'))
await waitFor(() => {
expect(L.control.zoom).toHaveBeenCalledTimes(1)
expect(mockZoomControl.addTo).toHaveBeenCalledTimes(1)
})

fireEvent.mouseLeave(container.firstChild as HTMLElement)
await waitFor(() => {
expect(mockZoomControl.remove).toHaveBeenCalled()
expect(getByText('Unlock map')).toBeInTheDocument()
})

jest.clearAllMocks()

fireEvent.click(getByText('Unlock map').closest('button'))
await waitFor(() => {
expect(L.control.zoom).toHaveBeenCalledTimes(1)
expect(mockZoomControl.addTo).toHaveBeenCalledTimes(1)
})
})

it('locks map again on mouse leave', async () => {
const { getByText, container } = render(<ChapterMap {...defaultProps} />)

Expand Down Expand Up @@ -266,6 +290,32 @@ describe('ChapterMap Refactored Tests', () => {
expect(mockMap.setView).toHaveBeenCalledWith([35.6762, 139.6503], 7)
})
})

it('handles zero container height (aspect ratio 1)', async () => {
mockMap.getContainer.mockReturnValueOnce({
clientWidth: 800,
clientHeight: 0,
})

render(<ChapterMap {...defaultProps} />)
await waitFor(() => {
expect(mockMap.setMinZoom).toHaveBeenCalledWith(2)
expect(mockMap.setView).toHaveBeenCalledWith([20, 0], 2)
})
})

it('adjusts minZoom for wide aspect ratio (> 2)', async () => {
mockMap.getContainer.mockReturnValueOnce({
clientWidth: 1600,
clientHeight: 600,
})

render(<ChapterMap {...defaultProps} />)
await waitFor(() => {
expect(mockMap.setMinZoom).toHaveBeenCalledWith(1)
expect(mockMap.setView).toHaveBeenCalledWith([20, 0], 2)
})
})
})

describe('Share Location Button', () => {
Expand Down
59 changes: 59 additions & 0 deletions frontend/__tests__/unit/components/EntityActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1024,5 +1024,64 @@ describe('EntityActions', () => {
)
})
})

it('skips cache update when getProgramModules is not in cache', async () => {
const mockCache = {
readQuery: jest.fn().mockReturnValue(null),
writeQuery: jest.fn(),
}

mockDeleteMutation.mockImplementationOnce(({ update }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (update) update(mockCache as any)
return Promise.resolve({ data: { deleteModule: true } })
})

render(
<EntityActions
type="module"
programKey="test-program"
moduleKey="test-module"
isAdmin={true}
/>
)
fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
fireEvent.click(screen.getByText('Delete'))

fireEvent.click(screen.getByRole('button', { name: 'Delete' }))

await waitFor(() => {
expect(mockCache.readQuery).toHaveBeenCalled()
expect(mockCache.writeQuery).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program')
})
})

it('handles non-Error thrown values gracefully', async () => {
mockDeleteMutation.mockRejectedValueOnce('string error')

render(
<EntityActions
type="module"
programKey="test-program"
moduleKey="test-module"
isAdmin={true}
/>
)
fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
fireEvent.click(screen.getByText('Delete'))

fireEvent.click(screen.getByRole('button', { name: 'Delete' }))

await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Error',
color: 'danger',
description: 'Failed to delete module. Please try again.',
})
)
})
})
})
})
77 changes: 72 additions & 5 deletions frontend/__tests__/unit/components/ModuleForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jest.mock('@heroui/react', () => ({
<button
type="button"
data-testid="autocomplete-clear"
onClick={() => onSelectionChange?.(new Set())}
onClick={() => onSelectionChange?.(null)}
>
Clear Selection
</button>
Expand Down Expand Up @@ -278,11 +278,16 @@ describe('ModuleForm', () => {

beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
mockQuery.mockResolvedValue({
data: { searchProjects: [{ id: 'project-1', name: 'Test Project' }] },
})
})

afterEach(() => {
jest.useRealTimers()
})

const renderModuleForm = (props = {}) => {
const defaultProps = {
formData: defaultFormData,
Expand Down Expand Up @@ -396,6 +401,51 @@ describe('ModuleForm', () => {
}
expect(mockSetFormData).toHaveBeenCalled()
})

it('updates project field when ProjectSelector changes', async () => {
renderModuleForm()
const input = screen.getByTestId('autocomplete-input')
await act(async () => {
fireEvent.change(input, { target: { value: 'Test' } })
jest.advanceTimersByTime(350)
})
await waitFor(() => expect(mockQuery).toHaveBeenCalled())
const selectButton = screen.getByTestId('autocomplete-select-single')
await act(async () => {
fireEvent.click(selectButton)
})
expect(mockSetFormData).toHaveBeenCalled()
const setterFn = mockSetFormData.mock.calls[mockSetFormData.mock.calls.length - 1][0]
const result = setterFn(defaultFormData)
expect(result).toEqual(
expect.objectContaining({
projectId: 'project-1',
projectName: 'Test Project',
})
)
})

it('updates project field when ProjectSelector is cleared', async () => {
const initialFormData = {
...defaultFormData,
projectId: 'proj-1',
projectName: 'Existing Project',
}
renderModuleForm({ formData: initialFormData })
const clearButton = screen.getByTestId('autocomplete-clear')
await act(async () => {
fireEvent.click(clearButton)
})
expect(mockSetFormData).toHaveBeenCalled()
const setterFn = mockSetFormData.mock.calls[mockSetFormData.mock.calls.length - 1][0]
const result = setterFn(initialFormData)
expect(result).toEqual(
expect.objectContaining({
projectId: '',
projectName: '',
})
)
})
})

describe('Experience Level Select - handleSelectChange (lines 74-84)', () => {
Expand Down Expand Up @@ -690,17 +740,16 @@ describe('ProjectSelector', () => {
expect(mockOnProjectChange).toHaveBeenCalledWith('project-1', 'Test Project 1')
})

it('clears selection when empty set is passed (lines 419-420)', async () => {
it('clears selection when clear button is clicked (lines 411-413)', async () => {
renderProjectSelector({ value: 'project-1', defaultName: 'Existing Project' })
const clearButton = screen.getByTestId('autocomplete-clear')

await act(async () => {
fireEvent.click(clearButton)
})

// The component's handleSelectionChange doesn't handle empty Set directly
// This test verifies the current behavior where it's not called
expect(mockOnProjectChange).not.toHaveBeenCalled()
// Now expected to call onProjectChange with null
expect(mockOnProjectChange).toHaveBeenCalledWith(null, '')
})
})

Expand Down Expand Up @@ -778,6 +827,24 @@ describe('ProjectSelector', () => {

consoleErrorSpy.mockRestore()
})

it('handles missing searchProjects in response', async () => {
mockQuery.mockResolvedValue({ data: {} })

renderProjectSelector()
const input = screen.getByTestId('autocomplete-input')

await act(async () => {
fireEvent.change(input, { target: { value: 'Test' } })
jest.advanceTimersByTime(350)
})

await waitFor(() => {
expect(mockQuery).toHaveBeenCalled()
})
const items = screen.queryAllByTestId('autocomplete-item')
expect(items).toHaveLength(0)
})
})

describe('Validation Display', () => {
Expand Down
Loading