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')
+ })
+
+})