Qué se implementó:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

src/features/auth/ConfirmInvitePage.tsx — nuevo componente con tres estados:

idle: pantalla de bienvenida + botón "Confirmar acceso" (estado inicial con params válidos)
loading: botón con spinner mientras se llama a verifyOtp
error: mensaje descriptivo + link a /login (token vacío/incorrecto en params, o fallo en verifyOtp)
El verifyOtp solo se llama al click del usuario — nunca al montar, por eso el pre-scan del email no consume el token.

src/router.tsx — ruta /auth/confirm agregada como pública (PUBLIC_PATHS) + confirmInviteRoute en el árbol de rutas.

tests/features/auth/confirm-invite.test.tsx — 6 tests cubriendo: no-call on mount, error con params vacíos, error con type distinto, llamada correcta a verifyOtp, navegación en éxito, y error con mensaje de detalle.

ResetPasswordPage no requiere cambios — updateUser({ password }) solo necesita sesión activa, que verifyOtp crea en el paso previo.
This commit is contained in:
2026-07-07 11:30:21 -03:00
parent 074f726d62
commit 1f84997794
4 changed files with 400 additions and 1 deletions

View File

@@ -0,0 +1,110 @@
/**
* Tests de ConfirmInvitePage — flujo de invitación resiliente a email scanners.
* El token NO se consume al montar: solo cuando el usuario hace click en el botón.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
// ─── Referencias mutables para controlar los mocks entre tests ────────────────
const { mockVerifyOtp, mockNavigate, searchRef } = vi.hoisted(() => ({
mockVerifyOtp: vi.fn(),
mockNavigate: vi.fn(),
searchRef: { current: { token_hash: 'valid-hash-abc123', type: 'invite' } as Record<string, string> },
}))
vi.mock('@/lib/supabase', () => ({
supabase: {
auth: {
verifyOtp: mockVerifyOtp,
},
},
}))
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => mockNavigate,
useSearch: () => searchRef.current,
Link: ({ children, to, className }: { children: React.ReactNode; to: string; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
}))
// Importar después de los mocks
import { ConfirmInvitePage } from '@/features/auth/ConfirmInvitePage'
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('ConfirmInvitePage', () => {
beforeEach(() => {
vi.clearAllMocks()
searchRef.current = { token_hash: 'valid-hash-abc123', type: 'invite' }
mockVerifyOtp.mockResolvedValue({ data: {}, error: null })
})
it('1. renderiza el botón "Confirmar acceso" sin llamar a verifyOtp al montar', () => {
render(<ConfirmInvitePage />)
expect(screen.getByRole('button', { name: /confirmar acceso/i })).toBeInTheDocument()
expect(mockVerifyOtp).not.toHaveBeenCalled()
})
it('2. token_hash vacío muestra estado de error directamente (sin botón de confirmación)', () => {
searchRef.current = { token_hash: '', type: 'invite' }
render(<ConfirmInvitePage />)
expect(screen.queryByRole('button', { name: /confirmar acceso/i })).not.toBeInTheDocument()
expect(screen.getByText(/link de invitación no es válido o ya fue utilizado/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /ir al login/i })).toBeInTheDocument()
expect(mockVerifyOtp).not.toHaveBeenCalled()
})
it('3. type distinto de "invite" muestra error directamente', () => {
searchRef.current = { token_hash: 'some-hash', type: 'recovery' }
render(<ConfirmInvitePage />)
expect(screen.queryByRole('button', { name: /confirmar acceso/i })).not.toBeInTheDocument()
expect(screen.getByText(/link de invitación no es válido o ya fue utilizado/i)).toBeInTheDocument()
expect(mockVerifyOtp).not.toHaveBeenCalled()
})
it('4. click en el botón llama verifyOtp con el token_hash y type correctos', async () => {
render(<ConfirmInvitePage />)
fireEvent.click(screen.getByRole('button', { name: /confirmar acceso/i }))
await waitFor(() => {
expect(mockVerifyOtp).toHaveBeenCalledOnce()
expect(mockVerifyOtp).toHaveBeenCalledWith({
type: 'invite',
token_hash: 'valid-hash-abc123',
})
})
})
it('5. verifyOtp exitoso navega a /reset-password', async () => {
mockVerifyOtp.mockResolvedValue({ data: {}, error: null })
render(<ConfirmInvitePage />)
fireEvent.click(screen.getByRole('button', { name: /confirmar acceso/i }))
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({ to: '/reset-password' })
})
expect(screen.queryByText(/link de invitación no es válido/i)).not.toBeInTheDocument()
})
it('6. verifyOtp con error muestra mensaje de error y no navega', async () => {
mockVerifyOtp.mockResolvedValue({ data: null, error: { message: 'Token has expired or already used' } })
render(<ConfirmInvitePage />)
fireEvent.click(screen.getByRole('button', { name: /confirmar acceso/i }))
await waitFor(() => {
expect(screen.getByText(/link de invitación no es válido o ya fue utilizado/i)).toBeInTheDocument()
})
expect(screen.getByText(/Token has expired or already used/i)).toBeInTheDocument()
expect(mockNavigate).not.toHaveBeenCalled()
expect(screen.getByRole('link', { name: /ir al login/i })).toBeInTheDocument()
})
})