import '@testing-library/jest-dom' import { axe, toHaveNoViolations } from 'jest-axe' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' expect.extend(toHaveNoViolations) import './header-menu' import { PktHeaderMenu } from './header-menu' import { deriveSocialIcon, fetchHeaderFooterData, getExternalDomainLabel, getLinkDomainLabel, ICON_MAP_ODS_TO_PUNKT, mapOdsIcon, resetHeaderFooterDataCache, selectLocaleData, selectMegamenu, } from 'shared-utils/header-footer' import type { THeaderFooterApi } from 'shared-types/header-footer' const LOCAL_DATA: THeaderFooterApi = { 'nb-NO': { megamenu: { buttons: [{ text: 'Min side', url: 'https://www.oslo.kommune.no/min-side/' }], services: { title: 'Tjenester og tilbud', links: [ { icon: '24h', text: 'Døgnåpne tjenester', url: 'https://example.test/24h' }, { icon: 'fire-emblem', text: 'Brannvern', url: 'https://example.test/brannvern' }, { icon: 'unknown-icon', text: 'Ukjent', url: 'https://example.test/ukjent' }, ], }, sections: [ { title: 'Oslo vokser', links: [{ text: 'Byutvikling', url: 'https://example.test/byutvikling' }], }, { title: 'Politikk og innsyn', links: [{ text: 'Politikk', url: 'https://example.test/politikk' }], }, ], links: [ { text: 'Kontakt', url: 'https://example.test/kontakt' }, { text: 'English', url: 'https://example.test/english' }, ], some: [ { text: 'Facebook', url: 'https://www.facebook.com/Oslo/' }, { text: 'LinkedIn', url: 'https://www.linkedin.com/company/oslo-kommune' }, ], }, footer: { sections: [], links: [], some: [] }, i18n: { menu: 'Meny', search: 'Søk', navAriaLabel: 'Oslo kommune hovedmeny' }, }, 'en-GB': { megamenu: { services: { title: 'Services and information', links: [{ icon: '24h', text: '24h', url: 'https://example.test/en/24h' }], }, sections: [], links: [], some: [], }, footer: { sections: [], links: [], some: [] }, i18n: { menu: 'Menu', search: 'Search', navAriaLabel: 'Oslo kommune main menu' }, }, } const waitForReady = async () => { await customElements.whenDefined('pkt-header-menu') } const mountWithData = async (props: Record = {}): Promise => { const el = document.createElement('pkt-header-menu') as PktHeaderMenu el.data = LOCAL_DATA el.open = true Object.assign(el, props) document.body.appendChild(el) await waitForReady() await el.updateComplete // Give nested icons/accordions a tick to settle. await new Promise((resolve) => setTimeout(resolve, 0)) await el.updateComplete return el } afterEach(() => { document.body.innerHTML = '' vi.restoreAllMocks() }) describe('shared-utils: icon-map', () => { test('mapOdsIcon translates known legacy names', () => { expect(mapOdsIcon('fire-emblem')).toBe('shield-fire') expect(mapOdsIcon('document')).toBe('document-plain') expect(mapOdsIcon('linked-in')).toBe('linkedin') }) test('mapOdsIcon passes unknown names through unchanged', () => { expect(mapOdsIcon('totally-made-up')).toBe('totally-made-up') }) test('mapOdsIcon handles empty input', () => { expect(mapOdsIcon('')).toBe('') }) test('ICON_MAP_ODS_TO_PUNKT only stores actual renames', () => { for (const [from, to] of Object.entries(ICON_MAP_ODS_TO_PUNKT)) { expect(from).not.toBe(to) } }) test('mapOdsIcon resolves the icons the live API ships', () => { // Identity passes (already valid Punkt names) and renames both reach // a non-empty mapped value. const liveIcons = ['24h', 'swingset', 'backpack', 'heart-plus', 'crane', 'fire-emblem'] for (const icon of liveIcons) { expect(mapOdsIcon(icon)).toBeTruthy() } }) test('deriveSocialIcon resolves known hostnames from the URL', () => { expect(deriveSocialIcon('https://www.facebook.com/Oslo/')).toBe('facebook') expect(deriveSocialIcon('https://www.instagram.com/oslo.kommune/')).toBe('instagram') expect(deriveSocialIcon('https://www.linkedin.com/company/oslo-kommune')).toBe('linkedin') expect(deriveSocialIcon('https://x.com/oslo_kommune')).toBe('x') }) test('deriveSocialIcon strips www./m. subdomains', () => { expect(deriveSocialIcon('https://m.facebook.com/Oslo/')).toBe('facebook') expect(deriveSocialIcon('https://facebook.com/Oslo/')).toBe('facebook') }) test('deriveSocialIcon ignores localized text when URL resolves', () => { expect(deriveSocialIcon('https://www.facebook.com/Oslo/', 'Følg oss på Facebook')).toBe( 'facebook', ) }) test('deriveSocialIcon falls back to text when the URL is unknown or invalid', () => { expect(deriveSocialIcon('mailto:foo@example.com', 'Facebook')).toBe('facebook') expect(deriveSocialIcon('not-a-url', 'LinkedIn')).toBe('linkedin') }) test('deriveSocialIcon falls back to slugified text for unknown platforms', () => { expect(deriveSocialIcon(undefined, 'Threads')).toBe('threads') }) test('deriveSocialIcon returns undefined when both inputs are empty', () => { expect(deriveSocialIcon()).toBeUndefined() expect(deriveSocialIcon('', '')).toBeUndefined() expect(deriveSocialIcon('not-a-url', ' ')).toBeUndefined() }) }) describe('shared-utils: select-megamenu', () => { test('selectLocaleData returns the requested locale slice', () => { expect(selectLocaleData(LOCAL_DATA, 'en-GB')?.i18n?.navAriaLabel).toBe('Oslo kommune main menu') }) test('selectLocaleData falls back to nb-NO for missing locales', () => { expect(selectLocaleData(LOCAL_DATA, 'fr-FR')?.i18n?.navAriaLabel).toBe('Oslo kommune hovedmeny') }) test('selectMegamenu returns the megamenu slice for the locale', () => { expect(selectMegamenu(LOCAL_DATA, 'nb-NO')?.services.title).toBe('Tjenester og tilbud') expect(selectMegamenu(LOCAL_DATA, 'en-GB')?.services.title).toBe('Services and information') }) test('selectLocaleData returns undefined for missing input', () => { expect(selectLocaleData(undefined)).toBeUndefined() }) }) describe('shared-utils: external-domain', () => { test('returns the top domain for hosts outside oslo.kommune.no', () => { expect( getExternalDomainLabel( 'https://candidate.webcruiter.com/nb-no/home/companyadverts?companylock=2180#search', ), ).toBe('webcruiter.com') expect(getExternalDomainLabel('https://www.facebook.com/Oslo/')).toBe('facebook.com') }) test('handles two-part TLDs', () => { expect(getExternalDomainLabel('https://news.bbc.co.uk/article')).toBe('bbc.co.uk') }) test('returns undefined for oslo.kommune.no and subdomains', () => { expect(getExternalDomainLabel('https://www.oslo.kommune.no/kontakt/')).toBeUndefined() expect(getExternalDomainLabel('https://oslo.kommune.no/')).toBeUndefined() expect(getExternalDomainLabel('https://tjenester.oslo.kommune.no/booking')).toBeUndefined() }) test('returns undefined for relative URLs, fragments, and non-http schemes', () => { expect(getExternalDomainLabel('/jobb-i-oslo-kommune/')).toBeUndefined() expect(getExternalDomainLabel('#cb-trigger')).toBeUndefined() expect(getExternalDomainLabel('mailto:postmottak@oslo.kommune.no')).toBeUndefined() expect(getExternalDomainLabel('tel:+4721802180')).toBeUndefined() expect(getExternalDomainLabel('')).toBeUndefined() expect(getExternalDomainLabel(undefined)).toBeUndefined() }) test('honors a custom internal host', () => { expect( getExternalDomainLabel('https://intranett.example.no/side', 'example.no'), ).toBeUndefined() expect(getExternalDomainLabel('https://www.oslo.kommune.no/', 'example.no')).toBe('oslo.kommune.no') }) test('getLinkDomainLabel skips the internal-host check (explicit external flag)', () => { expect(getLinkDomainLabel('https://www.oslo.kommune.no/kontakt/')).toBe('oslo.kommune.no') expect(getLinkDomainLabel('https://example.test/side')).toBe('example.test') expect(getLinkDomainLabel('/relativ')).toBeUndefined() expect(getLinkDomainLabel('mailto:foo@example.test')).toBeUndefined() }) }) describe('shared-utils: fetchHeaderFooterData dedup', () => { const PAYLOAD_URL = 'https://dedup.test/payload.json' const mockFetchOk = () => vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( new Response(JSON.stringify(LOCAL_DATA), { status: 200, headers: { 'Content-Type': 'application/json' }, }), ), ) beforeEach(() => { resetHeaderFooterDataCache() }) test('concurrent calls for the same URL share one request', async () => { const fetchSpy = mockFetchOk() const [a, b] = await Promise.all([ fetchHeaderFooterData(PAYLOAD_URL), fetchHeaderFooterData(PAYLOAD_URL), ]) expect(fetchSpy).toHaveBeenCalledTimes(1) expect(a).toEqual(LOCAL_DATA) expect(b).toEqual(LOCAL_DATA) }) test('successful payloads stay cached for later calls', async () => { const fetchSpy = mockFetchOk() await fetchHeaderFooterData(PAYLOAD_URL) await fetchHeaderFooterData(PAYLOAD_URL) expect(fetchSpy).toHaveBeenCalledTimes(1) }) test('different URLs fetch independently', async () => { const fetchSpy = mockFetchOk() await Promise.all([ fetchHeaderFooterData(PAYLOAD_URL), fetchHeaderFooterData('https://dedup.test/other.json'), ]) expect(fetchSpy).toHaveBeenCalledTimes(2) }) test('failed fetches are evicted so a retry hits the network', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockResolvedValueOnce(new Response('boom', { status: 500 })) .mockResolvedValueOnce( new Response(JSON.stringify(LOCAL_DATA), { status: 200, headers: { 'Content-Type': 'application/json' }, }), ) await expect(fetchHeaderFooterData(PAYLOAD_URL)).rejects.toThrow('HTTP 500') await expect(fetchHeaderFooterData(PAYLOAD_URL)).resolves.toEqual(LOCAL_DATA) expect(fetchSpy).toHaveBeenCalledTimes(2) }) test('aborting one caller does not cancel the shared request', async () => { mockFetchOk() const controller = new AbortController() const aborted = fetchHeaderFooterData(PAYLOAD_URL, controller.signal) const kept = fetchHeaderFooterData(PAYLOAD_URL) controller.abort() await expect(aborted).rejects.toThrow() await expect(kept).resolves.toEqual(LOCAL_DATA) }) test('rejects immediately when the signal is already aborted', async () => { mockFetchOk() const controller = new AbortController() controller.abort() await expect(fetchHeaderFooterData(PAYLOAD_URL, controller.signal)).rejects.toThrow() }) }) describe('PktHeaderMenu rendering', () => { test('renders services, sections, and footer when given pre-fetched data', async () => { const el = await mountWithData() expect(el.querySelector('.pkt-header-menu__services-title')?.textContent).toContain( 'Tjenester og tilbud', ) expect(el.querySelectorAll('.pkt-header-menu__service')).toHaveLength(3) expect(el.querySelectorAll('.pkt-header-menu__section')).toHaveLength(2) expect(el.querySelectorAll('.pkt-header-menu__footer-link')).toHaveLength(2) }) test('does not fetch when data prop is supplied', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('should not run')) await mountWithData() expect(fetchSpy).not.toHaveBeenCalled() }) test('maps ODS icon names on service links', async () => { const el = await mountWithData() const serviceIcons = el.querySelectorAll('.pkt-header-menu__service-icon') expect(serviceIcons[0].getAttribute('name')).toBe('24h') expect(serviceIcons[1].getAttribute('name')).toBe('shield-fire') // Unmapped names pass through expect(serviceIcons[2].getAttribute('name')).toBe('unknown-icon') }) test('uses the locale-specific nav aria-label', async () => { const el = await mountWithData() const nav = el.querySelector('nav') expect(nav?.getAttribute('aria-label')).toBe('Oslo kommune hovedmeny') }) test('switches to en-GB content when locale changes', async () => { const el = await mountWithData() el.locale = 'en-GB' await el.updateComplete const nav = el.querySelector('nav') expect(nav?.getAttribute('aria-label')).toBe('Oslo kommune main menu') expect(el.querySelector('.pkt-header-menu__services-title')?.textContent).toContain( 'Services and information', ) }) test('renders social links with derived aria-labels and icons', async () => { const el = await mountWithData() const socialLinks = el.querySelectorAll('.pkt-header-menu__social-link') expect(socialLinks).toHaveLength(2) expect(socialLinks[0].getAttribute('aria-label')).toBe('Facebook') expect(socialLinks[0].querySelector('pkt-icon')?.getAttribute('name')).toBe('facebook') expect(socialLinks[1].getAttribute('aria-label')).toBe('LinkedIn') expect(socialLinks[1].querySelector('pkt-icon')?.getAttribute('name')).toBe('linkedin') }) test('reflects the open prop to a host class', async () => { const el = await mountWithData() expect(el.classList.contains('pkt-header-menu')).toBe(true) expect(el.classList.contains('pkt-header-menu--open')).toBe(true) el.open = false await el.updateComplete expect(el.classList.contains('pkt-header-menu--open')).toBe(false) }) test('emits data-loaded with the supplied payload', async () => { const el = document.createElement('pkt-header-menu') as PktHeaderMenu el.data = LOCAL_DATA const events: CustomEvent[] = [] el.addEventListener('data-loaded', (e) => events.push(e as CustomEvent)) document.body.appendChild(el) await waitForReady() await el.updateComplete expect(events.length).toBeGreaterThanOrEqual(1) expect(events[0].detail.data).toBe(LOCAL_DATA) }) }) describe('PktHeaderMenu fetching', () => { beforeEach(() => { vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( new Response(JSON.stringify(LOCAL_DATA), { status: 200, headers: { 'Content-Type': 'application/json' }, }), ), ) }) test('fetches the default URL on connect when no data is supplied', async () => { const el = document.createElement('pkt-header-menu') as PktHeaderMenu el.open = true document.body.appendChild(el) await waitForReady() // Wait for the async fetch chain to resolve. await new Promise((resolve) => setTimeout(resolve, 0)) await el.updateComplete expect(globalThis.fetch).toHaveBeenCalledWith( 'https://cdn.web.oslo.kommune.no/header-footer/header-footer.json', ) expect(el.querySelector('.pkt-header-menu__services-title')?.textContent).toContain( 'Tjenester og tilbud', ) }) test('honors a custom data-url attribute', async () => { const el = document.createElement('pkt-header-menu') as PktHeaderMenu el.setAttribute('data-url', 'https://custom.test/payload.json') document.body.appendChild(el) await waitForReady() await new Promise((resolve) => setTimeout(resolve, 0)) expect(globalThis.fetch).toHaveBeenCalledWith('https://custom.test/payload.json') }) test('emits data-error when the fetch fails', async () => { vi.restoreAllMocks() vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response('boom', { status: 500 }) as unknown as Response, ) const el = document.createElement('pkt-header-menu') as PktHeaderMenu const events: CustomEvent[] = [] el.addEventListener('data-error', (e) => events.push(e as CustomEvent)) document.body.appendChild(el) await waitForReady() await new Promise((resolve) => setTimeout(resolve, 0)) await el.updateComplete expect(events.length).toBe(1) expect((events[0].detail.error as Error).message).toMatch(/HTTP 500/) expect(el.querySelector('.pkt-header-menu__error')).not.toBeNull() }) }) describe('PktHeaderMenu accessibility', () => { test('has no axe violations with the local-data payload', async () => { const el = await mountWithData() const results = await axe(el) expect(results).toHaveNoViolations() }) })