- Matriz de roles (vendedor, administracion_venta, director_ventas, escrituracion, direccion_general, admin) con modulos visibles por rol; auth/me y login incluyen 'modulos'; PATCH /usuarios/:id/rol (admin). - Landing: Cobranza -> Clientes, Chat IA -> Chat Hermes, nuevo modulo Contactos (vista interna leyendo el tablero Contactos), grid filtrado por rol, rol visible en Configuracion de cuenta. - Dashboards: tab 'Tablero comercial' y tabs filtrados por rol. - Boton '<- Menu' en el header de todas las apps. - Mi dia: cada lead muestra el ultimo cambio (que, quien, cuando) desde el activity log de Monday (normalizacion de created_at 100ns/us/ms). - Descarte: el motivo se crea en el grupo de su categoria en el tablero Motivos No Compra.
1041 lines
44 KiB
TypeScript
1041 lines
44 KiB
TypeScript
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',
|
|
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<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
|
|
})
|
|
|
|
describe('getLeads / mapearLead (perfilamiento)', () => {
|
|
const COLUMNAS_LEADS_BASE = [
|
|
{ id: 'people_v', title: 'Vendedor', type: 'people' },
|
|
{ id: 'color_e', title: 'Estado', type: 'status' },
|
|
]
|
|
const COLUMNAS_PERFIL = [
|
|
{ id: 'color_des', title: 'Desarrollo', type: 'status' },
|
|
{ id: 'color_mod', title: 'Modelo de Interés', type: 'status' },
|
|
{ id: 'text_mc', title: 'Medio de Contacto', type: 'text' },
|
|
{ id: 'numeric_edad', title: 'Edad', type: 'numbers' },
|
|
{ id: 'color_ec', title: 'Estado Civil', type: 'status' },
|
|
{ id: 'color_te', title: 'Tipo de Empleo', type: 'status' },
|
|
{ id: 'color_cr', title: 'Crédito', type: 'status' },
|
|
{ id: 'color_teco', title: 'Tipo de Economía', type: 'status' },
|
|
{ id: 'color_cal', title: 'Califica', type: 'status' },
|
|
{ id: 'text_rs', title: 'Rango Salarial', type: 'text' },
|
|
{ id: 'text_lqp', title: 'Lo Quiere Para', type: 'text' },
|
|
]
|
|
const itemLead = (id: string, extras: { id: string; text: string }[] = []) => ({
|
|
id,
|
|
name: `Lead ${id}`,
|
|
created_at: '2026-08-20T10:00:00Z',
|
|
updated_at: '2026-08-21T10:00:00Z',
|
|
column_values: [
|
|
{ id: 'people_v', text: '', value: '{"personsAndTeams":[{"id":106736727,"kind":"person"}]}' },
|
|
{ id: 'color_e', text: 'NUEVO', value: null },
|
|
...extras.map((e) => ({ ...e, value: null })),
|
|
],
|
|
})
|
|
|
|
it('mapea las columnas de perfilamiento cuando existen en el board', async () => {
|
|
mockearFetch((body) => {
|
|
if (body.query.includes('columns')) {
|
|
return respuestaOk({ boards: [{ columns: [...COLUMNAS_LEADS_BASE, ...COLUMNAS_PERFIL] }] })
|
|
}
|
|
return respuestaOk({
|
|
boards: [
|
|
{
|
|
items_page: {
|
|
cursor: null,
|
|
items: [
|
|
itemLead('1', [
|
|
{ id: 'color_des', text: 'ATELIER' },
|
|
{ id: 'color_mod', text: '1R' },
|
|
{ id: 'text_mc', text: 'WhatsApp' },
|
|
{ id: 'numeric_edad', text: '32' },
|
|
{ id: 'color_ec', text: 'Soltero' },
|
|
{ id: 'color_te', text: 'Empleado' },
|
|
{ id: 'color_cr', text: 'Bancario' },
|
|
{ id: 'color_teco', text: 'Asalariado' },
|
|
{ id: 'color_cal', text: 'Si' },
|
|
{ id: 'text_rs', text: '20-30 mil' },
|
|
{ id: 'text_lqp', text: 'Habitarla' },
|
|
]),
|
|
],
|
|
},
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
const leads = await crearServicio().getLeads()
|
|
expect(leads[0]).toMatchObject({
|
|
desarrollo: 'ATELIER',
|
|
modeloInteres: '1R',
|
|
medioContacto: 'WhatsApp',
|
|
edad: 32,
|
|
estadoCivil: 'Soltero',
|
|
tipoEmpleo: 'Empleado',
|
|
credito: 'Bancario',
|
|
tipoEconomia: 'Asalariado',
|
|
califica: 'Si',
|
|
rangoSalarial: '20-30 mil',
|
|
loQuierePara: 'Habitarla',
|
|
})
|
|
})
|
|
|
|
it('sin las columnas de perfilamiento devuelve \'\' / null y no truena', async () => {
|
|
mockearFetch((body) => {
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_BASE }] })
|
|
return respuestaOk({ boards: [{ items_page: { cursor: null, items: [itemLead('1')] } }] })
|
|
})
|
|
|
|
const leads = await crearServicio().getLeads()
|
|
expect(leads[0]).toMatchObject({
|
|
desarrollo: '',
|
|
modeloInteres: '',
|
|
medioContacto: '',
|
|
edad: null,
|
|
estadoCivil: '',
|
|
tipoEmpleo: '',
|
|
credito: '',
|
|
tipoEconomia: '',
|
|
califica: '',
|
|
rangoSalarial: '',
|
|
loQuierePara: '',
|
|
})
|
|
})
|
|
|
|
it('edad no numérica o vacía mapea a null', async () => {
|
|
mockearFetch((body) => {
|
|
if (body.query.includes('columns')) {
|
|
return respuestaOk({ boards: [{ columns: [...COLUMNAS_LEADS_BASE, ...COLUMNAS_PERFIL] }] })
|
|
}
|
|
return respuestaOk({
|
|
boards: [
|
|
{
|
|
items_page: {
|
|
cursor: null,
|
|
items: [
|
|
itemLead('1', [{ id: 'numeric_edad', text: 'N/A' }]),
|
|
itemLead('2', [{ id: 'numeric_edad', text: '' }]),
|
|
],
|
|
},
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
const leads = await crearServicio().getLeads()
|
|
expect(leads.map((l) => l.edad)).toEqual([null, null])
|
|
})
|
|
|
|
it('estado con value null (default del tablero, nunca escrito) se lee como vacío', async () => {
|
|
mockearFetch((body) => {
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_BASE }] })
|
|
return respuestaOk({
|
|
boards: [
|
|
{
|
|
items_page: {
|
|
cursor: null,
|
|
items: [
|
|
// text trae el label default del board, pero value es null: no hay estado real
|
|
{ ...itemLead('1'), column_values: [{ id: 'people_v', text: '', value: null }, { id: 'color_e', text: 'Descartado', value: null }] },
|
|
// estado real escrito: value presente → se usa text
|
|
{ ...itemLead('2'), column_values: [{ id: 'people_v', text: '', value: null }, { id: 'color_e', text: 'Nuevo', value: '{"index":1}' }] },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
const leads = await crearServicio().getLeads()
|
|
expect(leads.map((l) => l.estado)).toEqual(['', 'Nuevo'])
|
|
})
|
|
})
|
|
|
|
describe('escrituras status tolerantes a case (rename de labels en la UI)', () => {
|
|
it('cambiarValorColumna resuelve el label real sin importar mayúsculas', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('settings_str')) {
|
|
return respuestaOk({
|
|
boards: [{ columns: [{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Nuevo","5":"Descartado"}}' }] }],
|
|
})
|
|
}
|
|
if (body.query.includes('columns')) {
|
|
return respuestaOk({ boards: [{ columns: [{ id: 'color_e', title: 'Estado', type: 'status' }] }] })
|
|
}
|
|
return respuestaOk({ change_column_value: { id: 'it-1' } })
|
|
})
|
|
|
|
await crearServicio().cambiarValorColumna('b1', 'it-1', 'Estado', 'NUEVO')
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
|
|
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ label: 'Nuevo' })
|
|
})
|
|
|
|
it('cambiarValorColumna resuelve el label de un DROPDOWN case-insensitive (los dropdown no auto-crean labels)', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('settings_str')) {
|
|
return respuestaOk({
|
|
boards: [{ columns: [{ id: 'dropdown_m', title: 'Motivo No Compra', settings_str: '{"labels":[{"name":"Precio"},{"name":"Timing"}]}' }] }],
|
|
})
|
|
}
|
|
if (body.query.includes('columns')) {
|
|
return respuestaOk({ boards: [{ columns: [{ id: 'dropdown_m', title: 'Motivo No Compra', type: 'dropdown' }] }] })
|
|
}
|
|
return respuestaOk({ change_column_value: { id: 'it-1' } })
|
|
})
|
|
|
|
await crearServicio().cambiarValorColumna('b1', 'it-1', 'Motivo No Compra', 'PRECIO')
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
|
|
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ labels: ['Precio'] })
|
|
})
|
|
})
|
|
|
|
// Columnas del board Oportunidades con los tipos reales (verificadas en Monday)
|
|
const COLUMNAS_OPORTUNIDADES = [
|
|
{ id: 'color_estado', title: 'Estado', type: 'status' },
|
|
{ id: 'date_fecha', title: 'Fecha', type: 'date' },
|
|
{ id: 'phone_ct', title: 'Cliente teléfono', type: 'phone' },
|
|
{ id: 'email_ce', title: 'Cliente email', type: 'email' },
|
|
{ id: 'color_des', title: 'Desarrollo', type: 'status' },
|
|
{ id: 'color_mod', title: 'Modelo de Interés', type: 'status' },
|
|
{ id: 'dropdown_or', title: 'Origen del Lead', type: 'dropdown' },
|
|
{ id: 'text_mc', title: 'Medio de Contacto', type: 'text' },
|
|
{ id: 'numeric_edad', title: 'Edad', type: 'numbers' },
|
|
{ id: 'dropdown_ec', title: 'Estado Civil', type: 'dropdown' },
|
|
{ id: 'color_te', title: 'Tipo de Empleo', type: 'status' },
|
|
{ id: 'text_ocu', title: 'Ocupación', type: 'text' },
|
|
{ id: 'text_rs', title: 'Rango Salarial', type: 'text' },
|
|
{ id: 'color_cr', title: 'Crédito', type: 'status' },
|
|
{ id: 'color_teco', title: 'Tipo de Econimia', type: 'status' }, // SIC: typo real
|
|
{ id: 'dropdown_cal', title: 'Lead Califica', type: 'dropdown' },
|
|
{ id: 'dropdown_lqp', title: 'Proposito de Compra', type: 'dropdown' },
|
|
{ id: 'long_com', title: 'COMENTARIOS', type: 'long_text' },
|
|
{ id: 'people_ase', title: 'Asesor', type: 'people' },
|
|
{ id: 'text_u1', title: 'Unidad', type: 'text' },
|
|
{ id: 'numeric_pf', title: 'Precio final', type: 'numbers' },
|
|
]
|
|
|
|
const itemOportunidad = (id: string, extras: { id: string; text: string; value?: string | null }[] = []) => ({
|
|
id,
|
|
name: `Cliente ${id}`,
|
|
created_at: '2026-08-20T10:00:00Z',
|
|
updated_at: '2026-08-21T10:00:00Z',
|
|
column_values: [
|
|
{ id: 'color_estado', text: 'Afluencia', value: '{"index":1}' },
|
|
{ id: 'phone_ct', text: '4771234567', value: null },
|
|
{ id: 'people_ase', text: '', value: '{"personsAndTeams":[{"id":106736727,"kind":"person"}]}' },
|
|
...extras.map((e) => ({ id: e.id, text: e.text, value: e.value ?? null })),
|
|
],
|
|
})
|
|
|
|
const mockTableroOportunidades = (items: unknown[]) =>
|
|
mockearFetch((body) => {
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
|
|
return respuestaOk({ boards: [{ items_page: { cursor: null, items } }] })
|
|
})
|
|
|
|
describe('getOportunidades / mapearOportunidad (afluencia en Oportunidades)', () => {
|
|
it('mapea asesor (people), contacto, origen y perfilamiento cuando existen en el board', async () => {
|
|
mockTableroOportunidades([
|
|
itemOportunidad('1', [
|
|
{ id: 'email_ce', text: 'a@b.com' },
|
|
{ id: 'dropdown_or', text: 'WhatsApp' },
|
|
{ id: 'color_des', text: 'ATELIER' },
|
|
{ id: 'color_mod', text: 'HIMALAYA' },
|
|
{ id: 'text_mc', text: 'Cabina' },
|
|
{ id: 'numeric_edad', text: '32' },
|
|
{ id: 'dropdown_ec', text: 'Soltero(a)' },
|
|
{ id: 'color_te', text: 'Asalariado' },
|
|
{ id: 'color_cr', text: 'Infonavit' },
|
|
{ id: 'color_teco', text: 'Economía mexicana' },
|
|
{ id: 'dropdown_cal', text: 'Sí' },
|
|
{ id: 'text_rs', text: '20-30 mil' },
|
|
{ id: 'dropdown_lqp', text: 'Vivir' },
|
|
{ id: 'text_u1', text: 'U-101' },
|
|
{ id: 'numeric_pf', text: '2500000' },
|
|
]),
|
|
])
|
|
|
|
const ops = await crearServicio().getOportunidades()
|
|
expect(ops[0]).toMatchObject({
|
|
id: '1',
|
|
etapa: 'Afluencia',
|
|
asesorIds: [106736727],
|
|
telefono: '4771234567',
|
|
email: 'a@b.com',
|
|
origen: 'WhatsApp',
|
|
desarrollo: 'ATELIER',
|
|
modeloInteres: 'HIMALAYA',
|
|
medioContacto: 'Cabina',
|
|
edad: 32,
|
|
estadoCivil: 'Soltero(a)',
|
|
tipoEmpleo: 'Asalariado',
|
|
credito: 'Infonavit',
|
|
tipoEconomia: 'Economía mexicana',
|
|
califica: 'Sí',
|
|
rangoSalarial: '20-30 mil',
|
|
loQuierePara: 'Vivir',
|
|
unidadCodigo: 'U-101',
|
|
precioFinal: 2500000,
|
|
updatedAt: '2026-08-21T10:00:00Z',
|
|
})
|
|
})
|
|
|
|
it('etapa con value null (default del tablero, nunca escrito) se lee como vacía', async () => {
|
|
mockTableroOportunidades([
|
|
// text trae el label default del board, pero value es null: no hay etapa real
|
|
itemOportunidad('1', [{ id: 'color_estado', text: 'Perdido', value: null }]),
|
|
])
|
|
// itemOportunidad ya pone color_estado con value; aquí el extra lo pisa en el mapa por id
|
|
const ops = await crearServicio().getOportunidades()
|
|
expect(ops[0]?.etapa).toBe('')
|
|
})
|
|
|
|
it('sin la columna Asesor devuelve asesorIds vacío (lectura tolerante)', async () => {
|
|
mockearFetch((body) => {
|
|
if (body.query.includes('columns')) {
|
|
return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES.filter((c) => c.title !== 'Asesor') }] })
|
|
}
|
|
return respuestaOk({ boards: [{ items_page: { cursor: null, items: [itemOportunidad('1')] } }] })
|
|
})
|
|
const ops = await crearServicio().getOportunidades()
|
|
expect(ops[0]?.asesorIds).toEqual([])
|
|
})
|
|
|
|
it('buscarOportunidadPorTelefono: dedup por últimos 10 dígitos', async () => {
|
|
mockTableroOportunidades([itemOportunidad('1'), itemOportunidad('2', [{ id: 'phone_ct', text: '' }])])
|
|
const svc = crearServicio()
|
|
await expect(svc.buscarOportunidadPorTelefono('+52 477 123 4567')).resolves.toMatchObject({ id: '1' })
|
|
await expect(svc.buscarOportunidadPorTelefono('4770000000')).resolves.toBeNull()
|
|
await expect(svc.buscarOportunidadPorTelefono('123')).resolves.toBeNull() // menos de 10 dígitos
|
|
})
|
|
})
|
|
|
|
describe('asignarVendedorALead (columna people Vendedor del tablero Leads)', () => {
|
|
it('escribe personsAndTeams con el user id en el board Leads (444 en tests)', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('columns')) {
|
|
return respuestaOk({ boards: [{ columns: [{ id: 'people_v', title: 'Vendedor', type: 'people' }] }] })
|
|
}
|
|
return respuestaOk({ change_column_value: { id: 'lead-1' } })
|
|
})
|
|
|
|
await crearServicio().asignarVendedorALead('lead-1', 12345)
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
|
|
expect(mutacion?.body.variables['boardId']).toBe('444')
|
|
expect(mutacion?.body.variables['columnId']).toBe('people_v')
|
|
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({
|
|
personsAndTeams: [{ id: 12345, kind: 'person' }],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('crearLead (captura ligera en el tablero Leads)', () => {
|
|
const COLUMNAS_LEADS_LIGERO = [
|
|
{ id: 'color_estado', title: 'Estado', type: 'status' },
|
|
{ id: 'date_fecha', title: 'Fecha', type: 'date' },
|
|
{ id: 'phone_tel', title: 'Teléfono', type: 'phone' },
|
|
{ id: 'email_em', title: 'Email', type: 'email' },
|
|
{ id: 'color_or', title: 'Origen', type: 'status' },
|
|
{ id: 'color_des', title: 'Desarrollo', type: 'status' },
|
|
{ id: 'text_notas', title: 'Notas', type: 'text' },
|
|
]
|
|
|
|
it('crea el item ligero con Estado NUEVO y Fecha de hoy (sin perfil ni oportunidad)', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('settings_str')) {
|
|
return respuestaOk({
|
|
boards: [
|
|
{
|
|
columns: COLUMNAS_LEADS_LIGERO.filter((c) => c.type === 'status').map((c) => ({
|
|
id: c.id,
|
|
title: c.title,
|
|
settings_str: '{"labels":{"1":"Nuevo"}}',
|
|
})),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] })
|
|
return respuestaOk({ create_item: { id: 'lead-nuevo-1' } })
|
|
})
|
|
|
|
const id = await crearServicio().crearLead({
|
|
nombre: 'Lead Prueba',
|
|
telefono: '4771234567',
|
|
email: 'lead@correo.com',
|
|
origenLabel: 'Facebook',
|
|
desarrolloLabel: 'SENDA',
|
|
notas: 'Vio el volante',
|
|
})
|
|
expect(id).toBe('lead-nuevo-1')
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
|
expect(mutacion?.body.variables['boardId']).toBe('444') // tablero Leads
|
|
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
|
expect(parsed['color_estado']).toEqual({ label: 'Nuevo' }) // 'NUEVO' resuelto al label real
|
|
expect(parsed['date_fecha']).toMatchObject({ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) })
|
|
expect(parsed['phone_tel']).toEqual({ phone: '4771234567', countryShortName: 'MX' })
|
|
expect(parsed['email_em']).toEqual({ email: 'lead@correo.com', text: 'lead@correo.com' })
|
|
expect(parsed['color_or']).toEqual({ label: 'Facebook' })
|
|
expect(parsed['color_des']).toEqual({ label: 'SENDA' })
|
|
expect(parsed['text_notas']).toBe('Vio el volante')
|
|
})
|
|
|
|
it('solo envía las columnas capturadas (las vacías se omiten)', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('settings_str')) {
|
|
return respuestaOk({
|
|
boards: [{ columns: [{ id: 'color_estado', title: 'Estado', settings_str: '{"labels":{"1":"Nuevo"}}' }] }],
|
|
})
|
|
}
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] })
|
|
return respuestaOk({ create_item: { id: 'lead-nuevo-2' } })
|
|
})
|
|
|
|
await crearServicio().crearLead({ nombre: 'Solo Nombre' })
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
|
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
|
expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha'])
|
|
})
|
|
})
|
|
|
|
describe('asignarAsesorAOportunidad (columna people Asesor)', () => {
|
|
it('escribe personsAndTeams con el user id en el board Oportunidades (555 en tests)', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
|
|
return respuestaOk({ change_column_value: { id: 'op-1' } })
|
|
})
|
|
|
|
await crearServicio().asignarAsesorAOportunidad('op-1', 12345)
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
|
|
expect(mutacion?.body.variables['boardId']).toBe('555')
|
|
expect(mutacion?.body.variables['columnId']).toBe('people_ase')
|
|
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({
|
|
personsAndTeams: [{ id: 12345, kind: 'person' }],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('crearOportunidadAfluencia (captura completa en Oportunidades)', () => {
|
|
it('serializa cada campo según su tipo real (dropdown, status, phone, numbers, long_text, date)', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('settings_str')) {
|
|
// El resolver de labels consulta los settings de cada status/dropdown escrito
|
|
return respuestaOk({
|
|
boards: [
|
|
{
|
|
columns: COLUMNAS_OPORTUNIDADES
|
|
.filter((c) => c.type === 'status' || c.type === 'dropdown')
|
|
.map((c) => ({ id: c.id, title: c.title, settings_str: '{"labels":{"1":"Afluencia"}}' })),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
|
|
return respuestaOk({ create_item: { id: 'op-nueva-1' } })
|
|
})
|
|
|
|
const id = await crearServicio().crearOportunidadAfluencia({
|
|
nombre: 'Lead Prueba',
|
|
telefono: '4771234567',
|
|
email: 'lead@correo.com',
|
|
desarrolloLabel: 'SENDA',
|
|
origenLabel: 'WhatsApp',
|
|
modeloLabel: 'HIMALAYA',
|
|
medioContacto: 'Cabina',
|
|
edad: 35,
|
|
estadoCivilLabel: 'Casado(a)',
|
|
tipoEmpleoLabel: 'Asalariado',
|
|
ocupacion: 'Ingeniero',
|
|
rangoSalarial: '$20,000 - $30,000',
|
|
creditoLabel: 'Infonavit',
|
|
tipoEconomiaLabel: 'Economía mexicana',
|
|
calificaLabel: 'Sí',
|
|
loQuierePara: 'Vivir',
|
|
notas: 'Vio el volante',
|
|
})
|
|
expect(id).toBe('op-nueva-1')
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
|
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
|
expect(parsed['color_estado']).toEqual({ label: 'Afluencia' }) // nace en etapa Afluencia
|
|
expect(parsed['date_fecha']).toMatchObject({ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }) // fecha de captura = hoy
|
|
expect(parsed['phone_ct']).toEqual({ phone: '4771234567', countryShortName: 'MX' })
|
|
expect(parsed['email_ce']).toEqual({ email: 'lead@correo.com', text: 'lead@correo.com' })
|
|
expect(parsed['dropdown_or']).toEqual({ labels: ['WhatsApp'] })
|
|
expect(parsed['dropdown_ec']).toEqual({ labels: ['Casado(a)'] })
|
|
expect(parsed['dropdown_cal']).toEqual({ labels: ['Sí'] })
|
|
expect(parsed['dropdown_lqp']).toEqual({ labels: ['Vivir'] })
|
|
expect(parsed['color_mod']).toEqual({ label: 'HIMALAYA' })
|
|
expect(parsed['color_teco']).toEqual({ label: 'Economía mexicana' }) // columna con typo 'Tipo de Econimia'
|
|
expect(parsed['numeric_edad']).toBe(35)
|
|
expect(parsed['text_mc']).toBe('Cabina')
|
|
expect(parsed['text_ocu']).toBe('Ingeniero')
|
|
expect(parsed['text_rs']).toBe('$20,000 - $30,000')
|
|
expect(parsed['long_com']).toEqual({ text: 'Vio el volante' })
|
|
})
|
|
|
|
it('solo envía las columnas capturadas (las vacías se omiten)', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('settings_str')) {
|
|
return respuestaOk({
|
|
boards: [{ columns: [{ id: 'color_estado', title: 'Estado', settings_str: '{"labels":{"1":"Afluencia"}}' }] }],
|
|
})
|
|
}
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
|
|
return respuestaOk({ create_item: { id: 'op-nueva-2' } })
|
|
})
|
|
|
|
await crearServicio().crearOportunidadAfluencia({ nombre: 'Solo Nombre' })
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
|
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
|
expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha'])
|
|
})
|
|
})
|
|
|
|
describe('crearApartado (ficha completa del tablero Apartado)', () => {
|
|
// Columnas reales del board (verificadas en Monday 2026-09-04)
|
|
const COLUMNAS_APARTADO = [
|
|
{ id: 'text_u', title: 'Unidad', type: 'text' },
|
|
{ id: 'numeric_ma', title: 'Monto apartado', type: 'numbers' },
|
|
{ id: 'date_fa', title: 'Fecha apartado', type: 'date' },
|
|
{ id: 'date_fl', title: 'Fecha límite', type: 'date' },
|
|
{ id: 'color_e', title: 'Estado', type: 'status' },
|
|
{ id: 'phone_t', title: 'Teléfono', type: 'phone' },
|
|
{ id: 'email_c', title: 'Correo electrónico', type: 'email' },
|
|
{ id: 'text_curp', title: 'curp', type: 'text' }, // minúscula en el board
|
|
{ id: 'numeric_pf', title: 'Precio Final', type: 'numbers' },
|
|
{ id: 'numeric_en', title: 'Enganche', type: 'numbers' },
|
|
{ id: 'date_fc', title: 'Fecha Compra', type: 'date' },
|
|
{ id: 'people_v', title: 'Vendedor', type: 'people' },
|
|
{ id: 'dropdown_fp', title: 'Forma de Pago', type: 'dropdown' },
|
|
{ id: 'long_obs', title: 'Observaciones', type: 'long_text' },
|
|
]
|
|
|
|
it('serializa cada columna según su tipo (phone, email, numbers, date, people, dropdown, long_text)', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('settings_str')) {
|
|
return respuestaOk({
|
|
boards: [
|
|
{
|
|
columns: [
|
|
{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Pago Apartado"}}' },
|
|
{ id: 'dropdown_fp', title: 'Forma de Pago', settings_str: '{"labels":[{"name":"Infonavit"},{"name":"Contado"}]}' },
|
|
],
|
|
},
|
|
],
|
|
})
|
|
}
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_APARTADO }] })
|
|
return respuestaOk({ create_item: { id: 'ap-1' } })
|
|
})
|
|
|
|
const id = await crearServicio().crearApartado({
|
|
clienteNombre: 'Cliente Demo',
|
|
unidadCodigo: 'U-101',
|
|
montoApartado: 20000,
|
|
fechaApartado: '2026-09-04',
|
|
fechaLimite: '2026-09-19',
|
|
estadoLabel: 'Pago Apartado',
|
|
telefono: '6641112233',
|
|
email: 'cli@correo.com',
|
|
curp: 'CURP123',
|
|
precioFinal: 1500000,
|
|
enganche: 20000,
|
|
fechaCompra: '2026-09-04',
|
|
vendedorId: 12345,
|
|
formaPagoLabel: 'INFONAVIT', // el resolver lo ajusta al label real
|
|
observaciones: 'Venta con descuento autorizado',
|
|
})
|
|
expect(id).toBe('ap-1')
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
|
expect(mutacion?.body.variables['boardId']).toBe('666') // tablero Apartado
|
|
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
|
expect(parsed['phone_t']).toEqual({ phone: '6641112233', countryShortName: 'MX' })
|
|
expect(parsed['email_c']).toEqual({ email: 'cli@correo.com', text: 'cli@correo.com' })
|
|
expect(parsed['text_curp']).toBe('CURP123')
|
|
expect(parsed['numeric_pf']).toBe(1500000)
|
|
expect(parsed['numeric_en']).toBe(20000)
|
|
expect(parsed['date_fc']).toEqual({ date: '2026-09-04' })
|
|
expect(parsed['people_v']).toEqual({ personsAndTeams: [{ id: 12345, kind: 'person' }] })
|
|
expect(parsed['dropdown_fp']).toEqual({ labels: ['Infonavit'] }) // dropdown resuelto case-insensitive
|
|
expect(parsed['long_obs']).toEqual({ text: 'Venta con descuento autorizado' })
|
|
})
|
|
|
|
it('omite las columnas opcionales no capturadas', async () => {
|
|
const llamadas = mockearFetch((body) => {
|
|
if (body.query.includes('settings_str')) {
|
|
return respuestaOk({
|
|
boards: [{ columns: [{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Pago Apartado"}}' }] }],
|
|
})
|
|
}
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_APARTADO }] })
|
|
return respuestaOk({ create_item: { id: 'ap-2' } })
|
|
})
|
|
|
|
await crearServicio().crearApartado({
|
|
clienteNombre: 'Cliente Demo',
|
|
unidadCodigo: 'U-101',
|
|
fechaApartado: '2026-09-04',
|
|
fechaLimite: '2026-09-19',
|
|
estadoLabel: 'Pago Apartado',
|
|
})
|
|
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
|
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
|
expect(Object.keys(parsed).sort()).toEqual(['color_e', 'date_fa', 'date_fl', 'text_u'])
|
|
})
|
|
})
|
|
|
|
describe('getUltimosCambios (activity log por item)', () => {
|
|
const log = (id: string, event: string, createdAt: string, userId: string, pulseId: number) => ({
|
|
id,
|
|
event,
|
|
created_at: createdAt,
|
|
user_id: userId,
|
|
data: JSON.stringify({ pulse_id: pulseId }),
|
|
})
|
|
|
|
it('itemIds vacío: Map vacío sin llamar a Monday', async () => {
|
|
const llamadas = mockearFetch(() => respuestaOk({}))
|
|
const resultado = await crearServicio().getUltimosCambios('444', [])
|
|
expect(resultado.size).toBe(0)
|
|
expect(llamadas).toHaveLength(0)
|
|
})
|
|
|
|
it('una query con todos los itemIds; se queda con el evento MÁS RECIENTE por item', async () => {
|
|
const llamadas = mockearFetch(() =>
|
|
respuestaOk({
|
|
boards: [
|
|
{
|
|
activity_logs: [
|
|
log('a1', 'create_pulse', '1757000000000000', '106736727', 1),
|
|
log('a2', 'update_status', '1757001000000000', '111111111', 1),
|
|
log('a3', 'create_update', '1757000500000000', '106736727', 2),
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
const resultado = await crearServicio().getUltimosCambios('444', ['1', '2'])
|
|
expect(llamadas).toHaveLength(1)
|
|
expect(llamadas[0]?.body.variables).toMatchObject({ ids: ['444'], itemIds: ['1', '2'], limit: 10 })
|
|
// created_at en µs (> 1e15) → se divide entre 1e6
|
|
expect(resultado.get('1')).toEqual({
|
|
fechaISO: new Date(1757001000000).toISOString(),
|
|
usuarioId: 111111111,
|
|
evento: 'update_status',
|
|
})
|
|
expect(resultado.get('2')).toEqual({
|
|
fechaISO: new Date(1757000500000).toISOString(),
|
|
usuarioId: 106736727,
|
|
evento: 'create_update',
|
|
})
|
|
})
|
|
|
|
it('created_at en ms (≤ 1e15) se usa directo; user_id vacío → null; payload sin pulse_id se ignora', async () => {
|
|
mockearFetch(() =>
|
|
respuestaOk({
|
|
boards: [
|
|
{
|
|
activity_logs: [
|
|
log('a1', 'update_column_value', '1757000000000', '', 7),
|
|
{ id: 'a2', event: 'x', created_at: '1757000000000000', user_id: '5', data: 'no-json' },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
const resultado = await crearServicio().getUltimosCambios('444', ['7'])
|
|
expect(resultado.size).toBe(1)
|
|
expect(resultado.get('7')).toEqual({
|
|
fechaISO: new Date(1757000000000).toISOString(),
|
|
usuarioId: null,
|
|
evento: 'update_column_value',
|
|
})
|
|
})
|
|
|
|
it('created_at de 17 dígitos (100ns, formato real de Monday) → /1e4 a ms', async () => {
|
|
mockearFetch(() =>
|
|
respuestaOk({
|
|
boards: [{ activity_logs: [log('a1', 'create_pulse', '17888408206077900', '106736727', 3)] }],
|
|
}),
|
|
)
|
|
const resultado = await crearServicio().getUltimosCambios('444', ['3'])
|
|
const fecha = resultado.get('3')?.fechaISO
|
|
expect(fecha?.slice(0, 4)).toBe('2026') // sin la regla de 17 dígitos saldría año 2558+
|
|
})
|
|
})
|
|
|
|
describe('crearMotivoNoCompra (grupo por categoría)', () => {
|
|
const COLUMNAS_MOTIVOS = [
|
|
{ id: 'color_cat', title: 'Categoria', type: 'status' },
|
|
{ id: 'text_des', title: 'Descripcion', type: 'text' },
|
|
]
|
|
|
|
function mockearBoardMotivos(): LlamadaFetch[] {
|
|
return mockearFetch((body) => {
|
|
if (body.query.includes('settings_str')) {
|
|
return respuestaOk({
|
|
boards: [
|
|
{ columns: [{ id: 'color_cat', title: 'Categoria', settings_str: '{"labels":{"1":"Precio","2":"Timing"}}' }] },
|
|
],
|
|
})
|
|
}
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_MOTIVOS }] })
|
|
return respuestaOk({ create_item: { id: 'motivo-1' } })
|
|
})
|
|
}
|
|
|
|
it('crea el item directo en el grupo de su categoría', async () => {
|
|
const llamadas = mockearBoardMotivos()
|
|
const id = await crearServicio().crearMotivoNoCompra({
|
|
nombre: 'Precio — Lead Prueba',
|
|
categoria: 'Precio',
|
|
descripcion: 'Lo encontró caro',
|
|
})
|
|
expect(id).toBe('motivo-1')
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
|
expect(mutacion?.body.variables['boardId']).toBe('888') // tablero Motivos No Compra
|
|
expect(mutacion?.body.variables['groupId']).toBe('group_mm6sze0z') // grupo Precio
|
|
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
|
expect(parsed['color_cat']).toEqual({ label: 'Precio' })
|
|
expect(parsed['text_des']).toBe('Lo encontró caro')
|
|
})
|
|
|
|
it('normaliza trim/case al buscar el grupo (Ubicacion sin acento)', async () => {
|
|
const llamadas = mockearBoardMotivos()
|
|
await crearServicio().crearMotivoNoCompra({ nombre: 'U — Lead', categoria: ' ubicacion ' })
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
|
expect(mutacion?.body.variables['groupId']).toBe('group_mm6sqern')
|
|
})
|
|
|
|
it('categoría sin grupo mapeado: group_id null (grupo default) sin tronar', async () => {
|
|
const llamadas = mockearBoardMotivos()
|
|
const id = await crearServicio().crearMotivoNoCompra({ nombre: 'Otro — Lead', categoria: 'Otro raro' })
|
|
expect(id).toBe('motivo-1')
|
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
|
expect(mutacion?.body.variables['groupId']).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('getContactos (módulo Contactos de la landing)', () => {
|
|
const COLUMNAS_CONTACTOS = [
|
|
{ id: 'phone_tel', title: 'Teléfono', type: 'phone' },
|
|
{ id: 'email_em', title: 'Email', type: 'email' },
|
|
{ id: 'color_est', title: 'Estado', type: 'status' },
|
|
]
|
|
const itemContacto = (id: string, updatedAt: string, estado: { text: string; value: string | null }) => ({
|
|
id,
|
|
name: `Contacto ${id}`,
|
|
updated_at: updatedAt,
|
|
column_values: [
|
|
{ id: 'phone_tel', text: `551111000${id}`, value: '{"phone":"5511110000"}' },
|
|
{ id: 'email_em', text: `c${id}@correo.mx`, value: '{"email":"c@correo.mx"}' },
|
|
{ id: 'color_est', text: estado.text, value: estado.value },
|
|
],
|
|
})
|
|
|
|
it('mapea nombre/teléfono/email/estado y ordena por updated_at desc', async () => {
|
|
mockearFetch((body) => {
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_CONTACTOS }] })
|
|
return respuestaOk({
|
|
boards: [
|
|
{
|
|
items_page: {
|
|
cursor: null,
|
|
items: [
|
|
itemContacto('1', '2026-08-01T10:00:00Z', { text: 'CLIENTE', value: '{"index":1}' }),
|
|
itemContacto('2', '2026-09-01T10:00:00Z', { text: 'PROSPECTO', value: '{"index":2}' }),
|
|
],
|
|
},
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
const contactos = await crearServicio().getContactos()
|
|
expect(contactos.map((c) => c.id)).toEqual(['2', '1'])
|
|
expect(contactos[0]).toEqual({
|
|
id: '2',
|
|
nombre: 'Contacto 2',
|
|
telefono: '5511110002',
|
|
email: 'c2@correo.mx',
|
|
estado: 'PROSPECTO',
|
|
updatedAt: '2026-09-01T10:00:00Z',
|
|
})
|
|
})
|
|
|
|
it('estado con value null (nunca escrito) se lee como vacío, no el default del tablero', async () => {
|
|
mockearFetch((body) => {
|
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_CONTACTOS }] })
|
|
return respuestaOk({
|
|
boards: [
|
|
{
|
|
items_page: {
|
|
cursor: null,
|
|
items: [itemContacto('1', '2026-09-01T10:00:00Z', { text: 'PROSPECTO', value: null })],
|
|
},
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
const contactos = await crearServicio().getContactos()
|
|
expect(contactos[0]?.estado).toBe('')
|
|
})
|
|
})
|
|
})
|