feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API
- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor, column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes, webhook con handshake challenge, Prisma SQLite dev, 55 tests - cotizador: inventario desde /api/unidades con fallback a seed - alta-clientes: POST /api/clientes + upload multipart de documentos - cobranza: persistencia via API con respaldo offline en localStorage - .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
133
apps/alta-clientes/src/services/api.test.ts
Normal file
133
apps/alta-clientes/src/services/api.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { AltaCliente } from '../types'
|
||||
import { fixtureAlta } from '../lib/fixture'
|
||||
import { crearAltaCliente, subirDocumentos } from './api'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
const docPrueba = (): AltaCliente['documentos'] => [
|
||||
{
|
||||
slot: 'INE',
|
||||
nombre: 'ine.pdf',
|
||||
tamano: 4,
|
||||
tipoMime: 'application/pdf',
|
||||
archivo: new File(['pdf!'], 'ine.pdf', { type: 'application/pdf' }),
|
||||
},
|
||||
{
|
||||
slot: 'CURP',
|
||||
nombre: 'curp.png',
|
||||
tamano: 4,
|
||||
tipoMime: 'image/png',
|
||||
archivo: new File(['png!'], 'curp.png', { type: 'image/png' }),
|
||||
},
|
||||
]
|
||||
|
||||
const altaConDocumentos = (): AltaCliente => ({ ...fixtureAlta, documentos: docPrueba() })
|
||||
|
||||
describe('services/api', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('crearAltaCliente', () => {
|
||||
it('hace POST JSON a /clientes y devuelve el id del servidor', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'srv-123' }), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
const alta = altaConDocumentos()
|
||||
const resultado = await crearAltaCliente(alta)
|
||||
|
||||
expect(resultado).toEqual({ 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')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
|
||||
// Los File no viajan en el JSON: solo metadatos; la firma va como string
|
||||
const cuerpo = JSON.parse(String(init.body)) as Record<string, unknown>
|
||||
const documentos = cuerpo['documentos'] as Record<string, unknown>[]
|
||||
expect(documentos).toEqual([
|
||||
{ tipo: 'INE', nombre: 'ine.pdf', tamano: 4, mime: 'application/pdf' },
|
||||
{ tipo: 'CURP', nombre: 'curp.png', tamano: 4, mime: 'image/png' },
|
||||
])
|
||||
expect(JSON.stringify(cuerpo)).not.toContain('archivo')
|
||||
expect((cuerpo['firma'] as Record<string, unknown>)['firmaDataUrl']).toBe(
|
||||
fixtureAlta.firma.firmaDataUrl,
|
||||
)
|
||||
})
|
||||
|
||||
it('ante un fallo de red devuelve null y avisa con console.warn', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
|
||||
|
||||
const resultado = await crearAltaCliente(altaConDocumentos())
|
||||
|
||||
expect(resultado).toBeNull()
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ante una respuesta HTTP de error devuelve null y avisa', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('error', { status: 500 }))
|
||||
|
||||
const resultado = await crearAltaCliente(altaConDocumentos())
|
||||
|
||||
expect(resultado).toBeNull()
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subirDocumentos', () => {
|
||||
it('arma el multipart correcto: una petición por documento con tipo y archivos', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('[]', { status: 201 }))
|
||||
|
||||
await subirDocumentos('srv-123', altaConDocumentos())
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
const [url1, init1] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url1).toBe('http://192.168.10.123:3000/api/clientes/srv-123/documentos')
|
||||
expect(init1.method).toBe('POST')
|
||||
// Sin Content-Type manual: el navegador pone el boundary del multipart
|
||||
expect(init1.headers).toBeUndefined()
|
||||
|
||||
const form1 = init1.body as FormData
|
||||
expect(form1).toBeInstanceOf(FormData)
|
||||
expect(form1.get('tipo')).toBe('INE')
|
||||
const archivo1 = form1.get('archivos')
|
||||
expect(archivo1).toBeInstanceOf(File)
|
||||
expect((archivo1 as File).name).toBe('ine.pdf')
|
||||
expect((archivo1 as File).type).toBe('application/pdf')
|
||||
|
||||
const [, init2] = fetchMock.mock.calls[1] as [string, RequestInit]
|
||||
const form2 = init2.body as FormData
|
||||
expect(form2.get('tipo')).toBe('CURP')
|
||||
expect((form2.get('archivos') as File).name).toBe('curp.png')
|
||||
})
|
||||
|
||||
it('no hace ninguna petición si no hay documentos', async () => {
|
||||
await subirDocumentos('srv-123', fixtureAlta)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ante un fallo de red avisa y continúa sin lanzar', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
|
||||
|
||||
await expect(subirDocumentos('srv-123', altaConDocumentos())).resolves.toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,71 @@
|
||||
import type { AltaCliente } from '../types'
|
||||
import { uuid } from '../lib/uuid'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||
// TODO produccion: '/api' same-origin detras de NGINX
|
||||
|
||||
export interface AltaClienteCreada {
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Registra el alta de cliente en el backend */
|
||||
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada> {
|
||||
// TODO: POST /api/clientes
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
console.log('[api] crearAltaCliente (stub):', payload.datosGenerales.nombreCompleto)
|
||||
return { id: uuid() }
|
||||
interface DocumentoMeta {
|
||||
tipo: string
|
||||
nombre: string
|
||||
tamano: number
|
||||
mime: string
|
||||
}
|
||||
|
||||
/** Sube los documentos del expediente del cliente */
|
||||
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
|
||||
// TODO: POST multipart a /api/clientes/:id/documentos
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
console.log(`[api] subirDocumentos (stub) cliente=${clienteId}:`, payload.documentos.length, 'archivos')
|
||||
/**
|
||||
* Serializa el alta para el POST JSON: los File no viajan en el cuerpo,
|
||||
* solo sus metadatos; la firma ya es un string base64 (data URL).
|
||||
*/
|
||||
const serializarAlta = (payload: AltaCliente) => ({
|
||||
...payload,
|
||||
documentos: payload.documentos.map(
|
||||
(d): DocumentoMeta => ({ tipo: d.slot, nombre: d.nombre, tamano: d.tamano, mime: d.tipoMime }),
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/clientes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serializarAlta(payload)),
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sube los documentos reales del expediente (POST multipart
|
||||
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
|
||||
* `tipo` por petición, así que se envía una petición por documento con su
|
||||
* slot como tipo. Los fallos se reportan con console.warn sin interrumpir
|
||||
* el flujo local.
|
||||
*/
|
||||
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
|
||||
for (const doc of payload.documentos) {
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('tipo', doc.slot)
|
||||
form.append('archivos', doc.archivo, doc.nombre)
|
||||
const res = await fetch(`${API_URL}/clientes/${clienteId}/documentos`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
} catch (err) {
|
||||
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { AltaCliente } from '../types'
|
||||
|
||||
/** Envía el alta al tablero de Monday (Contactos + Apartado) */
|
||||
/**
|
||||
* OBSOLETO: la réplica en Monday (tableros Contactos y Apartado) ya la hace
|
||||
* el API Gateway en POST /api/clientes (ver services/api.ts y
|
||||
* api-gateway/src/controllers/clientes.controller.ts). Se conserva el
|
||||
* archivo solo como referencia; no llamar desde el flujo del wizard.
|
||||
*/
|
||||
export async function enviarAMonday(payload: AltaCliente): Promise<void> {
|
||||
// TODO: integración Monday — crear item en tablero "Contactos" y "Apartado"
|
||||
// con nombre, teléfono, correo, proyecto y ubicación del cliente
|
||||
console.log('[monday] enviarAMonday (stub):', {
|
||||
console.warn('[monday] enviarAMonday está obsoleto: el backend ya replica en Monday', {
|
||||
nombre: payload.datosGenerales.nombreCompleto,
|
||||
proyecto: payload.viviendaCredito.proyecto,
|
||||
ubicacion: payload.viviendaCredito.ubicacion,
|
||||
|
||||
Reference in New Issue
Block a user