diff --git a/frontend/src/components/Captcha.jsx b/frontend/src/components/Captcha.jsx
index d87d826..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,55 +26,43 @@ 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 = ''
- window.turnstile.ready(() => {
- if (
- cancelled ||
- !containerRef.current ||
- !window.turnstile
- ) {
- return
+ localWidgetId = window.turnstile.render(
+ containerRef.current,
+ {
+ sitekey: TURNSTILE_SITE_KEY,
+ language: 'fr',
+ size: 'flexible',
+ appearance: 'always',
+
+ callback: (token) => {
+ onToken(token)
+ },
+
+ 'expired-callback': () => {
+ onToken('')
+ },
+
+ 'error-callback': () => {
+ onToken('')
+ },
}
+ )
- widgetIdRef.current = window.turnstile.render(
- containerRef.current,
- {
- sitekey: TURNSTILE_SITE_KEY,
- language: 'fr',
- size: 'flexible',
- appearance: 'always',
-
- callback: (token) => {
- if (!cancelled) {
- onToken(token)
- }
- },
-
- 'expired-callback': () => {
- if (!cancelled) {
- onToken('')
- }
- },
-
- 'error-callback': () => {
- if (!cancelled) {
- onToken('')
- }
- },
- }
- )
- })
+ widgetIdRef.current = localWidgetId
}
const existingScript = document.getElementById(
@@ -104,22 +93,21 @@ export default function Captcha({ onToken }) {
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
-
- if (containerRef.current) {
- containerRef.current.innerHTML = ''
- }
}
}, [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')
+ })
+
+})