import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' // Mock the entreprise-search module since it's imported by app.js vi.mock('../../assets/modules/entreprise-search.js', () => ({ initEntrepriseSearch: vi.fn(), })) // Mock the scss import vi.mock('../../assets/app.scss', () => ({})) // localStorage mock const localStorageMock = (() => { let store = {} return { getItem: vi.fn((key) => store[key] ?? null), setItem: vi.fn((key, value) => { store[key] = String(value) }), removeItem: vi.fn((key) => { delete store[key] }), clear: vi.fn(() => { store = {} }), get length() { return Object.keys(store).length }, key: vi.fn((i) => Object.keys(store)[i] ?? null), } })() Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) // Track DOMContentLoaded listeners so we can remove them between tests let domContentLoadedListeners = [] const originalAddEventListener = document.addEventListener.bind(document) const originalRemoveEventListener = document.removeEventListener.bind(document) document.addEventListener = function(type, listener, options) { if (type === 'DOMContentLoaded') { domContentLoadedListeners.push(listener) } return originalAddEventListener(type, listener, options) } describe('app.js DOMContentLoaded', () => { beforeEach(() => { // Remove all previously registered DOMContentLoaded listeners to prevent accumulation domContentLoadedListeners.forEach(listener => { originalRemoveEventListener('DOMContentLoaded', listener) }) domContentLoadedListeners = [] document.body.innerHTML = '' localStorageMock.clear() vi.restoreAllMocks() // Re-apply localStorage mock after restoreAllMocks localStorageMock.getItem.mockImplementation((key) => { // Use internal store - reimplemented per test via setItem return null }) }) const loadApp = async () => { vi.resetModules() // Re-mock the modules before re-import vi.doMock('../../assets/modules/entreprise-search.js', () => ({ initEntrepriseSearch: vi.fn(), })) vi.doMock('../../assets/app.scss', () => ({})) await import('../../assets/app.js') document.dispatchEvent(new Event('DOMContentLoaded')) } describe('Member/Admin checkboxes', () => { beforeEach(() => { document.body.innerHTML = ` ` }) it('unchecks other groups when member is checked', async () => { await loadApp() const member = document.querySelector('[value="gp_member"]') const admin = document.querySelector('[value="superadmin"]') const esyWeb = document.querySelector('[value="esy-web"]') admin.checked = true esyWeb.checked = true member.checked = true member.dispatchEvent(new Event('change')) expect(admin.checked).toBe(false) expect(esyWeb.checked).toBe(false) }) it('checks all groups and unchecks member when admin is checked', async () => { await loadApp() const member = document.querySelector('[value="gp_member"]') const admin = document.querySelector('[value="superadmin"]') const esyWeb = document.querySelector('[value="esy-web"]') admin.checked = true admin.dispatchEvent(new Event('change')) expect(member.checked).toBe(false) expect(esyWeb.checked).toBe(true) }) it('does nothing when admin is unchecked', async () => { await loadApp() const member = document.querySelector('[value="gp_member"]') const admin = document.querySelector('[value="superadmin"]') member.checked = true admin.checked = false admin.dispatchEvent(new Event('change')) expect(member.checked).toBe(true) }) }) describe('Stats period selector', () => { beforeEach(() => { document.body.innerHTML = ` ` }) it('shows custom range when custom is selected', async () => { await loadApp() const select = document.getElementById('stats-period-select') const range = document.getElementById('stats-custom-range') select.value = 'custom' select.dispatchEvent(new Event('change')) expect(range.classList.contains('hidden')).toBe(false) }) it('hides custom range when current is selected', async () => { await loadApp() const select = document.getElementById('stats-period-select') const range = document.getElementById('stats-custom-range') select.value = 'current' select.dispatchEvent(new Event('change')) expect(range.classList.contains('hidden')).toBe(true) }) }) describe('data-confirm forms', () => { it('prevents submission and shows confirm modal', async () => { document.body.innerHTML = '
' await loadApp() const form = document.querySelector('form') const event = new Event('submit', { cancelable: true }) form.dispatchEvent(event) // The custom confirm modal should prevent default expect(event.defaultPrevented).toBe(true) // The confirm modal should be visible const confirmModal = document.getElementById('confirm-modal') expect(confirmModal).not.toBeNull() expect(confirmModal.classList.contains('hidden')).toBe(false) }) it('closes confirm modal on cancel click', async () => { document.body.innerHTML = '
' await loadApp() const form = document.querySelector('form') form.dispatchEvent(new Event('submit', { cancelable: true })) const confirmModal = document.getElementById('confirm-modal') expect(confirmModal.classList.contains('hidden')).toBe(false) // Click cancel document.getElementById('confirm-cancel').click() expect(confirmModal.classList.contains('hidden')).toBe(true) }) it('closes confirm modal on overlay click', async () => { document.body.innerHTML = '
' await loadApp() const form = document.querySelector('form') form.dispatchEvent(new Event('submit', { cancelable: true })) const confirmModal = document.getElementById('confirm-modal') expect(confirmModal.classList.contains('hidden')).toBe(false) document.getElementById('confirm-overlay').click() expect(confirmModal.classList.contains('hidden')).toBe(true) }) it('closes confirm modal on Escape key', async () => { document.body.innerHTML = '
' await loadApp() const form = document.querySelector('form') form.dispatchEvent(new Event('submit', { cancelable: true })) const confirmModal = document.getElementById('confirm-modal') expect(confirmModal.classList.contains('hidden')).toBe(false) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) expect(confirmModal.classList.contains('hidden')).toBe(true) }) it('submits form when confirm OK is clicked', async () => { document.body.innerHTML = '
' await loadApp() const form = document.querySelector('form') form.requestSubmit = vi.fn() form.dispatchEvent(new Event('submit', { cancelable: true })) // Click OK to confirm document.getElementById('confirm-ok').click() expect(form.requestSubmit).toHaveBeenCalled() expect(document.getElementById('confirm-modal').classList.contains('hidden')).toBe(true) }) it('displays the confirm message from data attribute', async () => { document.body.innerHTML = '
' await loadApp() const form = document.querySelector('form') form.dispatchEvent(new Event('submit', { cancelable: true })) expect(document.getElementById('confirm-message').textContent).toBe('Voulez-vous supprimer ?') }) }) describe('Sidebar dropdown', () => { it('toggles dropdown menu and arrow on click', async () => { document.body.innerHTML = `
` await loadApp() const btn = document.querySelector('.sidebar-dropdown-btn') const menu = btn.nextElementSibling const arrow = btn.querySelector('.sidebar-dropdown-arrow') expect(menu.classList.contains('hidden')).toBe(true) expect(arrow.classList.contains('rotate-180')).toBe(false) btn.dispatchEvent(new MouseEvent('click', { bubbles: true })) expect(btn).not.toBeNull() expect(arrow).not.toBeNull() }) }) describe('Mobile sidebar', () => { beforeEach(() => { document.body.innerHTML = `
` }) it('opens sidebar on toggle click', async () => { await loadApp() document.getElementById('admin-sidebar-toggle').click() expect(document.getElementById('admin-sidebar').classList.contains('open')).toBe(true) }) it('closes sidebar on overlay click', async () => { await loadApp() const sidebar = document.getElementById('admin-sidebar') sidebar.classList.add('open') document.getElementById('admin-overlay').click() expect(sidebar.classList.contains('open')).toBe(false) }) }) describe('Mobile menu (public)', () => { it('toggles mobile menu and icons', async () => { document.body.innerHTML = ` ` await loadApp() document.getElementById('mobile-menu-btn').click() expect(document.getElementById('mobile-menu').classList.contains('hidden')).toBe(false) expect(document.getElementById('menu-icon-open').classList.contains('hidden')).toBe(true) expect(document.getElementById('menu-icon-close').classList.contains('hidden')).toBe(false) }) }) describe('Cookie banner', () => { beforeEach(() => { document.body.innerHTML = ` ` }) it('shows banner when no consent', async () => { localStorageMock.getItem.mockReturnValue(null) await loadApp() expect(document.getElementById('cookie-banner').classList.contains('hidden')).toBe(false) }) it('hides banner when already accepted', async () => { localStorageMock.getItem.mockReturnValue('accepted') await loadApp() expect(document.getElementById('cookie-banner').classList.contains('hidden')).toBe(true) }) it('hides banner and stores accepted on accept click', async () => { localStorageMock.getItem.mockReturnValue(null) await loadApp() document.getElementById('cookie-accept').click() expect(document.getElementById('cookie-banner').classList.contains('hidden')).toBe(true) expect(localStorageMock.setItem).toHaveBeenCalledWith('cookie_consent', 'accepted') }) it('hides banner and stores refused on refuse click', async () => { localStorageMock.getItem.mockReturnValue(null) await loadApp() document.getElementById('cookie-refuse').click() expect(document.getElementById('cookie-banner').classList.contains('hidden')).toBe(true) expect(localStorageMock.setItem).toHaveBeenCalledWith('cookie_consent', 'refused') }) }) describe('Tarif tabs', () => { beforeEach(() => { document.body.innerHTML = `
NDD content
` }) it('switches tabs on click', async () => { await loadApp() document.querySelector('[data-tab="mail"]').click() expect(document.getElementById('content-ndd').classList.contains('hidden')).toBe(true) expect(document.getElementById('content-mail').classList.contains('hidden')).toBe(false) }) }) describe('Search functionality', () => { it('search setup does nothing without elements', async () => { document.body.innerHTML = '' await loadApp() expect(true).toBe(true) }) it('hides results when query is too short', async () => { document.body.innerHTML = ` ` await loadApp() const input = document.getElementById('search-customers') input.value = 'a' input.dispatchEvent(new Event('input')) expect(document.getElementById('search-results').classList.contains('hidden')).toBe(true) }) it('performs search when query is long enough', async () => { document.body.innerHTML = ` ` globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { id: 1, fullName: 'Jean Dupont', email: 'jean@test.com' } ]) }) ) await loadApp() const input = document.getElementById('search-customers') input.value = 'jean' input.dispatchEvent(new Event('input')) // Wait for debounce (300ms) + fetch await new Promise(r => setTimeout(r, 400)) expect(globalThis.fetch).toHaveBeenCalled() }) it('performs search with no results', async () => { document.body.innerHTML = ` ` globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }) ) await loadApp() const input = document.getElementById('search-customers') input.value = 'xyz' input.dispatchEvent(new Event('input')) await new Promise(r => setTimeout(r, 400)) const results = document.getElementById('search-results') expect(results.classList.contains('hidden')).toBe(false) expect(results.innerHTML).toContain('Aucun resultat') }) it('performs search with revendeur result', async () => { document.body.innerHTML = ` ` globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { id: 1, raisonSociale: 'Ma SARL', codeRevendeur: 'REV-001' } ]) }) ) await loadApp() const input = document.getElementById('search-revendeurs') input.value = 'sarl' input.dispatchEvent(new Event('input')) await new Promise(r => setTimeout(r, 400)) const results = document.getElementById('search-results-revendeurs') expect(results.classList.contains('hidden')).toBe(false) expect(results.innerHTML).toContain('Ma SARL') expect(results.innerHTML).toContain('REV-001') }) it('hides results when clicking outside', async () => { document.body.innerHTML = `
Result
Outside
` await loadApp() const results = document.getElementById('search-results') document.dispatchEvent(new MouseEvent('click', { bubbles: true })) expect(results.classList.contains('hidden')).toBe(true) }) it('renders hit with firstName/lastName fallback', async () => { document.body.innerHTML = ` ` globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { id: 2, firstName: 'Marie', lastName: 'Martin' } ]) }) ) await loadApp() const input = document.getElementById('search-customers') input.value = 'marie' input.dispatchEvent(new Event('input')) await new Promise(r => setTimeout(r, 400)) const results = document.getElementById('search-results') expect(results.innerHTML).toContain('Marie Martin') }) }) describe('Modal open/close (data-modal-open / data-modal-close)', () => { it('opens a modal when clicking a data-modal-open button', async () => { document.body.innerHTML = ` ` await loadApp() document.querySelector('[data-modal-open="my-modal"]').click() expect(document.getElementById('my-modal').classList.contains('hidden')).toBe(false) }) it('closes a modal when clicking a data-modal-close button', async () => { document.body.innerHTML = `
Modal content
` await loadApp() // Modal starts visible expect(document.getElementById('my-modal').classList.contains('hidden')).toBe(false) document.querySelector('[data-modal-close="my-modal"]').click() expect(document.getElementById('my-modal').classList.contains('hidden')).toBe(true) }) it('does nothing if target modal does not exist', async () => { document.body.innerHTML = ` ` await loadApp() // Should not throw document.querySelector('[data-modal-open="nonexistent"]').click() document.querySelector('[data-modal-close="nonexistent"]').click() }) it('handles multiple modals independently', async () => { document.body.innerHTML = ` ` await loadApp() document.querySelector('[data-modal-open="modal-a"]').click() expect(document.getElementById('modal-a').classList.contains('hidden')).toBe(false) expect(document.getElementById('modal-b').classList.contains('hidden')).toBe(true) }) }) describe('SIRET search (prestataire creation)', () => { beforeEach(() => { document.body.innerHTML = `
` }) it('shows message when query is too short', async () => { await loadApp() const input = document.getElementById('siret-search-input') input.value = 'ab' document.getElementById('siret-search-btn').click() const results = document.getElementById('siret-search-results') expect(results.classList.contains('hidden')).toBe(false) expect(results.innerHTML).toContain('au moins 3 caracteres') }) it('shows loading state then results on search', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ results: [ { nom_complet: 'Test SARL', siege: { siret: '12345678901234', adresse: '1 rue de Paris', code_postal: '75001', libelle_commune: 'PARIS' } } ] }) }) ) await loadApp() const input = document.getElementById('siret-search-input') input.value = 'test sarl' document.getElementById('siret-search-btn').click() // Wait for fetch await new Promise(r => setTimeout(r, 50)) const results = document.getElementById('siret-search-results') expect(results.innerHTML).toContain('Test SARL') expect(results.innerHTML).toContain('12345678901234') expect(globalThis.fetch).toHaveBeenCalledWith('/admin/prestataires/entreprise-search?q=test%20sarl') }) it('shows no results message when empty', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ results: [] }) }) ) await loadApp() document.getElementById('siret-search-input').value = 'zzzzz' document.getElementById('siret-search-btn').click() await new Promise(r => setTimeout(r, 50)) const results = document.getElementById('siret-search-results') expect(results.innerHTML).toContain('Aucun resultat') }) it('fills form fields when a result is clicked', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ results: [ { nom_complet: 'Ma Societe', siege: { siret: '98765432109876', adresse: '10 avenue de Lyon', code_postal: '69001', libelle_commune: 'LYON' } } ] }) }) ) await loadApp() document.getElementById('siret-search-input').value = 'ma societe' document.getElementById('siret-search-btn').click() await new Promise(r => setTimeout(r, 50)) // Click the first result const resultItem = document.querySelector('.siret-result-item') expect(resultItem).not.toBeNull() resultItem.click() expect(document.querySelector('[name="raisonSociale"]').value).toBe('Ma Societe') expect(document.querySelector('[name="siret"]').value).toBe('98765432109876') expect(document.querySelector('[name="address"]').value).toBe('10 avenue de Lyon') expect(document.querySelector('[name="zipCode"]').value).toBe('69001') expect(document.querySelector('[name="city"]').value).toBe('LYON') // Results should be hidden and input cleared expect(document.getElementById('siret-search-results').classList.contains('hidden')).toBe(true) expect(document.getElementById('siret-search-input').value).toBe('') }) it('shows error message on fetch failure', async () => { globalThis.fetch = vi.fn(() => Promise.reject(new Error('Network error'))) await loadApp() document.getElementById('siret-search-input').value = 'test' document.getElementById('siret-search-btn').click() await new Promise(r => setTimeout(r, 50)) const results = document.getElementById('siret-search-results') expect(results.innerHTML).toContain('Erreur lors de la recherche') }) it('triggers search on Enter key in input', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ results: [] }) }) ) await loadApp() const input = document.getElementById('siret-search-input') input.value = 'test enter' const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }) input.dispatchEvent(event) await new Promise(r => setTimeout(r, 50)) expect(globalThis.fetch).toHaveBeenCalled() }) it('hides results when clicking outside', async () => { await loadApp() const results = document.getElementById('siret-search-results') results.classList.remove('hidden') results.innerHTML = '

Some results

' // Click on document body (outside) document.body.click() // Need to trigger click on document level document.dispatchEvent(new MouseEvent('click', { bubbles: true })) expect(results.classList.contains('hidden')).toBe(true) }) }) describe('Refuse toggle button', () => { it('toggles refuse form visibility', async () => { document.body.innerHTML = ` ` await loadApp() document.getElementById('refuse-toggle-btn').click() expect(document.getElementById('refuse-form').classList.contains('hidden')).toBe(false) document.getElementById('refuse-toggle-btn').click() expect(document.getElementById('refuse-form').classList.contains('hidden')).toBe(true) }) }) describe('initTabSearch', () => { const lineTemplate = ` ` beforeEach(() => { document.body.innerHTML = ` ` }) it('does nothing when elements are absent', async () => { document.body.innerHTML = '' await loadApp() expect(true).toBe(true) }) it('hides results when query is shorter than 2 chars', async () => { await loadApp() const input = document.getElementById('search-devis') const results = document.getElementById('search-devis-results') input.value = 'a' input.dispatchEvent(new Event('input')) expect(results.classList.contains('hidden')).toBe(true) }) it('shows results with correct HTML on successful search', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { numOrder: 'DEV-001', customerName: 'Client A', totalTtc: '120.00', state: 'send' } ]) }) ) await loadApp() const input = document.getElementById('search-devis') const results = document.getElementById('search-devis-results') input.value = 'DEV' input.dispatchEvent(new Event('input')) await new Promise(r => setTimeout(r, 350)) expect(globalThis.fetch).toHaveBeenCalledWith('/admin/devis/search?q=DEV') expect(results.classList.contains('hidden')).toBe(false) expect(results.innerHTML).toContain('DEV-001') expect(results.innerHTML).toContain('Client A') expect(results.innerHTML).toContain('120.00') }) it('shows "Aucun resultat" when search returns empty array', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }) ) await loadApp() const input = document.getElementById('search-devis') const results = document.getElementById('search-devis-results') input.value = 'xyz' input.dispatchEvent(new Event('input')) await new Promise(r => setTimeout(r, 350)) expect(results.classList.contains('hidden')).toBe(false) expect(results.innerHTML).toContain('Aucun resultat') }) it('hides results when clicking outside', async () => { await loadApp() const results = document.getElementById('search-devis-results') results.classList.remove('hidden') document.dispatchEvent(new MouseEvent('click', { bubbles: true })) expect(results.classList.contains('hidden')).toBe(true) }) it('hides results on Escape key', async () => { await loadApp() const input = document.getElementById('search-devis') const results = document.getElementById('search-devis-results') results.classList.remove('hidden') input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) expect(results.classList.contains('hidden')).toBe(true) }) it('renders state labels and colors correctly', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { numOrder: 'DEV-002', customerName: 'Client B', totalTtc: '50.00', state: 'accepted' } ]) }) ) await loadApp() const input = document.getElementById('search-devis') const results = document.getElementById('search-devis-results') input.value = 'DEV' input.dispatchEvent(new Event('input')) await new Promise(r => setTimeout(r, 350)) expect(results.innerHTML).toContain('Accepte') expect(results.innerHTML).toContain('bg-green-500/20') }) it('renders unknown state as-is', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { numOrder: 'ADV-001', customerName: 'Client C', totalTtc: '200.00', state: 'pending' } ]) }) ) await loadApp() const input = document.getElementById('search-factures') const results = document.getElementById('search-factures-results') input.value = 'ADV' input.dispatchEvent(new Event('input')) await new Promise(r => setTimeout(r, 350)) expect(results.innerHTML).toContain('pending') }) }) describe('initDevisLines', () => { const lineTemplate = `
` beforeEach(() => { document.body.innerHTML = `