import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' // Mock Prisma before importing the service vi.mock('./prisma.js', () => ({ prisma: { leadCaptura: { findMany: vi.fn(), update: vi.fn() }, asignacionLocal: { findMany: vi.fn(), create: vi.fn() }, actividadLocal: { findFirst: vi.fn(), create: vi.fn() }, usuario: { findMany: vi.fn(), findFirst: vi.fn() }, }, })) vi.mock('./actividad-local.service.js', () => ({ actividadLocalService: { crearActividad: vi.fn(), listarActividades: vi.fn(), cerrarActividad: vi.fn(), eliminarActividad: vi.fn(), contarActividadesAbiertas: vi.fn(), contarActividadesHoy: vi.fn(), actividadesVencidas: vi.fn(), }, })) vi.mock('./salud-monday.service.js', () => ({ saludMonday: { estaDisponible: vi.fn(() => false), registrarPing: vi.fn(), registrarExito: vi.fn(), registrarFallo: vi.fn(), ok: vi.fn(), sondear: vi.fn(), restablecer: vi.fn(), }, })) import { prisma } from './prisma.js' import { actividadLocalService } from './actividad-local.service.js' import { saludMonday } from './salud-monday.service.js' import { AutomatizacionesService, type StateStore } from './automatizaciones.service.js' // ── Fixtures ── const prismaMock = vi.mocked(prisma) const actividadMock = vi.mocked(actividadLocalService) const saludMock = vi.mocked(saludMonday) function leadLocal(id: string, overrides: Record = {}) { return { id, leadId: `loc_${id}`, nombreLead: `Lead ${id}`, board: 'LEADS', etapa: 'NUEVO', usuarioId: 'user-1', telefono: '6641112233', email: '', origen: '', desarrollo: null, modelo: null, motivo: null, submotivo: null, datosJson: null, mondayItemId: null, fechaLimite: null, pendienteSync: false, createdAt: new Date('2026-08-01T09:00:00Z'), updatedAt: new Date('2026-08-01T09:00:00Z'), ...overrides, } } function usuarioLocal(id: string, nombre: string, overrides: Record = {}) { return { id, username: nombre.toLowerCase(), nombre, passwordHash: '', rol: 'vendedor', asignacion: null, empleadoId: null, mondayUserId: null, activo: true, createdAt: new Date(), updatedAt: new Date(), ...overrides, } } function stateEnMemoria(): StateStore & { mapa: Map } { const mapa = new Map() return { mapa, async get(clave) { return mapa.get(clave) ?? null }, async set(clave, valor) { mapa.set(clave, valor) }, } } function crearServicio(state: StateStore) { // Mock Monday service stub (unused in local path but needed by constructor) const mondayStub = { getLeads: async () => [], getOportunidades: async () => [], asignarVendedorALead: async () => {}, asignarAsesorAOportunidad: async () => {}, crearUpdateEnItem: async () => '', crearActividad: async () => '', crearApartado: async () => '', crearVenta: async () => '', getUsuariosEquipo: async () => [], } return new AutomatizacionesService(mondayStub, state) } // ── Tests ── beforeEach(() => { saludMock.estaDisponible.mockReturnValue(false) }) afterEach(() => { vi.clearAllMocks() }) describe('AutomatizacionesService.runRoundRobinLocal', () => { it('asigna leads locales sin asignar en rotación y crea ActividadLocal', async () => { const leads = [ leadLocal('a1', { nombreLead: 'María López' }), leadLocal('a2', { nombreLead: 'Carlos Ruiz' }), ] const usuarios = [ usuarioLocal('u1', 'Ana Vendedor'), usuarioLocal('u2', 'Beto Vendedor'), ] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(leads as never) prismaMock.asignacionLocal.findMany.mockResolvedValueOnce([] as never) prismaMock.usuario.findMany.mockResolvedValueOnce(usuarios as never) prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarios[0] as never) prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarios[1] as never) prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never) actividadMock.crearActividad.mockResolvedValueOnce({} as never) prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never) prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never) actividadMock.crearActividad.mockResolvedValueOnce({} as never) prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.runRoundRobinLocal() expect(r.asignados).toBe(2) expect(r.detalle[0]?.lead).toBe('María López') expect(r.detalle[1]?.lead).toBe('Carlos Ruiz') expect(prismaMock.asignacionLocal.create).toHaveBeenCalledTimes(2) expect(actividadMock.crearActividad).toHaveBeenCalledTimes(2) expect(actividadMock.crearActividad).toHaveBeenCalledWith( expect.objectContaining({ titulo: 'Contactar a María López', tipo: 'SEGUIMIENTO', }), ) expect(prismaMock.leadCaptura.update).toHaveBeenCalledTimes(2) }) it('no asigna leads que ya tienen AsignacionLocal', async () => { const leads = [ leadLocal('a1'), leadLocal('a2'), ] const asignaciones = [{ leadCapturaId: 'a1' }] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(leads as never) prismaMock.asignacionLocal.findMany.mockResolvedValueOnce(asignaciones as never) prismaMock.usuario.findMany.mockResolvedValueOnce([usuarioLocal('u1', 'Ana')] as never) prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarioLocal('u1', 'Ana') as never) prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never) actividadMock.crearActividad.mockResolvedValueOnce({} as never) prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.runRoundRobinLocal() expect(r.asignados).toBe(1) expect(r.detalle[0]?.lead).toBe('Lead a2') }) it('devuelve 0 si no hay leads sin asignar', async () => { prismaMock.leadCaptura.findMany.mockResolvedValueOnce([] as never) prismaMock.asignacionLocal.findMany.mockResolvedValueOnce([] as never) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.runRoundRobinLocal() expect(r.asignados).toBe(0) expect(prismaMock.asignacionLocal.create).not.toHaveBeenCalled() }) it('rota el índice entre pasadas (dedup)', async () => { const leads = [leadLocal('a1')] const usuarios = [usuarioLocal('u1', 'Ana'), usuarioLocal('u2', 'Beto')] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(leads as never) prismaMock.asignacionLocal.findMany.mockResolvedValueOnce([] as never) prismaMock.usuario.findMany.mockResolvedValueOnce(usuarios as never) prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarios[1] as never) prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never) actividadMock.crearActividad.mockResolvedValueOnce({} as never) prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never) const state = stateEnMemoria() state.mapa.set('rr:indice', '1') const svc = crearServicio(state) const r = await svc.runRoundRobinLocal() expect(r.asignados).toBe(1) expect(r.detalle[0]?.vendedor).toBe('Beto') expect(state.mapa.get('rr:indice')).toBe('0') // 2 % 2 vendedores = 0 }) }) describe('AutomatizacionesService.escaneoInactividadLocal', () => { const ahora = new Date('2026-08-22T09:00:00Z') it('detecta oportunidades inactivas y crea ActividadLocal RECORDATORIO', async () => { const oportunidades = [ leadLocal('o1', { board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-08-01T00:00:00Z'), }), ] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never) prismaMock.actividadLocal.findFirst.mockResolvedValueOnce(null) actividadMock.crearActividad.mockResolvedValueOnce({} as never) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.escaneoInactividadLocal({ preview: false, ahora }) expect(r.preview).toBe(false) expect(r.candidatos).toHaveLength(1) expect(r.candidatos[0]?.diasInactivo).toBe(21) expect(r.recordatoriosEnviados).toBe(1) expect(actividadMock.crearActividad).toHaveBeenCalledWith( expect.objectContaining({ titulo: 'Seguimiento inactivo: Lead o1', tipo: 'RECORDATORIO', }), ) }) it('no duplica si ya tiene actividad abierta (yaRecordadoHoy=true)', async () => { const oportunidades = [ leadLocal('o1', { board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-08-01T00:00:00Z'), }), ] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never) prismaMock.actividadLocal.findFirst.mockResolvedValueOnce({ id: 'act-1' } as never) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.escaneoInactividadLocal({ preview: false, ahora }) expect(r.candidatos[0]?.yaRecordadoHoy).toBe(true) expect(r.recordatoriosEnviados).toBe(0) expect(actividadMock.crearActividad).not.toHaveBeenCalled() }) it('preview no crea actividades', async () => { const oportunidades = [ leadLocal('o1', { board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-08-01T00:00:00Z'), }), ] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never) prismaMock.actividadLocal.findFirst.mockResolvedValueOnce(null) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.escaneoInactividadLocal({ preview: true, ahora }) expect(r.preview).toBe(true) expect(r.recordatoriosEnviados).toBe(0) expect(actividadMock.crearActividad).not.toHaveBeenCalled() }) it('ignora etapas terminales (Prisma las filtra con notIn)', async () => { // Prisma's where clause filters terminal stages; mock returns empty (simulating that) prismaMock.leadCaptura.findMany.mockResolvedValueOnce([] as never) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.escaneoInactividadLocal({ preview: false, ahora }) expect(r.candidatos).toHaveLength(0) }) }) describe('AutomatizacionesService.escaneoEscaladoLocal', () => { const ahora = new Date('2026-08-22T12:00:00Z') it('detecta oportunidades sin contacto >48h y crea ActividadLocal URGENTE', async () => { const oportunidades = [ leadLocal('o1', { board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-08-19T12:00:00Z'), // 72h }), ] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never) prismaMock.actividadLocal.findFirst.mockResolvedValueOnce(null) // no tiene URGENTE actividadMock.crearActividad.mockResolvedValueOnce({} as never) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.escaneoEscaladoLocal({ ahora }) expect(r.escalados).toBe(1) expect(r.candidatos[0]?.horasSinContactar).toBe(72) expect(r.candidatos[0]?.yaEscalado).toBe(false) expect(actividadMock.crearActividad).toHaveBeenCalledWith( expect.objectContaining({ titulo: '🔴 URGENTE: Lead o1 sin contacto >48h', tipo: 'SEGUIMIENTO', }), ) expect(state.mapa.get('escalado:loc_o1')).toBeDefined() }) it('no duplica si ya tiene actividad URGENTE', async () => { const oportunidades = [ leadLocal('o1', { board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-08-19T12:00:00Z'), }), ] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never) prismaMock.actividadLocal.findFirst.mockResolvedValueOnce({ id: 'act-urg-1' } as never) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.escaneoEscaladoLocal({ ahora }) expect(r.escalados).toBe(0) expect(r.candidatos[0]?.yaEscalado).toBe(true) expect(actividadMock.crearActividad).not.toHaveBeenCalled() }) it('ignora oportunidades con <48h sin contacto', async () => { const oportunidades = [ leadLocal('o1', { board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-08-20T12:00:00Z'), // 48h justo → no supera }), ] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.escaneoEscaladoLocal({ ahora }) expect(r.candidatos).toHaveLength(0) expect(r.escalados).toBe(0) }) it('un fallo al crear actividad no rompe el escaneo de las demás', async () => { const oportunidades = [ leadLocal('o1', { board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-08-19T12:00:00Z'), }), leadLocal('o2', { board: 'OPORTUNIDADES', etapa: 'INTENTO DE CONTACTO', updatedAt: new Date('2026-08-18T12:00:00Z'), }), ] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never) prismaMock.actividadLocal.findFirst .mockResolvedValueOnce(null) // o1 no tiene URGENTE .mockResolvedValueOnce(null) // o2 no tiene URGENTE actividadMock.crearActividad .mockRejectedValueOnce(new Error('DB error')) .mockResolvedValueOnce({} as never) const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) const state = stateEnMemoria() const svc = crearServicio(state) const r = await svc.escaneoEscaladoLocal({ ahora }) expect(r.escalados).toBe(1) // o2 se escaló expect(r.candidatos).toHaveLength(2) spyError.mockRestore() }) }) describe('runRoundRobin delega a runRoundRobinLocal cuando Monday no está disponible', () => { it('si saludMonday.estaDisponible() es false, llama a runRoundRobinLocal', async () => { saludMock.estaDisponible.mockReturnValue(false) const leads = [leadLocal('a1')] prismaMock.leadCaptura.findMany.mockResolvedValueOnce(leads as never) prismaMock.asignacionLocal.findMany.mockResolvedValueOnce([] as never) prismaMock.usuario.findMany.mockResolvedValueOnce([usuarioLocal('u1', 'Ana')] as never) prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarioLocal('u1', 'Ana') as never) prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never) actividadMock.crearActividad.mockResolvedValueOnce({} as never) prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never) const mondayStub = { getLeads: vi.fn(), getOportunidades: vi.fn(), getUsuariosEquipo: vi.fn(), asignarVendedorALead: vi.fn(), asignarAsesorAOportunidad: vi.fn(), crearUpdateEnItem: vi.fn(), crearActividad: vi.fn(), crearApartado: vi.fn(), crearVenta: vi.fn(), } const state = stateEnMemoria() const svc = new AutomatizacionesService(mondayStub, state) const r = await svc.runRoundRobin() // Monday methods should NOT be called (local path used) expect(mondayStub.getLeads).not.toHaveBeenCalled() expect(r.asignados).toBe(1) }) })