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.
111 lines
4.3 KiB
TypeScript
111 lines
4.3 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|