diff --git a/frontend/__tests__/unit/components/ItemCardList.test.tsx b/frontend/__tests__/unit/components/ItemCardList.test.tsx index 5eafffd374..852a6e576d 100644 --- a/frontend/__tests__/unit/components/ItemCardList.test.tsx +++ b/frontend/__tests__/unit/components/ItemCardList.test.tsx @@ -178,7 +178,6 @@ const mockRelease: Release = { publishedAt: 1640995200000, repositoryName: 'test-repo', tagName: 'v1.0.0', - url: 'https://github.com/test-org/test-repo/releases/tag/v1.0.0', } describe('ItemCardList Component', () => { diff --git a/frontend/__tests__/unit/components/RecentRelease.test.tsx b/frontend/__tests__/unit/components/RecentRelease.test.tsx new file mode 100644 index 0000000000..cbed308e81 --- /dev/null +++ b/frontend/__tests__/unit/components/RecentRelease.test.tsx @@ -0,0 +1,390 @@ +import { render, screen, fireEvent, act } from '@testing-library/react' +import type { ReactElement, ReactNode } from 'react' +import type { Release } from 'types/release' +import RecentReleases from 'components/RecentReleases' + +// Define proper types for mock components +interface MockComponentProps { + children?: ReactNode + [key: string]: unknown +} + +interface MockImageProps { + alt?: string + src?: string + [key: string]: unknown +} + +// Mock framer-motion to prevent LazyMotion issues +jest.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: MockComponentProps): ReactElement => ( +
{children}
+ ), + span: ({ children, ...props }: MockComponentProps): ReactElement => ( + {children} + ), + }, + AnimatePresence: ({ children }: { children: ReactNode }): ReactNode => children, + useAnimation: () => ({ + start: jest.fn(), + set: jest.fn(), + }), + LazyMotion: ({ children }: { children: ReactNode }): ReactNode => children, + domAnimation: jest.fn(), +})) + +// Mock HeroUI components +jest.mock('@heroui/tooltip', () => ({ + Tooltip: ({ + children, + closeDelay: _closeDelay, + delay: _delay, + placement: _placement, + showArrow: _showArrow, + id: _id, + content: _content, + ...props + }: MockComponentProps): ReactElement =>
{children}
, +})) + +const mockRouterPush = jest.fn() + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + }), +})) + +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: MockImageProps): ReactElement => { + // eslint-disable-next-line @next/next/no-img-element + return {props.alt + }, +})) + +const now = Date.now() +const mockReleases: Release[] = [ + { + name: 'v1.0 The First Release', + publishedAt: now, + repositoryName: 'our-awesome-project', + organizationName: 'our-org', + tagName: 'v1.0', + isPreRelease: false, + author: { + login: 'testuser', + name: 'Test User', + avatarUrl: 'https://example.com/avatar.png', + key: 'testuser', + contributionsCount: 0, + createdAt: 0, + followersCount: 0, + followingCount: 0, + publicRepositoriesCount: 0, + url: 'https://example.com/user/testuser', + }, + }, + { + name: 'v2.0 The Second Release', + publishedAt: now, + repositoryName: 'another-cool-project', + organizationName: 'our-org', + tagName: 'v2.0', + isPreRelease: false, + author: { + login: 'jane-doe', + name: 'Jane Doe', + avatarUrl: 'https://example.com/avatar2.png', + key: 'jane-doe', + contributionsCount: 0, + createdAt: 0, + followersCount: 0, + followingCount: 0, + publicRepositoriesCount: 0, + url: 'https://example.com/user/jane-doe', + }, + }, +] + +describe('RecentReleases Component', () => { + beforeEach(() => { + mockRouterPush.mockClear() + }) + + it('should display a message when there is no data', () => { + act(() => { + render() + }) + expect(screen.getByText('No recent releases.')).toBeInTheDocument() + }) + + it('should render release details and links correctly with data', () => { + act(() => { + render() + }) + + const releaseLink = screen.getByRole('link', { name: /v1.0 The First Release/i }) + const repoNameElement = screen.getByText(/another-cool-project/i) + const authorLink = screen.getByRole('link', { name: /Test User/i }) + + expect(releaseLink).toBeInTheDocument() + expect(repoNameElement).toBeInTheDocument() + expect(authorLink).toBeInTheDocument() + + expect(releaseLink).toHaveAttribute( + 'href', + 'https://github.com/our-org/our-awesome-project/releases/tag/v1.0' + ) + expect(releaseLink).toHaveAttribute('target', '_blank') + expect(authorLink).toHaveAttribute('href', '/members/testuser') + }) + + it('should navigate when the repository name is clicked', () => { + act(() => { + render() + }) + + const repoNameElement = screen.getByText(/our-awesome-project/i) + act(() => { + fireEvent.click(repoNameElement) + }) + + expect(mockRouterPush).toHaveBeenCalledTimes(1) + expect(mockRouterPush).toHaveBeenCalledWith( + '/organizations/our-org/repositories/our-awesome-project' + ) + }) + + it('should not render avatars if showAvatar is false', () => { + act(() => { + render() + }) + expect(screen.queryByRole('link', { name: /Test User/i })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Jane Doe/i })).not.toBeInTheDocument() + }) + + it('should apply single-column class when showSingleColumn is true', () => { + let container: HTMLElement + act(() => { + const result = render() + container = result.container + }) + const gridContainer = container.querySelector('.grid') + + expect(gridContainer).toHaveClass('grid-cols-1') + expect(gridContainer).not.toHaveClass('md:grid-cols-2') + }) + + it('should apply multi-column classes by default', () => { + let container: HTMLElement + act(() => { + const result = render() + container = result.container + }) + const gridContainer = container.querySelector('.grid') + + expect(gridContainer).not.toHaveClass('grid-cols-1') + expect(gridContainer).toHaveClass('md:grid-cols-2', 'lg:grid-cols-3') + }) + + // New test cases for comprehensive coverage + + it('should handle releases with missing author name', () => { + const releasesWithMissingAuthor = [ + { + ...mockReleases[0], + author: { + ...mockReleases[0].author, + name: '', + }, + }, + ] + + act(() => { + render() + }) + + // Should still render the release name + expect(screen.getByText('v1.0 The First Release')).toBeInTheDocument() + // Should handle missing author gracefully + expect(screen.getByAltText('testuser')).toBeInTheDocument() + }) + + it('should handle releases with missing repository information', () => { + const releasesWithMissingRepo = [ + { + ...mockReleases[0], + repositoryName: undefined, + organizationName: undefined, + }, + ] + + act(() => { + render() + }) + + // Should still render the release name + expect(screen.getByText('v1.0 The First Release')).toBeInTheDocument() + // Should handle missing repo info gracefully - check for button element + const repoButton = screen.getByRole('button') + expect(repoButton).toBeInTheDocument() + }) + + it('should handle releases with missing URLs', () => { + const releasesWithMissingUrls = [ + { + ...mockReleases[0], + url: undefined, + }, + ] + + act(() => { + render() + }) + + const releaseLink = screen.getByRole('link', { name: /v1.0 The First Release/i }) + expect(releaseLink).toHaveAttribute( + 'href', + 'https://github.com/our-org/our-awesome-project/releases/tag/v1.0' + ) + }) + + it('should render with default props when not provided', () => { + let container: HTMLElement + act(() => { + const result = render() + container = result.container + }) + // Should show avatars by default + expect(screen.getByRole('link', { name: /Test User/i })).toBeInTheDocument() + const gridContainer = container.querySelector('.grid') + expect(gridContainer).toHaveClass('md:grid-cols-2', 'lg:grid-cols-3') + }) + + it('should handle null/undefined data gracefully', () => { + const { unmount } = render() + expect(screen.getByText('No recent releases.')).toBeInTheDocument() + unmount() + + render() + expect(screen.getByText('No recent releases.')).toBeInTheDocument() + }) + + it('should have proper accessibility attributes', () => { + act(() => { + render() + }) + + // Check for proper alt text on images + const authorImage = screen.getByAltText('Test User') + expect(authorImage).toBeInTheDocument() + + // Check for proper link roles + const releaseLink = screen.getByRole('link', { name: /v1.0 The First Release/i }) + expect(releaseLink).toBeInTheDocument() + + // Check for proper button roles + const repoButton = screen.getByText(/our-awesome-project/i) + expect(repoButton).toBeInTheDocument() + }) + + it('should handle multiple releases correctly', () => { + act(() => { + render() + }) + + // Should render both releases + expect(screen.getByText('v1.0 The First Release')).toBeInTheDocument() + expect(screen.getByText('v2.0 The Second Release')).toBeInTheDocument() + + // Should render both repository names + expect(screen.getByText('our-awesome-project')).toBeInTheDocument() + expect(screen.getByText('another-cool-project')).toBeInTheDocument() + }) + + it('should handle repository click with missing organization name', () => { + const releasesWithMissingOrg = [ + { + ...mockReleases[0], + organizationName: undefined, + }, + ] + + act(() => { + render() + }) + + const repoButton = screen.getByRole('button') + expect(repoButton).toBeDisabled() + + act(() => { + fireEvent.click(repoButton) + }) + + // Should not navigate when organization name is missing + expect(mockRouterPush).not.toHaveBeenCalled() + }) + + it('should disable repository button if repository name is missing', () => { + const releasesWithMissingRepoName = [ + { + ...mockReleases[0], + repositoryName: undefined, + }, + ] + + act(() => { + render() + }) + + const repoButton = screen.getByRole('button') + expect(repoButton).toBeDisabled() + + act(() => { + fireEvent.click(repoButton) + }) + + // Should not navigate when repository name is missing + expect(mockRouterPush).not.toHaveBeenCalled() + }) + + it('should render with proper CSS classes for styling', () => { + let container: HTMLElement + act(() => { + const result = render() + container = result.container + }) + + // Check for main card structure - look for the card wrapper + const cardElement = container.querySelector( + '.mb-4.w-full.rounded-lg.bg-gray-200.p-4.dark\\:bg-gray-700' + ) + expect(cardElement).toBeInTheDocument() + + // Check for proper grid layout + const gridElement = container.querySelector('.grid') + expect(gridElement).toBeInTheDocument() + + // Check for proper text styling - look for the title + const titleElement = container.querySelector('.text-2xl.font-semibold') + expect(titleElement).toBeInTheDocument() + }) + + it('should handle releases with very long names gracefully', () => { + const releasesWithLongNames = [ + { + ...mockReleases[0], + name: 'This is a very long release name that should be truncated properly in the UI to prevent layout issues and maintain consistent styling across different screen sizes', + }, + ] + + act(() => { + render() + }) + + // Should still render the long name + expect(screen.getByText(/This is a very long release name/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/RecentReleases.tsx b/frontend/src/components/RecentReleases.tsx index 5ea73c37fb..c8aadef0f1 100644 --- a/frontend/src/components/RecentReleases.tsx +++ b/frontend/src/components/RecentReleases.tsx @@ -44,7 +44,7 @@ const RecentReleases: React.FC = ({ {showAvatar && item?.author && ( = ({ > {item?.author?.name @@ -71,7 +71,7 @@ const RecentReleases: React.FC = ({ target="_blank" rel="noopener noreferrer" > - + @@ -84,11 +84,13 @@ const RecentReleases: React.FC = ({ diff --git a/frontend/src/types/release.ts b/frontend/src/types/release.ts index 296faa5e86..2e422ec81d 100644 --- a/frontend/src/types/release.ts +++ b/frontend/src/types/release.ts @@ -10,5 +10,4 @@ export type Release = { repository?: RepositoryDetails repositoryName: string tagName: string - url: string }