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 } } /** 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', mesesParaFirma: null, etapa: null, }) }) it('mapea la columna opcional "Etapa" (pipeline de venta) cuando existe', async () => { const columnasConEtapa = [...COLUMNAS_UNIDADES, { id: 'color_mm6ejjj', title: 'Etapa', type: 'status' }] const item = itemUnidad('1', 'U-101', 'VENDIDO') item.column_values.push({ id: 'color_mm6ejjj', text: 'Notaría', value: null }) mockearFetch((body) => { if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: columnasConEtapa }] }) return respuestaOk({ boards: [{ items_page: { cursor: null, items: [item] } }] }) }) const unidades = await crearServicio().getUnidades() expect(unidades[0]?.etapa).toBe('Notaría') }) 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 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 }) })