import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
//mock element width to control mobile/desktop behavior
let mockedWidth = 1280
vi.mock('../../hooks/useElementWidth', async () => {
return {
useElementWidth: () => mockedWidth,
}
})
import { PktHeaderService } from './HeaderService'
describe('PktHeaderService', () => {
// jsdom does not implement scrollTo; our scroll lock hook uses it
beforeAll(() => {
window.scrollTo = vi.fn()
})
beforeEach(() => {
mockedWidth = 1280
vi.clearAllMocks()
})
it('renders service name and link when provided', () => {
const { container } = render()
const host = container.querySelector('.pkt-header-service__service-link') as HTMLElement | null
expect(host).toBeTruthy()
expect(screen.getByText('My Service')).toBeInTheDocument()
})
it('applies compact class when compact=true', () => {
const { container } = render()
const header = container.querySelector('.pkt-header-service')
expect(header).toHaveClass('pkt-header-service--compact')
})
it('shows children inline on desktop, menu button on mobile', () => {
// Desktop
mockedWidth = 1440
const { rerender } = render(
Link
,
)
expect(screen.getByText('Link')).toBeInTheDocument()
// Mobile
mockedWidth = 500
rerender(
Link
,
)
expect(screen.getByRole('button', { name: 'Åpne meny' })).toBeInTheDocument()
})
it('renders desktop search input when showSearch is true (desktop)', () => {
mockedWidth = 1400
render()
const search = screen.getByRole('searchbox', { name: 'Søk' })
expect(search).toBeInTheDocument()
expect(search).toHaveAttribute('id', 'header-service-search')
})
it('renders mobile search toggle and opens search field when clicked (mobile)', () => {
mockedWidth = 500
render()
const btn = screen.getByRole('button', { name: 'Åpne søkefelt' })
fireEvent.click(btn)
// After opening, the mobile menu should show the search input
expect(screen.getByRole('searchbox', { name: 'Søk' })).toBeInTheDocument()
expect(screen.getByRole('searchbox')).toHaveAttribute('id', 'mobile-search-menu')
})
it('renders user button on desktop and opens user menu on click', () => {
mockedWidth = 1400
render()
// Button shows both fullname and shortname (both 'Aksel' when no shortname provided)
const userBtn = screen.getByRole('button', { name: /Aksel/ })
expect(userBtn).toBeInTheDocument()
fireEvent.click(userBtn)
// User menu should be mounted
const menu = screen.getByRole('navigation')
expect(menu).toHaveClass('pkt-user-menu')
})
// New tests for hideLogo prop
describe('hideLogo prop', () => {
it('shows oslo logo by default (hideLogo=false)', () => {
const { container } = render()
const logos = container.querySelectorAll('.pkt-header-service__logo')
expect(logos.length).toBe(1)
})
it('hides oslo logo when hideLogo=true', () => {
const { container } = render()
const logos = container.querySelectorAll('.pkt-header-service__logo')
expect(logos.length).toBe(0)
})
})
// New tests for logoLink prop
describe('logoLink prop', () => {
it('renders logo as link when logoLink is a string', () => {
const { container } = render()
const logoLink = container.querySelector('.pkt-header-service__logo a')
expect(logoLink).toBeTruthy()
expect(logoLink).toHaveAttribute('href', 'https://oslo.kommune.no')
})
it('renders logo without link/button when logoLink is not provided', () => {
const { container } = render()
const logoLink = container.querySelector('.pkt-header-service__logo a')
const logoButton = container.querySelector('.pkt-header-service__logo button')
expect(logoLink).toBeNull()
expect(logoButton).toBeNull()
})
})
// New tests for position and scrollBehavior props
describe('position and scrollBehavior props', () => {
it('applies fixed class by default (position="fixed")', () => {
const { container } = render()
const header = container.querySelector('.pkt-header-service')
expect(header).toHaveClass('pkt-header-service--fixed')
})
it('does not apply fixed class when position="relative"', () => {
const { container } = render()
const header = container.querySelector('.pkt-header-service')
expect(header).not.toHaveClass('pkt-header-service--fixed')
})
it('applies scroll-to-hide class by default (scrollBehavior="hide")', () => {
const { container } = render()
const header = container.querySelector('.pkt-header-service')
expect(header).toHaveClass('pkt-header-service--scroll-to-hide')
})
it('does not apply scroll-to-hide class when scrollBehavior="none"', () => {
const { container } = render()
const header = container.querySelector('.pkt-header-service')
expect(header).not.toHaveClass('pkt-header-service--scroll-to-hide')
})
})
// New tests for user with lastLoggedIn
describe('user props', () => {
it('displays user name in button', () => {
mockedWidth = 1400
render()
const userBtn = screen.getByRole('button', { name: /Aksel Olsen/ })
expect(userBtn).toBeInTheDocument()
})
it('logs deprecation warning when shortname is provided', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockedWidth = 1400
render()
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('shortname'))
consoleSpy.mockRestore()
})
it('displays formatted lastLoggedIn in user menu', () => {
mockedWidth = 1400
render()
const userBtn = screen.getByRole('button', { name: /Aksel/ })
fireEvent.click(userBtn)
// Should format the date in Norwegian
expect(screen.getByText(/Sist pålogget:/)).toBeInTheDocument()
})
it('displays lastLoggedIn string as-is in user menu', () => {
mockedWidth = 1400
render()
const userBtn = screen.getByRole('button', { name: /Aksel/ })
fireEvent.click(userBtn)
expect(screen.getByText('19. september 2025')).toBeInTheDocument()
})
})
// New tests for representing props
describe('representing props', () => {
it('displays representing name in user button when representing is provided', () => {
mockedWidth = 1400
render()
const userBtn = screen.getByRole('button', { name: /Oslo Kommune/ })
expect(userBtn).toBeInTheDocument()
})
it('logs deprecation warning when representing.shortname is provided', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockedWidth = 1400
render(
,
)
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('shortname'))
consoleSpy.mockRestore()
})
it('displays orgNumber in user menu when representing has orgNumber', () => {
mockedWidth = 1400
render(
,
)
const userBtn = screen.getByRole('button', { name: /Oslo Kommune/ })
fireEvent.click(userBtn)
expect(screen.getByText('Org.nr. 911738066')).toBeInTheDocument()
})
})
// New tests for canChangeRepresentation
describe('canChangeRepresentation prop', () => {
it('shows change representation button when canChangeRepresentation=true', () => {
mockedWidth = 1400
const handleChange = vi.fn()
render(
,
)
const userBtn = screen.getByRole('button', { name: /Oslo Kommune/ })
fireEvent.click(userBtn)
const changeBtn = screen.getByRole('button', { name: 'Endre organisasjon' })
expect(changeBtn).toBeInTheDocument()
fireEvent.click(changeBtn)
expect(handleChange).toHaveBeenCalledTimes(1)
})
it('does not show change representation button when canChangeRepresentation=false', () => {
mockedWidth = 1400
render(
,
)
const userBtn = screen.getByRole('button', { name: /Oslo Kommune/ })
fireEvent.click(userBtn)
expect(screen.queryByRole('button', { name: 'Endre organisasjon' })).not.toBeInTheDocument()
})
})
// New tests for userMenu prop
describe('userMenu prop', () => {
it('renders userMenu items in user menu dropdown', () => {
mockedWidth = 1400
const userMenu = [
{ title: 'Mine bookinger', iconName: 'heart', target: '/bookinger' },
{ title: 'Innstillinger', iconName: 'cogwheel', target: () => {} },
]
render()
const userBtn = screen.getByRole('button', { name: /Aksel/ })
fireEvent.click(userBtn)
expect(screen.getByText('Mine bookinger')).toBeInTheDocument()
expect(screen.getByText('Innstillinger')).toBeInTheDocument()
})
it('renders userMenu link items as links', () => {
mockedWidth = 1400
const userMenu = [{ title: 'Min profil', target: '/profil' }]
const { container } = render()
const userBtn = screen.getByRole('button', { name: /Aksel/ })
fireEvent.click(userBtn)
// PktLink renders as an tag with class pkt-link
const linkElement = container.querySelector('a.pkt-link[href="/profil"]')
expect(linkElement).toBeTruthy()
expect(screen.getByText('Min profil')).toBeInTheDocument()
})
it('calls userMenu button item onClick when clicked', () => {
mockedWidth = 1400
const handleClick = vi.fn()
const userMenu = [{ title: 'Custom Action', target: handleClick }]
render()
const userBtn = screen.getByRole('button', { name: /Aksel/ })
fireEvent.click(userBtn)
const actionBtn = screen.getByRole('button', { name: 'Custom Action' })
fireEvent.click(actionBtn)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
// New tests for logOut callback and placement
describe('logOut callback', () => {
it('shows logout button in header when logOutButtonPlacement="header"', () => {
mockedWidth = 1400
const handleLogout = vi.fn()
render(
,
)
const logoutBtn = screen.getByRole('button', { name: 'Logg ut' })
expect(logoutBtn).toBeInTheDocument()
fireEvent.click(logoutBtn)
expect(handleLogout).toHaveBeenCalledTimes(1)
})
it('shows logout button in userMenu when logOutButtonPlacement="userMenu"', () => {
mockedWidth = 1400
const handleLogout = vi.fn()
render(
,
)
const userBtn = screen.getByRole('button', { name: /Aksel/ })
fireEvent.click(userBtn)
const logoutBtn = screen.getByRole('button', { name: 'Logg ut' })
expect(logoutBtn).toBeInTheDocument()
fireEvent.click(logoutBtn)
expect(handleLogout).toHaveBeenCalledTimes(1)
})
it('shows logout button in both header and userMenu when logOutButtonPlacement="both"', () => {
mockedWidth = 1400
const handleLogout = vi.fn()
render(
,
)
// Header logout
const headerLogoutBtn = screen.getByRole('button', { name: 'Logg ut' })
expect(headerLogoutBtn).toBeInTheDocument()
// Open user menu
const userBtn = screen.getByRole('button', { name: /Aksel/ })
fireEvent.click(userBtn)
// Should have logout in menu too
const logoutBtns = screen.getAllByRole('button', { name: 'Logg ut' })
expect(logoutBtns.length).toBe(2)
})
})
// Test for userMenuFooter deprecation warning
describe('userMenuFooter deprecation', () => {
it('logs deprecation warning when userMenuFooter is provided', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockedWidth = 1400
render(
,
)
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('userMenuFooter'))
consoleSpy.mockRestore()
})
})
// Test for userOptions "no longer available" warning
describe('userOptions warning', () => {
it('logs warning when userOptions is provided', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockedWidth = 1400
render(
,
)
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('userOptions'))
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('no longer available'))
consoleSpy.mockRestore()
})
})
// Tests for serviceLink and serviceClick props
describe('serviceLink and serviceClick props', () => {
it('renders service name as link when serviceLink is a string', () => {
const { container } = render()
const link = container.querySelector('a.pkt-link.pkt-header-service__service-link')
expect(link).toBeTruthy()
expect(link).toHaveAttribute('href', 'https://example.com')
})
it('renders service name as button when serviceClick is provided', () => {
const handleClick = vi.fn()
const { container } = render()
const button = container.querySelector('button.pkt-header-service__service-link')
expect(button).toBeTruthy()
fireEvent.click(button!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('renders service name as span when neither serviceLink nor serviceClick is provided', () => {
const { container } = render()
const span = container.querySelector('span.pkt-header-service__service-link')
expect(span).toBeTruthy()
expect(span?.tagName).toBe('SPAN')
})
it('prioritizes serviceLink over serviceClick when both are provided', () => {
const handleClick = vi.fn()
const { container } = render(
,
)
const link = container.querySelector('a.pkt-link.pkt-header-service__service-link')
const button = container.querySelector('button.pkt-header-service__service-link')
expect(link).toBeTruthy()
expect(button).toBeNull()
})
})
// Tests for logoClick prop
describe('logoClick prop', () => {
it('renders logo as button when logoClick is provided', () => {
const handleClick = vi.fn()
const { container } = render()
const logoButton = container.querySelector('.pkt-header-service__logo button')
expect(logoButton).toBeTruthy()
fireEvent.click(logoButton!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('prioritizes logoLink over logoClick when both are provided', () => {
const handleClick = vi.fn()
const { container } = render(
,
)
const logoLink = container.querySelector('.pkt-header-service__logo a')
const logoButton = container.querySelector('.pkt-header-service__logo button')
expect(logoLink).toBeTruthy()
expect(logoButton).toBeNull()
})
})
// Tests for logout button placement on mobile vs desktop
describe('logout button placement mobile vs desktop', () => {
it('shows logout button in user area on desktop when logOutButtonPlacement="header"', () => {
mockedWidth = 1400
const handleLogout = vi.fn()
render(
,
)
// On desktop, logout button should be in user area
const logoutBtn = screen.getByRole('button', { name: 'Logg ut' })
expect(logoutBtn).toBeInTheDocument()
})
it('shows logout button in content area on mobile when logOutButtonPlacement="header"', () => {
mockedWidth = 500
const handleLogout = vi.fn()
const { container } = render(
,
)
const contentArea = container.querySelector('.pkt-header-service__content')
const logoutBtn = contentArea?.querySelector('button')
expect(logoutBtn).toBeTruthy()
expect(screen.getByRole('button', { name: 'Logg ut' })).toBeInTheDocument()
})
it('does not show logout button in header areas when logOutButtonPlacement="userMenu"', () => {
mockedWidth = 1400
const handleLogout = vi.fn()
render(
,
)
// When logOutButtonPlacement is userMenu, logout button should not be visible outside the menu
// Only the user menu button should be visible, not a separate logout button
const logoutButtons = screen.queryAllByRole('button', { name: 'Logg ut' })
expect(logoutButtons.length).toBe(0)
})
})
})