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:
110
tests/features/auth/confirm-invite.test.tsx
Normal file
110
tests/features/auth/confirm-invite.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user