fix(alta): error 400 del servidor ya no se reporta como 'sin conexión'
- API: viviendaCredito.ubicacion ahora opcional (la app web no la exigía; el desfase causaba 400 que la app mostraba como fallo de red) - App: crearAltaCliente distingue conexion/validacion; el 400 muestra banner rojo con el detalle de los issues en vez del banner ámbar - Tests: api 160, alta-clientes 66
This commit is contained in:
@@ -16,18 +16,25 @@ export default function AltaClientePage() {
|
||||
useAltaCliente()
|
||||
const [registro, setRegistro] = useState<string>('')
|
||||
const [sinConexion, setSinConexion] = useState(false)
|
||||
const [errorValidacion, setErrorValidacion] = useState<string>('')
|
||||
|
||||
// Registra el alta en el API al llegar al paso de firmas; si el API no
|
||||
// responde, el flujo continúa solo local (los PDFs se generan igual)
|
||||
const registrarAlta = async () => {
|
||||
const creada = await crearAltaCliente(alta)
|
||||
if (creada) {
|
||||
await subirDocumentos(creada.id, alta)
|
||||
setRegistro(creada.id)
|
||||
const res = await crearAltaCliente(alta)
|
||||
if (res.ok) {
|
||||
await subirDocumentos(res.id, alta)
|
||||
setRegistro(res.id)
|
||||
setSinConexion(false)
|
||||
setErrorValidacion('')
|
||||
} else if (res.motivo === 'validacion') {
|
||||
setRegistro('')
|
||||
setSinConexion(false)
|
||||
setErrorValidacion(res.detalle ?? 'Datos incompletos o inválidos')
|
||||
} else {
|
||||
setRegistro('')
|
||||
setSinConexion(true)
|
||||
setErrorValidacion('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +141,14 @@ export default function AltaClientePage() {
|
||||
Sin conexión al servidor — el expediente se generó solo localmente
|
||||
</p>
|
||||
)}
|
||||
{errorValidacion && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mb-8 border border-red-300 bg-red-50 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
|
||||
>
|
||||
El servidor rechazó el alta: {errorValidacion}
|
||||
</p>
|
||||
)}
|
||||
<FirmasForm
|
||||
alta={alta}
|
||||
onCambiarFirma={(f) => actualizar('firma', f)}
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('services/api', () => {
|
||||
const alta = altaConDocumentos()
|
||||
const resultado = await crearAltaCliente(alta)
|
||||
|
||||
expect(resultado).toEqual({ id: 'srv-123' })
|
||||
expect(resultado).toEqual({ ok: true, id: 'srv-123' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('http://192.168.10.123:3000/api/clientes')
|
||||
@@ -71,23 +71,43 @@ describe('services/api', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('ante un fallo de red devuelve null y avisa con console.warn', async () => {
|
||||
it('ante un fallo de red devuelve motivo conexion y avisa con console.warn', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
|
||||
|
||||
const resultado = await crearAltaCliente(altaConDocumentos())
|
||||
|
||||
expect(resultado).toBeNull()
|
||||
expect(resultado).toEqual({ ok: false, motivo: 'conexion' })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ante una respuesta HTTP de error devuelve null y avisa', async () => {
|
||||
it('ante una respuesta HTTP de error devuelve motivo conexion y avisa', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('error', { status: 500 }))
|
||||
|
||||
const resultado = await crearAltaCliente(altaConDocumentos())
|
||||
|
||||
expect(resultado).toBeNull()
|
||||
expect(resultado).toEqual({ ok: false, motivo: 'conexion' })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ante un 400 devuelve motivo validacion con el detalle de los issues (no es un fallo de red)', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: 'Validación fallida',
|
||||
issues: [{ code: 'too_small', path: ['viviendaCredito', 'ubicacion'], message: 'Required' }],
|
||||
}),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
|
||||
const resultado = await crearAltaCliente(altaConDocumentos())
|
||||
|
||||
expect(resultado).toEqual({
|
||||
ok: false,
|
||||
motivo: 'validacion',
|
||||
detalle: 'viviendaCredito.ubicacion: Required',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('subirDocumentos', () => {
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface AltaClienteCreada {
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Resultado del registro en el API: distingue fallo de red de rechazo por validación. */
|
||||
export type ResultadoAlta =
|
||||
| { ok: true; id: string }
|
||||
| { ok: false; motivo: 'conexion' | 'validacion'; detalle?: string }
|
||||
|
||||
interface DocumentoMeta {
|
||||
tipo: string
|
||||
nombre: string
|
||||
@@ -28,12 +33,14 @@ const serializarAlta = (payload: AltaCliente) => ({
|
||||
|
||||
/**
|
||||
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
||||
* SQLite y replica en Monday. Si el API no responde devuelve null (con
|
||||
* console.warn) y el flujo continúa solo local: los PDFs se generan igual.
|
||||
* SQLite y replica en Monday. Si el API no responde devuelve motivo 'conexion'
|
||||
* y el flujo continúa solo local: los PDFs se generan igual. Si el API rechaza
|
||||
* por validación (400) devuelve motivo 'validacion' con el detalle.
|
||||
*/
|
||||
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada | null> {
|
||||
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
||||
let res: Response
|
||||
try {
|
||||
const res = await fetch(
|
||||
res = await fetch(
|
||||
`${API_URL}/clientes`,
|
||||
conAuth({
|
||||
method: 'POST',
|
||||
@@ -41,14 +48,26 @@ export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClient
|
||||
body: JSON.stringify(serializarAlta(payload)),
|
||||
}),
|
||||
)
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = (await res.json()) as { id: string }
|
||||
return { id: data.id }
|
||||
} catch (err) {
|
||||
console.warn('[api] crearAltaCliente: sin conexión al servidor, el expediente se genera solo localmente', err)
|
||||
return null
|
||||
return { ok: false, motivo: 'conexion' }
|
||||
}
|
||||
manejar401(res)
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { id: string }
|
||||
return { ok: true, id: data.id }
|
||||
}
|
||||
if (res.status === 400) {
|
||||
// El API rechazó por validación: NO es un problema de conexión.
|
||||
const cuerpo = (await res.json().catch(() => null)) as {
|
||||
issues?: { path: (string | number)[]; message: string }[]
|
||||
} | null
|
||||
const detalle = cuerpo?.issues?.map((i) => `${i.path.join('.')}: ${i.message}`).join(' · ')
|
||||
console.warn('[api] crearAltaCliente: validación rechazada', detalle)
|
||||
return { ok: false, motivo: 'validacion', detalle: detalle ?? 'Datos incompletos o inválidos' }
|
||||
}
|
||||
console.warn(`[api] crearAltaCliente: HTTP ${res.status}`)
|
||||
return { ok: false, motivo: 'conexion' }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user