Complete TASK_CHECKUP: security, UX, tests, coverage, accessibility, config externalization
Billetterie: - Partial refund support (STATUS_PARTIALLY_REFUNDED, refundedAmount field, migration) - Race condition fix: PESSIMISTIC_WRITE lock on stock decrement in transaction - Idempotency key on PaymentIntent::create, reuse existing PI if stripeSessionId set - Disable checkout when event ended (server 400 + template hide) - Webhook deduplication via cache (24h TTL on stripe event.id) - Email validation (filter_var) in OrderController guest flow - JSON cart validation (structure check before processing) - Invitation expiration after 7 days (isExpired method + landing page message) - Stripe Checkout fallback when JS fails to load (noscript + redirect) Config externalization: - Move Stripe fees (STRIPE_FEE_RATE, STRIPE_FEE_FIXED) and admin email (ADMIN_EMAIL) to .env/services.yaml - Replace all hardcoded contact@e-cosplay.fr across 13 files - MailerService: getAdminEmail()/getAdminFrom(), default $from=null resolves to admin UX & Accessibility: - ARIA tabs: role=tablist/tab/tabpanel, aria-selected, keyboard nav (arrows, Home, End) - aria-label on cart +/- buttons and editor toolbar buttons - tabindex=0 on editor toolbar buttons for keyboard access - data-confirm handler in app.js (was only in admin.js) - Cart error feedback on checkout failure - Billet designer save feedback (loading/success/error states) - Stock polling every 30s with rupture/low stock badges - Back to event link on payment page Security: - HTML sanitizer: BLOCKED_TAGS list (script, style, iframe, svg, etc.) - content fully removed - Stripe polling timeout (15s max) with fallback redirect - Rate limiting on public order access (20/5min) - .catch() on all fetch() calls (sortable, billet-designer) Tests (92% PHP, 100% JS lines): - PCOV added to dev Dockerfile - Test DB setup: .env.test with DATABASE_URL, Redis auth, Meilisearch key - Rate limiter disabled in test env - Makefile: test_db_setup, test_db_reset, run_test_php, run_test_coverage_php/js - New tests: InvitationFlowTest (21), AuditServiceTest (4), ExportServiceTest (9), InvoiceServiceTest (4) - New tests: SuspendedUserSubscriberTest, RateLimiterSubscriberTest, MeilisearchServiceTest - New tests: Stripe webhook payment_failed (6) + charge.refunded (6) - New tests: BilletBuyer refund, User suspended, OrganizerInvitation expiration - JS tests: stock polling (6), data-confirm (2), copy-url restore (1), editor ARIA (2), XSS (9), tabs keyboard (9) - ESLint + PHP CS Fixer: 0 errors - SonarQube exclusions aligned with vitest coverage config Infra: - Meilisearch consistency command (app:meilisearch:check-consistency --fix) + cron daily 3am - MeilisearchService: getAllDocumentIds(), listIndexes() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,4 +23,36 @@ describe('app.js', () => {
|
||||
expect(initMobileMenu).toHaveBeenCalled()
|
||||
expect(initTabs).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('data-confirm prevents submit when cancelled', async () => {
|
||||
document.body.innerHTML = '<form data-confirm="Sure?"><button type="submit">Go</button></form>'
|
||||
|
||||
await import('../../assets/app.js')
|
||||
document.dispatchEvent(new Event('DOMContentLoaded'))
|
||||
|
||||
globalThis.confirm = vi.fn().mockReturnValue(false)
|
||||
|
||||
const form = document.querySelector('form')
|
||||
const event = new Event('submit', { cancelable: true })
|
||||
form.dispatchEvent(event)
|
||||
|
||||
expect(globalThis.confirm).toHaveBeenCalledWith('Sure?')
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('data-confirm allows submit when confirmed', async () => {
|
||||
document.body.innerHTML = '<form data-confirm="Sure?"><button type="submit">Go</button></form>'
|
||||
|
||||
await import('../../assets/app.js')
|
||||
document.dispatchEvent(new Event('DOMContentLoaded'))
|
||||
|
||||
globalThis.confirm = vi.fn().mockReturnValue(true)
|
||||
|
||||
const form = document.querySelector('form')
|
||||
const event = new Event('submit', { cancelable: true })
|
||||
form.dispatchEvent(event)
|
||||
|
||||
expect(globalThis.confirm).toHaveBeenCalledWith('Sure?')
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { initCart } from '../../assets/modules/cart.js'
|
||||
|
||||
function createBilletterie(billets) {
|
||||
let html = '<div id="billetterie">'
|
||||
function createBilletterie(billets, stockUrl = '') {
|
||||
let html = `<div id="billetterie"${stockUrl ? ` data-stock-url="${stockUrl}"` : ''}>`
|
||||
|
||||
for (const b of billets) {
|
||||
html += `
|
||||
@@ -11,10 +11,12 @@ function createBilletterie(billets) {
|
||||
<input data-cart-qty type="number" min="0" max="${b.max || 99}" value="0" readonly>
|
||||
<button data-cart-plus></button>
|
||||
<span data-cart-line-total></span>
|
||||
<p data-stock-label></p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
html += '<div id="cart-error" class="hidden"><p id="cart-error-text"></p></div>'
|
||||
html += '<span id="cart-total"></span><span id="cart-count"></span><button id="cart-checkout" disabled data-order-url="/order"></button></div>'
|
||||
document.body.innerHTML = html
|
||||
}
|
||||
@@ -131,6 +133,7 @@ describe('initCart', () => {
|
||||
|
||||
it('posts cart data on checkout click', () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ redirect: '/commande/1/informations' }),
|
||||
})
|
||||
globalThis.fetch = fetchMock
|
||||
@@ -160,6 +163,7 @@ describe('initCart', () => {
|
||||
|
||||
it('redirects after successful checkout', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ redirect: '/commande/1/paiement' }),
|
||||
})
|
||||
globalThis.fetch = fetchMock
|
||||
@@ -178,6 +182,7 @@ describe('initCart', () => {
|
||||
|
||||
it('does not redirect when response has no redirect', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
globalThis.fetch = fetchMock
|
||||
@@ -265,6 +270,23 @@ describe('initCart', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error message on HTTP error', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 400 })
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 5 }])
|
||||
initCart()
|
||||
|
||||
document.querySelector('[data-cart-plus]').click()
|
||||
document.getElementById('cart-checkout').click()
|
||||
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
const errorEl = document.getElementById('cart-error')
|
||||
expect(errorEl.classList.contains('hidden')).toBe(false)
|
||||
expect(document.getElementById('cart-error-text').textContent).toContain('erreur')
|
||||
})
|
||||
|
||||
it('does not post without order url', () => {
|
||||
const fetchMock = vi.fn()
|
||||
globalThis.fetch = fetchMock
|
||||
@@ -289,3 +311,100 @@ describe('initCart', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stock polling', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mockStock(stock) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve(stock),
|
||||
})
|
||||
}
|
||||
|
||||
it('polls stock URL and updates labels for out of stock', async () => {
|
||||
const fetchMock = mockStock({ 1: 0 })
|
||||
globalThis.fetch = fetchMock
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
|
||||
initCart()
|
||||
|
||||
document.querySelector('[data-cart-plus]').click()
|
||||
document.querySelector('[data-cart-plus]').click()
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/stock')
|
||||
expect(document.querySelector('[data-stock-label]').innerHTML).toContain('Rupture')
|
||||
expect(document.querySelector('[data-cart-qty]').value).toBe('0')
|
||||
})
|
||||
|
||||
it('polls stock URL and shows low stock warning', async () => {
|
||||
globalThis.fetch = mockStock({ 1: 5 })
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 20 }], '/stock')
|
||||
initCart()
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(document.querySelector('[data-stock-label]').innerHTML).toContain('Plus que')
|
||||
})
|
||||
|
||||
it('polls stock URL and shows normal stock', async () => {
|
||||
globalThis.fetch = mockStock({ 1: 50 })
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 100 }], '/stock')
|
||||
initCart()
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(document.querySelector('[data-stock-label]').innerHTML).toContain('disponible')
|
||||
})
|
||||
|
||||
it('clamps qty when stock decreases below current selection', async () => {
|
||||
globalThis.fetch = mockStock({ 1: 2 })
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 10 }], '/stock')
|
||||
initCart()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
document.querySelector('[data-cart-plus]').click()
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(document.querySelector('[data-cart-qty]').value).toBe('2')
|
||||
})
|
||||
|
||||
it('does not poll without stock URL', () => {
|
||||
const fetchMock = vi.fn()
|
||||
globalThis.fetch = fetchMock
|
||||
const origSetInterval = globalThis.setInterval
|
||||
const intervalSpy = vi.fn()
|
||||
globalThis.setInterval = intervalSpy
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 5 }])
|
||||
initCart()
|
||||
|
||||
expect(intervalSpy).not.toHaveBeenCalled()
|
||||
globalThis.setInterval = origSetInterval
|
||||
})
|
||||
|
||||
it('handles stock poll fetch error gracefully', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network'))
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
|
||||
initCart()
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
// No crash, label unchanged
|
||||
expect(document.querySelector('[data-stock-label]').innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,5 +29,28 @@ describe('initCopyUrl', () => {
|
||||
|
||||
await new Promise(r => { setTimeout(r, 10) })
|
||||
expect(writeText).toHaveBeenCalledWith('https://example.com/event/1-test')
|
||||
expect(document.getElementById('copy-url-btn').textContent).toBe('Copie !')
|
||||
})
|
||||
|
||||
it('restores button text after 2 seconds', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
document.body.innerHTML = `
|
||||
<p id="event-url">https://example.com</p>
|
||||
<button id="copy-url-btn">Copier le lien</button>
|
||||
`
|
||||
|
||||
globalThis.navigator = { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }
|
||||
|
||||
initCopyUrl()
|
||||
document.getElementById('copy-url-btn').click()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(document.getElementById('copy-url-btn').textContent).toBe('Copie !')
|
||||
|
||||
vi.advanceTimersByTime(2000)
|
||||
expect(document.getElementById('copy-url-btn').textContent).toBe('Copier le lien')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,6 +40,46 @@ describe('sanitizeHtml', () => {
|
||||
const html = '<p style="color:red">Text</p>'
|
||||
expect(sanitizeHtml(html)).toBe('<p>Text</p>')
|
||||
})
|
||||
|
||||
it('strips onclick from allowed tags', () => {
|
||||
const html = '<b onclick="alert(1)">Bold</b>'
|
||||
expect(sanitizeHtml(html)).toBe('<b>Bold</b>')
|
||||
})
|
||||
|
||||
it('strips onerror from allowed tags', () => {
|
||||
const html = '<p onerror="alert(1)">Text</p>'
|
||||
expect(sanitizeHtml(html)).toBe('<p>Text</p>')
|
||||
})
|
||||
|
||||
it('strips class and id from allowed tags', () => {
|
||||
const html = '<p class="evil" id="inject">Text</p>'
|
||||
expect(sanitizeHtml(html)).toBe('<p>Text</p>')
|
||||
})
|
||||
|
||||
it('strips data attributes from allowed tags', () => {
|
||||
const html = '<ul data-x="1"><li data-y="2">Item</li></ul>'
|
||||
expect(sanitizeHtml(html)).toBe('<ul><li>Item</li></ul>')
|
||||
})
|
||||
|
||||
it('strips script tags entirely', () => {
|
||||
const html = '<p>Safe</p><script>alert(1)</script>'
|
||||
expect(sanitizeHtml(html)).toBe('<p>Safe</p>')
|
||||
})
|
||||
|
||||
it('strips img onerror XSS', () => {
|
||||
const html = '<img src=x onerror="alert(1)">'
|
||||
expect(sanitizeHtml(html)).toBe('')
|
||||
})
|
||||
|
||||
it('strips nested disallowed tags with attributes', () => {
|
||||
const html = '<div style="background:url(evil)"><p onclick="steal()">OK</p></div>'
|
||||
expect(sanitizeHtml(html)).toBe('<p>OK</p>')
|
||||
})
|
||||
|
||||
it('strips href from anchor but keeps text', () => {
|
||||
const html = '<a href="javascript:alert(1)">Click</a>'
|
||||
expect(sanitizeHtml(html)).toBe('Click')
|
||||
})
|
||||
})
|
||||
|
||||
function createEditor(innerHtml = '<textarea></textarea>') {
|
||||
@@ -91,6 +131,22 @@ describe('ETicketEditor', () => {
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('toolbar buttons have aria-label', () => {
|
||||
const editor = createEditor()
|
||||
const buttons = editor.querySelectorAll('.ete-btn')
|
||||
buttons.forEach(btn => {
|
||||
expect(btn.getAttribute('aria-label')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('toolbar buttons have tabindex=0', () => {
|
||||
const editor = createEditor()
|
||||
const buttons = editor.querySelectorAll('.ete-btn')
|
||||
buttons.forEach(btn => {
|
||||
expect(btn.tabIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('toolbar has separators', () => {
|
||||
const editor = createEditor()
|
||||
const separators = editor.querySelectorAll('.ete-separator')
|
||||
|
||||
@@ -218,6 +218,25 @@ describe('initSortable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads page on fetch error during drop', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('Network'))
|
||||
globalThis.fetch = fetchMock
|
||||
const reloadMock = vi.fn()
|
||||
globalThis.location = { reload: reloadMock }
|
||||
|
||||
const list = createList('/api/reorder')
|
||||
addItem(list, 1)
|
||||
|
||||
initSortable()
|
||||
|
||||
const dropEvent = createDragEvent('drop')
|
||||
list.dispatchEvent(dropEvent)
|
||||
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
expect(reloadMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('initializes billets-list sortable', () => {
|
||||
const list = document.createElement('div')
|
||||
list.classList.add('billets-list')
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { initTabs } from '../../assets/modules/tabs.js'
|
||||
|
||||
describe('initTabs', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
function setup() {
|
||||
document.body.innerHTML = `
|
||||
<div id="tablist">
|
||||
<button data-tab="tab-a" style="background-color:#111827;color:white;">Tab A</button>
|
||||
<button data-tab="tab-b" style="background-color:white;color:#111827;">Tab B</button>
|
||||
<div id="tab-a" style="display:block;">Content A</div>
|
||||
<div id="tab-b" style="display:none;">Content B</div>
|
||||
`
|
||||
})
|
||||
<button data-tab="tab-c" style="background-color:white;color:#111827;">Tab C</button>
|
||||
</div>
|
||||
<div id="tab-a" style="display:block;">Content A</div>
|
||||
<div id="tab-b" style="display:none;">Content B</div>
|
||||
<div id="tab-c" style="display:none;">Content C</div>
|
||||
`
|
||||
initTabs()
|
||||
}
|
||||
|
||||
describe('initTabs', () => {
|
||||
beforeEach(() => setup())
|
||||
|
||||
it('switches active tab on click', () => {
|
||||
initTabs()
|
||||
|
||||
const btnB = document.querySelector('[data-tab="tab-b"]')
|
||||
btnB.click()
|
||||
document.querySelector('[data-tab="tab-b"]').click()
|
||||
|
||||
expect(document.getElementById('tab-a').style.display).toBe('none')
|
||||
expect(document.getElementById('tab-b').style.display).toBe('block')
|
||||
})
|
||||
|
||||
it('updates button styles on click', () => {
|
||||
initTabs()
|
||||
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
const btnB = document.querySelector('[data-tab="tab-b"]')
|
||||
btnB.click()
|
||||
@@ -35,8 +37,6 @@ describe('initTabs', () => {
|
||||
})
|
||||
|
||||
it('switches back to first tab', () => {
|
||||
initTabs()
|
||||
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
const btnB = document.querySelector('[data-tab="tab-b"]')
|
||||
|
||||
@@ -52,3 +52,136 @@ describe('initTabs', () => {
|
||||
expect(() => initTabs()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ARIA attributes', () => {
|
||||
beforeEach(() => setup())
|
||||
|
||||
it('sets role=tablist on parent', () => {
|
||||
expect(document.getElementById('tablist').getAttribute('role')).toBe('tablist')
|
||||
})
|
||||
|
||||
it('sets role=tab on each button', () => {
|
||||
document.querySelectorAll('[data-tab]').forEach(btn => {
|
||||
expect(btn.getAttribute('role')).toBe('tab')
|
||||
})
|
||||
})
|
||||
|
||||
it('sets aria-controls matching panel id', () => {
|
||||
const btn = document.querySelector('[data-tab="tab-b"]')
|
||||
expect(btn.getAttribute('aria-controls')).toBe('tab-b')
|
||||
})
|
||||
|
||||
it('sets role=tabpanel on panels', () => {
|
||||
expect(document.getElementById('tab-a').getAttribute('role')).toBe('tabpanel')
|
||||
expect(document.getElementById('tab-b').getAttribute('role')).toBe('tabpanel')
|
||||
})
|
||||
|
||||
it('sets aria-labelledby on panels', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
expect(document.getElementById('tab-a').getAttribute('aria-labelledby')).toBe(btnA.id)
|
||||
})
|
||||
|
||||
it('sets aria-selected=true on active tab', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
const btnB = document.querySelector('[data-tab="tab-b"]')
|
||||
expect(btnA.getAttribute('aria-selected')).toBe('true')
|
||||
expect(btnB.getAttribute('aria-selected')).toBe('false')
|
||||
})
|
||||
|
||||
it('updates aria-selected on click', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
const btnB = document.querySelector('[data-tab="tab-b"]')
|
||||
btnB.click()
|
||||
|
||||
expect(btnA.getAttribute('aria-selected')).toBe('false')
|
||||
expect(btnB.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('sets tabindex=0 on active, -1 on inactive', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
const btnB = document.querySelector('[data-tab="tab-b"]')
|
||||
expect(btnA.getAttribute('tabindex')).toBe('0')
|
||||
expect(btnB.getAttribute('tabindex')).toBe('-1')
|
||||
})
|
||||
|
||||
it('updates tabindex on click', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
const btnB = document.querySelector('[data-tab="tab-b"]')
|
||||
btnB.click()
|
||||
|
||||
expect(btnA.getAttribute('tabindex')).toBe('-1')
|
||||
expect(btnB.getAttribute('tabindex')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
beforeEach(() => setup())
|
||||
|
||||
it('ArrowRight moves to next tab', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
|
||||
|
||||
expect(document.querySelector('[data-tab="tab-b"]').getAttribute('aria-selected')).toBe('true')
|
||||
expect(document.getElementById('tab-b').style.display).toBe('block')
|
||||
})
|
||||
|
||||
it('ArrowLeft moves to previous tab', () => {
|
||||
const btnB = document.querySelector('[data-tab="tab-b"]')
|
||||
btnB.click()
|
||||
btnB.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
|
||||
|
||||
expect(document.querySelector('[data-tab="tab-a"]').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('ArrowRight wraps from last to first', () => {
|
||||
const btnC = document.querySelector('[data-tab="tab-c"]')
|
||||
btnC.click()
|
||||
btnC.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
|
||||
|
||||
expect(document.querySelector('[data-tab="tab-a"]').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('ArrowLeft wraps from first to last', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
|
||||
|
||||
expect(document.querySelector('[data-tab="tab-c"]').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('Home moves to first tab', () => {
|
||||
const btnC = document.querySelector('[data-tab="tab-c"]')
|
||||
btnC.click()
|
||||
btnC.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }))
|
||||
|
||||
expect(document.querySelector('[data-tab="tab-a"]').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('End moves to last tab', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }))
|
||||
|
||||
expect(document.querySelector('[data-tab="tab-c"]').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('ArrowDown moves to next tab', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
|
||||
expect(document.querySelector('[data-tab="tab-b"]').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('ArrowUp moves to previous tab', () => {
|
||||
const btnB = document.querySelector('[data-tab="tab-b"]')
|
||||
btnB.click()
|
||||
btnB.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
|
||||
|
||||
expect(document.querySelector('[data-tab="tab-a"]').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('other keys do nothing', () => {
|
||||
const btnA = document.querySelector('[data-tab="tab-a"]')
|
||||
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
|
||||
expect(btnA.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user