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:
2026-08-27 18:51:51 +00:00
parent 5d4293888b
commit 04de7d703f
5 changed files with 95 additions and 22 deletions

View File

@@ -73,3 +73,22 @@ describe('actividadEconomicaLabel', () => {
expect(actividadEconomicaLabel(undefined)).toBeUndefined() expect(actividadEconomicaLabel(undefined)).toBeUndefined()
}) })
}) })
describe('crearClienteSchema', () => {
const base = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
viviendaCredito: { proyecto: 'SENDA' },
}
it('acepta un alta sin ubicación (opcional en el formato web)', async () => {
const { crearClienteSchema } = await import('./clientes.controller.js')
expect(() => crearClienteSchema.parse(base)).not.toThrow()
})
it('sigue exigiendo nombre, curp, rfc y proyecto', async () => {
const { crearClienteSchema } = await import('./clientes.controller.js')
expect(() =>
crearClienteSchema.parse({ datosGenerales: { nombreCompleto: '', curp: '', rfc: '' }, viviendaCredito: {} }),
).toThrow()
})
})

View File

@@ -11,7 +11,7 @@ import { NotFoundError, BadRequestError } from '../utils/errors.js'
// ---------- Alta de clientes ---------- // ---------- Alta de clientes ----------
const crearClienteSchema = z export const crearClienteSchema = z
.object({ .object({
datosGenerales: z datosGenerales: z
.object({ .object({
@@ -32,7 +32,7 @@ const crearClienteSchema = z
viviendaCredito: z viviendaCredito: z
.object({ .object({
proyecto: z.string().min(1), proyecto: z.string().min(1),
ubicacion: z.string().min(1), ubicacion: z.string().optional(),
valorVenta: z.union([z.string(), z.number()]).optional(), valorVenta: z.union([z.string(), z.number()]).optional(),
enganche: z.union([z.string(), z.number()]).optional(), enganche: z.union([z.string(), z.number()]).optional(),
}) })
@@ -169,7 +169,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
email: dg.email ?? null, email: dg.email ?? null,
celular: dg.celular ?? null, celular: dg.celular ?? null,
proyecto: vc.proyecto, proyecto: vc.proyecto,
unidadCodigo: vc.ubicacion, // la "ubicación" del formato es el código de unidad (U-101, etc.) unidadCodigo: vc.ubicacion ?? '', // la "ubicación" del formato es el código de unidad (U-101, etc.)
payloadJson: JSON.stringify(body), payloadJson: JSON.stringify(body),
firmaPng: body.firma?.firmaDataUrl ?? null, firmaPng: body.firma?.firmaDataUrl ?? null,
}, },
@@ -199,7 +199,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
const enganche = aNumero(vc.enganche) const enganche = aNumero(vc.enganche)
mondayApartadoId = await mondayService.crearApartado({ mondayApartadoId = await mondayService.crearApartado({
clienteNombre: dg.nombreCompleto, clienteNombre: dg.nombreCompleto,
unidadCodigo: vc.ubicacion, unidadCodigo: vc.ubicacion ?? '',
...(enganche > 0 ? { montoApartado: enganche } : {}), ...(enganche > 0 ? { montoApartado: enganche } : {}),
fechaApartado: hoyMas(0), fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15), fechaLimite: hoyMas(15),

View File

@@ -16,18 +16,25 @@ export default function AltaClientePage() {
useAltaCliente() useAltaCliente()
const [registro, setRegistro] = useState<string>('') const [registro, setRegistro] = useState<string>('')
const [sinConexion, setSinConexion] = useState(false) 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 // 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) // responde, el flujo continúa solo local (los PDFs se generan igual)
const registrarAlta = async () => { const registrarAlta = async () => {
const creada = await crearAltaCliente(alta) const res = await crearAltaCliente(alta)
if (creada) { if (res.ok) {
await subirDocumentos(creada.id, alta) await subirDocumentos(res.id, alta)
setRegistro(creada.id) setRegistro(res.id)
setSinConexion(false) setSinConexion(false)
setErrorValidacion('')
} else if (res.motivo === 'validacion') {
setRegistro('')
setSinConexion(false)
setErrorValidacion(res.detalle ?? 'Datos incompletos o inválidos')
} else { } else {
setRegistro('') setRegistro('')
setSinConexion(true) setSinConexion(true)
setErrorValidacion('')
} }
} }
@@ -134,6 +141,14 @@ export default function AltaClientePage() {
Sin conexión al servidor — el expediente se generó solo localmente Sin conexión al servidor — el expediente se generó solo localmente
</p> </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 <FirmasForm
alta={alta} alta={alta}
onCambiarFirma={(f) => actualizar('firma', f)} onCambiarFirma={(f) => actualizar('firma', f)}

View File

@@ -51,7 +51,7 @@ describe('services/api', () => {
const alta = altaConDocumentos() const alta = altaConDocumentos()
const resultado = await crearAltaCliente(alta) const resultado = await crearAltaCliente(alta)
expect(resultado).toEqual({ id: 'srv-123' }) expect(resultado).toEqual({ ok: true, id: 'srv-123' })
expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://192.168.10.123:3000/api/clientes') 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')) fetchMock.mockRejectedValue(new TypeError('fetch failed'))
const resultado = await crearAltaCliente(altaConDocumentos()) const resultado = await crearAltaCliente(altaConDocumentos())
expect(resultado).toBeNull() expect(resultado).toEqual({ ok: false, motivo: 'conexion' })
expect(warnSpy).toHaveBeenCalled() 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 })) fetchMock.mockResolvedValue(new Response('error', { status: 500 }))
const resultado = await crearAltaCliente(altaConDocumentos()) const resultado = await crearAltaCliente(altaConDocumentos())
expect(resultado).toBeNull() expect(resultado).toEqual({ ok: false, motivo: 'conexion' })
expect(warnSpy).toHaveBeenCalled() 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', () => { describe('subirDocumentos', () => {

View File

@@ -8,6 +8,11 @@ export interface AltaClienteCreada {
id: string 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 { interface DocumentoMeta {
tipo: string tipo: string
nombre: string nombre: string
@@ -28,12 +33,14 @@ const serializarAlta = (payload: AltaCliente) => ({
/** /**
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en * 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 * SQLite y replica en Monday. Si el API no responde devuelve motivo 'conexion'
* console.warn) y el flujo continúa solo local: los PDFs se generan igual. * 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 { try {
const res = await fetch( res = await fetch(
`${API_URL}/clientes`, `${API_URL}/clientes`,
conAuth({ conAuth({
method: 'POST', method: 'POST',
@@ -41,14 +48,26 @@ export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClient
body: JSON.stringify(serializarAlta(payload)), 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) { } catch (err) {
console.warn('[api] crearAltaCliente: sin conexión al servidor, el expediente se genera solo localmente', 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' }
} }
/** /**