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:
235
api-gateway/src/services/monday.service.test.ts
Normal file
235
api-gateway/src/services/monday.service.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MondayService } from './monday.service.js'
|
||||
|
||||
// Columnas con slugs autogenerados (como las reales de Monday); el servicio
|
||||
// debe resolverlas por TÍTULO, nunca hardcodear el id.
|
||||
const COLUMNAS_UNIDADES = [
|
||||
{ id: 'text_mm6eaaa', title: 'Desarrollo', type: 'text' },
|
||||
{ id: 'text_mm6ebbb', title: 'Modelo', type: 'text' },
|
||||
{ id: 'date_mm6eccc', title: 'Fecha entrega', type: 'date' },
|
||||
{ id: 'numeric_mm6eddd', title: 'Sup. construcción', type: 'numbers' },
|
||||
{ id: 'numeric_mm6eeee', title: 'Sup. excedente', type: 'numbers' },
|
||||
{ id: 'numeric_mm6efff', title: 'Sup. estacionamiento', type: 'numbers' },
|
||||
{ id: 'numeric_mm6eggg', title: 'Precio sin acabados', type: 'numbers' },
|
||||
{ id: 'numeric_mm6ehhh', title: 'Precio con acabados', type: 'numbers' },
|
||||
{ id: 'color_mm6eiii', title: 'Disponibilidad', type: 'status' },
|
||||
]
|
||||
|
||||
function itemUnidad(id: string, codigo: string, disponibilidad: string) {
|
||||
return {
|
||||
id,
|
||||
name: codigo,
|
||||
column_values: [
|
||||
{ id: 'text_mm6eaaa', text: 'SENDA', value: null },
|
||||
{ id: 'text_mm6ebbb', text: '1R', value: null },
|
||||
{ id: 'date_mm6eccc', text: '2027-10-08', value: null },
|
||||
{ id: 'numeric_mm6eddd', text: '69.485', value: null },
|
||||
{ id: 'numeric_mm6eeee', text: '0', value: null },
|
||||
{ id: 'numeric_mm6efff', text: '12.5', value: null },
|
||||
{ id: 'numeric_mm6eggg', text: '4415000', value: null },
|
||||
{ id: 'numeric_mm6ehhh', text: '4579000', value: null },
|
||||
{ id: 'color_mm6eiii', text: disponibilidad, value: null },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const respuestaOk = (data: unknown): Response =>
|
||||
new Response(JSON.stringify({ data }), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
interface LlamadaFetch {
|
||||
body: { query: string; variables: Record<string, unknown> }
|
||||
}
|
||||
|
||||
/** Instala un fetch mockeado que responde según el query; devuelve las llamadas registradas */
|
||||
function mockearFetch(handler: (body: LlamadaFetch['body']) => Response): LlamadaFetch[] {
|
||||
const llamadas: LlamadaFetch[] = []
|
||||
vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => {
|
||||
const body = JSON.parse(init.body) as LlamadaFetch['body']
|
||||
llamadas.push({ body })
|
||||
return handler(body)
|
||||
})
|
||||
return llamadas
|
||||
}
|
||||
|
||||
const crearServicio = (): MondayService =>
|
||||
new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 })
|
||||
|
||||
describe('MondayService', () => {
|
||||
beforeEach(() => vi.useRealTimers())
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('resuelve column IDs por TÍTULO y mapea unidades al tipo del cotizador', async () => {
|
||||
mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades).toHaveLength(1)
|
||||
expect(unidades[0]).toEqual({
|
||||
codigo: 'U-101',
|
||||
modelo: '1R',
|
||||
fechaEntrega: '2027-10-08',
|
||||
supConstruccion: 69.485,
|
||||
supExcedente: 0,
|
||||
supEstacionamiento: 12.5,
|
||||
precioSinAcabados: 4415000,
|
||||
precioConAcabados: 4579000,
|
||||
status: 'DISPONIBLE',
|
||||
desarrollo: 'SENDA',
|
||||
})
|
||||
})
|
||||
|
||||
it('excluye items placeholder sin datos (p. ej. "Task 1" default de Monday)', async () => {
|
||||
const placeholder = {
|
||||
id: '99',
|
||||
name: 'Task 1',
|
||||
column_values: COLUMNAS_UNIDADES.map((c) => ({ id: c.id, text: '', value: null })),
|
||||
}
|
||||
mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE'), placeholder] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades.map((u) => u.codigo)).toEqual(['U-101'])
|
||||
})
|
||||
|
||||
it('pagina con cursor hasta agotar las páginas', async () => {
|
||||
const llamadas = mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
if (body.variables['cursor'] === 'pagina-2') {
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('2', 'U-102', 'APARTADA')] } }],
|
||||
})
|
||||
}
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: 'pagina-2', items: [itemUnidad('1', 'U-101', 'VENDIDA')] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades.map((u) => u.codigo)).toEqual(['U-101', 'U-102'])
|
||||
expect(unidades[0]?.status).toBe('VENDIDA')
|
||||
expect(unidades[1]?.status).toBe('APARTADA')
|
||||
// 1 llamada de columnas + 2 de items_page
|
||||
expect(llamadas).toHaveLength(3)
|
||||
expect(llamadas[2]?.body.variables['cursor']).toBe('pagina-2')
|
||||
})
|
||||
|
||||
it('usa el cache de 5 minutos para no repetir llamadas', async () => {
|
||||
const llamadas = mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
|
||||
})
|
||||
})
|
||||
const svc = crearServicio()
|
||||
await svc.getUnidades()
|
||||
await svc.getUnidades()
|
||||
expect(llamadas).toHaveLength(2) // columnas + una sola página de items
|
||||
})
|
||||
|
||||
it('reintenta ante HTTP 429 con backoff y logra responder', async () => {
|
||||
let intentos = 0
|
||||
mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
intentos++
|
||||
if (intentos === 1) return new Response('rate limited', { status: 429 })
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades).toHaveLength(1)
|
||||
expect(intentos).toBe(2)
|
||||
})
|
||||
|
||||
it('reintenta ante error de complejidad (error_code COMPLEXITY_BUDGET_EXCEEDED)', async () => {
|
||||
let intentos = 0
|
||||
mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
intentos++
|
||||
if (intentos < 3) {
|
||||
return new Response(
|
||||
JSON.stringify({ error_code: 'COMPLEXITY_BUDGET_EXCEEDED', error_message: 'Complexity budget exceeded' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades).toHaveLength(1)
|
||||
expect(intentos).toBe(3)
|
||||
})
|
||||
|
||||
it('agota los reintentos y lanza error', async () => {
|
||||
mockearFetch(() => new Response('rate limited', { status: 429 }))
|
||||
await expect(crearServicio().getUnidades()).rejects.toThrow(/no respondió/)
|
||||
})
|
||||
|
||||
it('lanza en errores GraphQL no retryables sin reintentar', async () => {
|
||||
let llamadasTotal = 0
|
||||
mockearFetch(() => {
|
||||
llamadasTotal++
|
||||
return new Response(JSON.stringify({ errors: [{ message: 'Invalid board ID' }] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
await expect(crearServicio().getUnidades()).rejects.toThrow(/Invalid board ID/)
|
||||
expect(llamadasTotal).toBe(1)
|
||||
})
|
||||
|
||||
it('crearOportunidad envía column_values como JSON.stringify en variables', async () => {
|
||||
const columnasOportunidades = [
|
||||
{ id: 'text_u1', title: 'Unidad', type: 'text' },
|
||||
{ id: 'text_e1', title: 'Cliente email', type: 'text' },
|
||||
{ id: 'text_t1', title: 'Cliente teléfono', type: 'text' },
|
||||
{ id: 'color_tp', title: 'Tipo propuesta', type: 'status' },
|
||||
{ id: 'numeric_pl', title: 'Precio de lista', type: 'numbers' },
|
||||
{ id: 'numeric_dp', title: 'Descuento %', type: 'numbers' },
|
||||
{ id: 'numeric_pf', title: 'Precio final', type: 'numbers' },
|
||||
{ id: 'date_fc', title: 'Fecha cotización', type: 'date' },
|
||||
{ id: 'color_es', title: 'Estado', type: 'status' },
|
||||
]
|
||||
const llamadas = mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: columnasOportunidades }] })
|
||||
return respuestaOk({ create_item: { id: '987654321' } })
|
||||
})
|
||||
|
||||
const id = await crearServicio().crearOportunidad({
|
||||
clienteNombre: 'Juan Pérez',
|
||||
unidadCodigo: 'U-101',
|
||||
tipoPropuestaLabel: 'BANCARIO',
|
||||
precioLista: 4579000,
|
||||
descuentoPct: 0,
|
||||
precioFinal: 4579000,
|
||||
fechaCotizacion: '2026-08-21',
|
||||
estadoLabel: 'COTIZADA',
|
||||
clienteEmail: 'juan@example.com',
|
||||
})
|
||||
expect(id).toBe('987654321')
|
||||
|
||||
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
||||
expect(mutacion).toBeDefined()
|
||||
const columnValues = mutacion?.body.variables['columnValues']
|
||||
expect(typeof columnValues).toBe('string') // JSON.stringify, nunca interpolado en el query
|
||||
const parsed = JSON.parse(columnValues as string) as Record<string, unknown>
|
||||
expect(parsed['text_u1']).toBe('U-101')
|
||||
expect(parsed['text_e1']).toBe('juan@example.com')
|
||||
expect(parsed['numeric_pl']).toBe(4579000)
|
||||
expect(parsed['color_tp']).toEqual({ label: 'BANCARIO' })
|
||||
expect(parsed['color_es']).toEqual({ label: 'COTIZADA' })
|
||||
expect(parsed['date_fc']).toEqual({ date: '2026-08-21' })
|
||||
expect(parsed['text_t1']).toBeUndefined() // no se envían columnas vacías
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user