Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
110 changes: 70 additions & 40 deletions frontend/__tests__/unit/components/BreadCrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,98 @@
import { render, screen } from '@testing-library/react'
import { usePathname } from 'next/navigation'
import BreadCrumbs from 'components/BreadCrumbs'
import BreadCrumbRenderer from 'components/BreadCrumbs'
import '@testing-library/jest-dom'

jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}))
describe('BreadCrumbRenderer', () => {
const mockItems = [
{ title: 'Home', path: '/' },
{ title: 'Projects', path: '/projects' },
{ title: 'OWASP ZAP', path: '/projects/zap' },
]

describe('BreadCrumb', () => {
afterEach(() => {
jest.clearAllMocks()
test('renders all breadcrumb items', () => {
render(<BreadCrumbRenderer items={mockItems} />)

expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('OWASP ZAP')).toBeInTheDocument()
})

test('does not render on root path "/"', () => {
;(usePathname as jest.Mock).mockReturnValue('/')
test('renders navigation element with correct aria-label', () => {
render(<BreadCrumbRenderer items={mockItems} />)

render(<BreadCrumbs />)
expect(screen.queryByText('Home')).not.toBeInTheDocument()
const nav = screen.getByRole('navigation')
expect(nav).toHaveAttribute('aria-label', 'breadcrumb')
})

test('renders breadcrumb with multiple segments', () => {
;(usePathname as jest.Mock).mockReturnValue('/dashboard/users/profile')
test('renders clickable links for non-last items', () => {
render(<BreadCrumbRenderer items={mockItems} />)

render(<BreadCrumbs />)
const homeLink = screen.getByText('Home').closest('a')
const projectsLink = screen.getByText('Projects').closest('a')

expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Users')).toBeInTheDocument()
expect(screen.getByText('Profile')).toBeInTheDocument()
expect(homeLink).toHaveAttribute('href', '/')
expect(projectsLink).toHaveAttribute('href', '/projects')
})

test('disables the last segment (non-clickable)', () => {
;(usePathname as jest.Mock).mockReturnValue('/settings/account')
test('disables the last item (non-clickable)', () => {
render(<BreadCrumbRenderer items={mockItems} />)

const lastItem = screen.getByText('OWASP ZAP')
expect(lastItem).not.toHaveAttribute('href')
expect(lastItem.tagName).toBe('SPAN')
})

render(<BreadCrumbs />)
test('applies hover styles to clickable links', () => {
render(<BreadCrumbRenderer items={mockItems} />)

const lastSegment = screen.getByText('Account')
expect(lastSegment).toBeInTheDocument()
expect(lastSegment).not.toHaveAttribute('href')
const homeLink = screen.getByText('Home').closest('a')
expect(homeLink).toHaveClass('hover:text-blue-700', 'hover:underline')
})

test('links have correct href attributes', () => {
;(usePathname as jest.Mock).mockReturnValue('/dashboard/users/profile')
test('applies disabled styling to last breadcrumb', () => {
render(<BreadCrumbRenderer items={mockItems} />)

render(<BreadCrumbs />)
const lastItem = screen.getByText('OWASP ZAP')
expect(lastItem).toHaveClass('cursor-default', 'font-semibold')
})

const homeLink = screen.getByText('Home').closest('a')
const dashboardLink = screen.getByText('Dashboard').closest('a')
const usersLink = screen.getByText('Users').closest('a')
test('renders chevron separators between items', () => {
const { container } = render(<BreadCrumbRenderer items={mockItems} />)

expect(homeLink).toHaveAttribute('href', '/')
expect(dashboardLink).toHaveAttribute('href', '/dashboard')
expect(usersLink).toHaveAttribute('href', '/dashboard/users')
const separators = container.querySelectorAll('[data-slot="separator"]')
expect(separators).toHaveLength(2)
})

test('links have hover styles', () => {
;(usePathname as jest.Mock).mockReturnValue('/dashboard/users')
test('handles single item (home only)', () => {
const singleItem = [{ title: 'Home', path: '/' }]
render(<BreadCrumbRenderer items={singleItem} />)

render(<BreadCrumbs />)
expect(screen.getByText('Home')).toBeInTheDocument()
const separators = screen.queryByRole('separator')
expect(separators).not.toBeInTheDocument()
})

test('handles empty items array', () => {
const { container } = render(<BreadCrumbRenderer items={[]} />)

const breadcrumbList = container.querySelector('[data-slot="list"]')
expect(breadcrumbList?.children).toHaveLength(0)
})

test('applies correct wrapper styling', () => {
const { container } = render(<BreadCrumbRenderer items={mockItems} />)

const wrapper = container.querySelector('.mt-16')
expect(wrapper).toHaveClass('w-full', 'pt-4')
})

test('links have correct href attributes', () => {
render(<BreadCrumbRenderer items={mockItems} />)

const homeLink = screen.getByText('Home').closest('a')
const dashboardLink = screen.getByText('Dashboard').closest('a')
const projectsLink = screen.getByText('Projects').closest('a')

expect(homeLink).toHaveClass('hover:text-blue-700', 'hover:underline')
expect(dashboardLink).toHaveClass('hover:text-blue-700', 'hover:underline')
expect(homeLink).toHaveAttribute('href', '/')
expect(projectsLink).toHaveAttribute('href', '/projects')
})
})
63 changes: 63 additions & 0 deletions frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { BreadcrumbRoot, registerBreadcrumb } from 'contexts/BreadcrumbContext'
import { usePathname } from 'next/navigation'
import React, { useEffect } from 'react'
import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper'

jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}))

// Wrapper with BreadcrumbRoot for context tests
const wrapper = ({ children }: { children: React.ReactNode }) => (
<BreadcrumbRoot>{children}</BreadcrumbRoot>
)

// Helper component to register a breadcrumb before rendering BreadCrumbsWrapper
function BreadCrumbsWrapperWithTitle({ path, title }: Readonly<{ path: string; title: string }>) {
useEffect(() => {
const unregister = registerBreadcrumb({ path, title })
return unregister
}, [path, title])
return <BreadCrumbsWrapper />
}

describe('BreadCrumbsWrapper', () => {
afterEach(() => {
jest.clearAllMocks()
})

describe('Route Detection - Should Hide', () => {
test('returns null on home page', () => {
;(usePathname as jest.Mock).mockReturnValue('/')

const { container } = render(<BreadCrumbsWrapper />)
expect(container.firstChild).toBeNull()
})
})

describe('Route Detection - Should Render', () => {
test('renders for routes with registered breadcrumbs', () => {
;(usePathname as jest.Mock).mockReturnValue('/projects')

render(<BreadCrumbsWrapperWithTitle path="/projects" title="Projects" />, { wrapper })
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('Projects')).toBeInTheDocument()
})

test('uses registered title', () => {
;(usePathname as jest.Mock).mockReturnValue('/projects/test-project-1')

render(
<BreadCrumbsWrapperWithTitle
path="/projects/test-project-1"
title="Security Scanner Tool"
/>,
{ wrapper }
)
// Should show registered title instead of URL slug
expect(screen.getByText('Security Scanner Tool')).toBeInTheDocument()
})
})
})
68 changes: 68 additions & 0 deletions frontend/__tests__/unit/components/PageLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { BreadcrumbRoot } from 'contexts/BreadcrumbContext'
import { usePathname } from 'next/navigation'
import React from 'react'
import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper'
import PageLayout from 'components/PageLayout'

jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}))

// Helper to wrap components with BreadcrumbRoot and render with BreadCrumbsWrapper
const renderWithProvider = (ui: React.ReactElement) => {
return render(
<BreadcrumbRoot>
<BreadCrumbsWrapper />
{ui}
</BreadcrumbRoot>
)
}

describe('PageLayout', () => {
afterEach(() => {
jest.clearAllMocks()
})

test('renders children components', () => {
;(usePathname as jest.Mock).mockReturnValue('/projects/zap')

renderWithProvider(
<PageLayout title="OWASP ZAP">
<div>Child Content</div>
</PageLayout>
)

expect(screen.getByText('Child Content')).toBeInTheDocument()
})

describe('Title Prop Handling', () => {
test('displays title in breadcrumbs', async () => {
;(usePathname as jest.Mock).mockReturnValue('/projects/zap')

renderWithProvider(
<PageLayout title="OWASP ZAP">
<div>Content</div>
</PageLayout>
)

// Wait for effect to run
await screen.findByText('OWASP ZAP')
expect(screen.getByText('OWASP ZAP')).toBeInTheDocument()
})

test('handles explicit path prop', async () => {
;(usePathname as jest.Mock).mockReturnValue('/projects/zap/details')

renderWithProvider(
<PageLayout title="OWASP ZAP" path="/projects/zap">
<div>Content</div>
</PageLayout>
)

await screen.findByText('OWASP ZAP')
expect(screen.getByText('OWASP ZAP')).toBeInTheDocument()
})
})
})
115 changes: 115 additions & 0 deletions frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { renderHook, act } from '@testing-library/react'
import { BreadcrumbRoot, registerBreadcrumb } from 'contexts/BreadcrumbContext'
import { useBreadcrumbs } from 'hooks/useBreadcrumbs'
import { usePathname } from 'next/navigation'
import React from 'react'

jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}))

// Helper wrapper with BreadcrumbRoot
const wrapper = ({ children }: { children: React.ReactNode }) => (
<BreadcrumbRoot>{children}</BreadcrumbRoot>
)

describe('useBreadcrumbs', () => {
beforeEach(() => {
jest.clearAllMocks()
})

describe('Hybrid Pattern (registered + auto-generated)', () => {
test('returns Home for root path', () => {
;(usePathname as jest.Mock).mockReturnValue('/')

const { result } = renderHook(() => useBreadcrumbs(), { wrapper })

expect(result.current).toEqual([{ title: 'Home', path: '/' }])
})

test('auto-generates breadcrumbs from URL when no items registered', () => {
;(usePathname as jest.Mock).mockReturnValue('/members')

const { result } = renderHook(() => useBreadcrumbs(), { wrapper })

expect(result.current).toEqual([
{ title: 'Home', path: '/' },
{ title: 'Members', path: '/members' },
])
})

test('uses registered title when available', () => {
;(usePathname as jest.Mock).mockReturnValue('/projects/test-project')

const { result } = renderHook(() => useBreadcrumbs(), { wrapper })

let unregister: () => void
act(() => {
unregister = registerBreadcrumb({ title: 'Test Project', path: '/projects/test-project' })
})

expect(result.current).toEqual([
{ title: 'Home', path: '/' },
{ title: 'Projects', path: '/projects' },
{ title: 'Test Project', path: '/projects/test-project' },
])

act(() => {
unregister()
})
})

test('merges registered and auto-generated items', () => {
;(usePathname as jest.Mock).mockReturnValue('/organizations/test-org/repositories/test-repo')

const { result } = renderHook(() => useBreadcrumbs(), { wrapper })

const unregisterFns: (() => void)[] = []
act(() => {
unregisterFns.push(
registerBreadcrumb({ title: 'Test Organization', path: '/organizations/test-org' }),
registerBreadcrumb({
title: 'Test Repository',
path: '/organizations/test-org/repositories/test-repo',
})
)
})

// Note: 'repositories' segment is hidden (in HIDDEN_SEGMENTS)
expect(result.current).toEqual([
{ title: 'Home', path: '/' },
{ title: 'Organizations', path: '/organizations' },
{ title: 'Test Organization', path: '/organizations/test-org' },
{ title: 'Test Repository', path: '/organizations/test-org/repositories/test-repo' },
])

act(() => {
for (const fn of unregisterFns) {
fn()
}
})
})
})

describe('Edge cases', () => {
test('handles null pathname', () => {
;(usePathname as jest.Mock).mockReturnValue(null)

const { result } = renderHook(() => useBreadcrumbs(), { wrapper })

expect(result.current).toEqual([{ title: 'Home', path: '/' }])
})

test('formats hyphenated URL segments', () => {
;(usePathname as jest.Mock).mockReturnValue('/test-page/test-section')

const { result } = renderHook(() => useBreadcrumbs(), { wrapper })

expect(result.current).toEqual([
{ title: 'Home', path: '/' },
{ title: 'Test Page', path: '/test-page' },
{ title: 'Test Section', path: '/test-page/test-section' },
])
})
})
})
1 change: 1 addition & 0 deletions frontend/__tests__/unit/pages/ChapterDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
useRouter: jest.fn(() => mockRouter),
useParams: () => ({ chapterKey: 'test-chapter' }),
usePathname: jest.fn(() => '/chapters/test-chapter'),
}))

describe('chapterDetailsPage Component', () => {
Expand Down
Loading