import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import AltaClientePage from './AltaClientePage' // El lienzo de firma necesita canvas 2d (no existe en jsdom); el paso 7 se // prueba solo hasta la orquestación de la página, no el trazo de la firma. // Stub del paso de firmas: expone el botón de registro (el registro del alta // ahora se dispara desde este paso, no al salir de Documentos) vi.mock('../forms/FirmasForm', () => ({ default: ({ onRegistrar }: { onRegistrar?: () => void }) => (

Paso de firmas

), })) type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise const respuesta = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) let fetchMock: ReturnType> const llamadasA = (fragmento: string, metodo?: string) => fetchMock.mock.calls.filter( ([input, init]) => String(input).includes(fragmento) && (metodo === undefined || (init?.method ?? 'GET') === metodo), ) beforeEach(() => { fetchMock = vi.fn().mockImplementation((input, init) => { const url = String(input) const metodo = init?.method ?? 'GET' if (url.includes('/borradores') && metodo === 'GET') return Promise.resolve(respuesta([])) if (url.includes('/borradores') && metodo === 'POST') { return Promise.resolve(respuesta({ id: 'b-1', nombre: 'B', paso: 1, datosJson: '{}' }), ) } if (url.includes('/borradores') && metodo === 'DELETE') return Promise.resolve(respuesta({})) if (url.endsWith('/clientes/progreso/iniciar')) { return Promise.resolve(respuesta({ id: 'prog-1', mondayContactoId: 'cont-1' }, 201)) } if (url.includes('/clientes/progreso/prog-1/completar')) return Promise.resolve(respuesta({ id: 'prog-1' }, 201)) if (url.includes('/clientes/progreso/prog-1') && metodo === 'PATCH') { return Promise.resolve(respuesta({ id: 'prog-1', mondaySync: true })) } if (url.endsWith('/clientes') && metodo === 'POST') return Promise.resolve(respuesta({ id: 'cli-directo' }, 201)) return Promise.reject(new Error(`fetch inesperado: ${metodo} ${url}`)) }) vi.stubGlobal('fetch', fetchMock) }) afterEach(() => { cleanup() vi.unstubAllGlobals() }) const entrarAlWizard = async () => { render() fireEvent.click(await screen.findByRole('button', { name: /Alta de Cliente/ })) fireEvent.click(await screen.findByRole('button', { name: 'Empezar nuevo' })) } const llenarPaso0 = async () => { fireEvent.change(await screen.findByLabelText(/Nombre completo/), { target: { value: 'Ana Demo Progreso' } }) fireEvent.change(screen.getByLabelText(/^CURP/), { target: { value: 'AEDA900101MBCXXX00' } }) fireEvent.change(screen.getByLabelText(/^RFC/), { target: { value: 'AEDA900101XX0' } }) fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: 'ana@correo.com' } }) fireEvent.change(screen.getByLabelText(/Celular/), { target: { value: '6649990003' } }) fireEvent.change(screen.getByLabelText(/Fecha de nacimiento/), { target: { value: '1990-01-01' } }) } const avanzar = async () => { fireEvent.click(screen.getByRole('button', { name: 'Siguiente' })) await act(async () => {}) } describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => { it('al avanzar del paso 0 con nombre inicia el progreso (Contacto PROSPECTO)', async () => { await entrarAlWizard() await llenarPaso0() await avanzar() // Avanzó al paso Empleo y subió el inicio del progreso con datosGenerales expect(await screen.findByLabelText(/Tipo de empleo/)).toBeTruthy() await waitFor(() => expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1)) const cuerpo = JSON.parse(String(llamadasA('/clientes/progreso/iniciar', 'POST')[0]?.[1]?.body)) as { datosGenerales: { nombreCompleto: string } } expect(cuerpo.datosGenerales.nombreCompleto).toBe('Ana Demo Progreso') }) it('los pasos siguientes hacen PATCH del progreso ya iniciado', async () => { await entrarAlWizard() await llenarPaso0() await avanzar() await screen.findByLabelText(/Tipo de empleo/) await waitFor(() => expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1)) await avanzar() // Empleo → Económicos await waitFor(() => expect(llamadasA('/clientes/progreso/prog-1', 'PATCH')).toHaveLength(1)) // No se duplica el inicio expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1) }) it('si el sync falla, el avance no se bloquea y solo queda un aviso', async () => { fetchMock.mockImplementation((input, init) => { const url = String(input) const metodo = init?.method ?? 'GET' if (url.includes('/borradores') && metodo === 'GET') return Promise.resolve(respuesta([])) if (url.includes('/borradores') && metodo === 'POST') { return Promise.resolve(respuesta({ id: 'b-1', nombre: 'B', paso: 1, datosJson: '{}' })) } if (url.endsWith('/clientes/progreso/iniciar')) return Promise.reject(new Error('API caída')) return Promise.reject(new Error(`fetch inesperado: ${metodo} ${url}`)) }) await entrarAlWizard() await llenarPaso0() await avanzar() // Avanzó igual al paso Empleo y solo queda el aviso sutil expect(await screen.findByLabelText(/Tipo de empleo/)).toBeTruthy() await waitFor(() => { const avisos = screen.getAllByRole('status').map((el) => el.textContent ?? '') expect(avisos.some((a) => a.includes('El avance quedó guardado en este dispositivo'))).toBe(true) }) }) it('el submit final usa el endpoint progresivo cuando hay progresoId', async () => { await entrarAlWizard() await llenarPaso0() await avanzar() await screen.findByLabelText(/Tipo de empleo/) await waitFor(() => expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1)) await avanzar() // Empleo → Económicos await screen.findByLabelText(/Ingresos mensuales/) await avanzar() // Económicos → Co-acreditado await screen.findByLabelText(/co-acreditado o cónyuge/) await avanzar() // Co-acreditado → Referencias // Referencias exige al menos 1 con nombre y teléfono fireEvent.change((await screen.findAllByLabelText(/^Nombre completo/))[0]!, { target: { value: 'Ref Uno' } }) fireEvent.change(screen.getAllByLabelText(/^Teléfono/)[0]!, { target: { value: '6641112233' } }) await avanzar() // Referencias → Vivienda y crédito fireEvent.change(await screen.findByLabelText(/^Proyecto/), { target: { value: 'DIAGONAL' } }) await avanzar() // Vivienda → Documentos // Documentos → Firmas: ya NO registra el alta (se registra tras firmar) await avanzar() expect(llamadasA('/clientes/progreso/prog-1/completar', 'POST')).toHaveLength(0) // El registro se dispara con el botón del paso de firmas fireEvent.click(await screen.findByRole('button', { name: /Registrar alta/ })) await waitFor(() => expect(llamadasA('/clientes/progreso/prog-1/completar', 'POST')).toHaveLength(1)) // NO se usó el endpoint legado expect( fetchMock.mock.calls.filter( ([input, init]) => String(input).endsWith('/clientes') && (init?.method ?? '') === 'POST', ), ).toHaveLength(0) // El flujo posterior queda idéntico: alta registrada con el id del progreso expect(await screen.findByText(/Alta registrada correctamente/)).toBeTruthy() expect(screen.getByText(/Folio prog-1/)).toBeTruthy() // Pantalla de éxito con las acciones post-registro (sin redirect automático) expect(screen.getByRole('button', { name: 'Generar liga de firma' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Ir a Mi día' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Registrar otro cliente' })).toBeTruthy() }) })