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
Mail 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 = `
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 = `
Modal content
`
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 = `
Modal A
Modal B
`
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 = `
Refuse form
`
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 = `