diff --git a/frontend/src/components/Captcha.jsx b/frontend/src/components/Captcha.jsx index 47d13e5..b817688 100644 --- a/frontend/src/components/Captcha.jsx +++ b/frontend/src/components/Captcha.jsx @@ -15,6 +15,7 @@ export default function Captcha({ onToken }) { } let cancelled = false + let localWidgetId = null const renderWidget = () => { if ( @@ -25,19 +26,21 @@ export default function Captcha({ onToken }) { return } - if (widgetIdRef.current !== null) { + // Nettoyage d'un éventuel widget précédent. + if (localWidgetId !== null) { try { - window.turnstile.remove(widgetIdRef.current) + window.turnstile.remove(localWidgetId) } catch { - // Widget déjà supprimé + // Widget déjà supprimé. } + localWidgetId = null widgetIdRef.current = null } containerRef.current.innerHTML = '' - widgetIdRef.current = window.turnstile.render( + localWidgetId = window.turnstile.render( containerRef.current, { sitekey: TURNSTILE_SITE_KEY, @@ -58,6 +61,8 @@ export default function Captcha({ onToken }) { }, } ) + + widgetIdRef.current = localWidgetId } const existingScript = document.getElementById( @@ -66,46 +71,42 @@ export default function Captcha({ onToken }) { if (window.turnstile) { renderWidget() - return - } - - if (existingScript) { + } else if (existingScript) { existingScript.addEventListener('load', renderWidget, { once: true, }) + } else { + const script = document.createElement('script') - return () => { - cancelled = true - } + script.id = TURNSTILE_SCRIPT_ID + script.src = TURNSTILE_SCRIPT_URL + script.async = true + script.defer = true + + script.addEventListener('load', renderWidget, { + once: true, + }) + + document.head.appendChild(script) } - const script = document.createElement('script') - - script.id = TURNSTILE_SCRIPT_ID - script.src = TURNSTILE_SCRIPT_URL - script.async = true - script.defer = true - - script.addEventListener('load', renderWidget, { - once: true, - }) - - document.head.appendChild(script) - return () => { cancelled = true - if ( - widgetIdRef.current !== null && - window.turnstile - ) { + const widgetId = + localWidgetId !== null + ? localWidgetId + : widgetIdRef.current + + if (widgetId !== null && window.turnstile) { try { - window.turnstile.remove(widgetIdRef.current) + window.turnstile.remove(widgetId) } catch { - // Widget déjà supprimé + // Widget déjà supprimé. } } + localWidgetId = null widgetIdRef.current = null } }, [onToken]) diff --git a/frontend/src/test/unit/Captcha.test.jsx b/frontend/src/test/unit/Captcha.test.jsx new file mode 100644 index 0000000..1b232af --- /dev/null +++ b/frontend/src/test/unit/Captcha.test.jsx @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, cleanup, waitFor } from '@testing-library/react' + +vi.mock('../../config/captcha', () => ({ + CAPTCHA_ENABLED: true, + TURNSTILE_SITE_KEY: '0x4AAAAAAE0AF9mR2Y1luOeG', +})) + +import Captcha from '../../components/Captcha' + +describe('Captcha', () => { + let originalTurnstile + + beforeEach(() => { + originalTurnstile = window.turnstile + + document.head + .querySelectorAll('#cf-turnstile-script') + .forEach((script) => script.remove()) + + window.turnstile = undefined + }) + + afterEach(() => { + cleanup() + window.turnstile = originalTurnstile + vi.restoreAllMocks() + }) + + it('rend le widget lorsque Turnstile est déjà disponible', async () => { + const onToken = vi.fn() + + window.turnstile = { + render: vi.fn((container, options) => { + options.callback('token-test') + return 'widget-1' + }), + remove: vi.fn(), + } + + render() + + await waitFor(() => { + expect(window.turnstile.render).toHaveBeenCalled() + }) + + expect(onToken).toHaveBeenCalledWith('token-test') + }) + + it('gère expiration et erreur du widget', async () => { + const onToken = vi.fn() + let options + + window.turnstile = { + render: vi.fn((container, opts) => { + options = opts + return 'widget-2' + }), + remove: vi.fn(), + } + + render() + + await waitFor(() => { + expect(options).toBeDefined() + }) + + options['expired-callback']() + options['error-callback']() + + expect(onToken).toHaveBeenNthCalledWith(1, '') + expect(onToken).toHaveBeenNthCalledWith(2, '') + }) + + it('charge Turnstile lorsque le script existe déjà', async () => { + const onToken = vi.fn() + + const script = document.createElement('script') + script.id = 'cf-turnstile-script' + document.head.appendChild(script) + + render() + + window.turnstile = { + render: vi.fn(() => 'widget-3'), + remove: vi.fn(), + } + + script.dispatchEvent(new Event('load')) + + await waitFor(() => { + expect(window.turnstile.render).toHaveBeenCalled() + }) + }) + + it('supprime le widget au démontage', async () => { + const onToken = vi.fn() + const remove = vi.fn() + + window.turnstile = { + render: vi.fn(() => 'widget-4'), + remove, + } + + const { unmount } = render() + + await waitFor(() => { + expect(window.turnstile.render).toHaveBeenCalledTimes(1) + }) + + // Le widget est bien créé avant le démontage. + unmount() + + expect(remove).toHaveBeenCalledWith('widget-4') + }) + + it('tolère une erreur lors de la suppression du widget', async () => { + const onToken = vi.fn() + const remove = vi.fn(() => { + throw new Error('widget déjà supprimé') + }) + + window.turnstile = { + render: vi.fn(() => 'widget-5'), + remove, + } + + const { unmount } = render() + + await waitFor(() => { + expect(window.turnstile.render).toHaveBeenCalledTimes(1) + }) + + expect(() => unmount()).not.toThrow() + + expect(remove).toHaveBeenCalledWith('widget-5') + }) + +})