feat(modo-local): fallback completo sin Monday + cola de sincronización
- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes
Env MODO_LOCAL_FORZADO=true para testing sin Monday.
This commit is contained in:
138
api-gateway/src/controllers/contactos-fallback.test.ts
Normal file
138
api-gateway/src/controllers/contactos-fallback.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
cliente: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
leadCaptura: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
cotizacion: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
documento: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
usuario: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
getContactos: vi.fn(),
|
||||
getContactoDetalle: vi.fn(),
|
||||
getUsuariosWorkspace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
|
||||
|
||||
import { detalleContactoEndpoint, listarContactosEndpoint } from './contactos.controller.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json })
|
||||
return { res: { json, status } as unknown as Response, status, json }
|
||||
}
|
||||
|
||||
const capturaLocal = {
|
||||
id: 'row-1',
|
||||
leadId: 'loc_1',
|
||||
nombreLead: 'ANA DEMO',
|
||||
telefono: '6649998877',
|
||||
email: null,
|
||||
origen: 'WhatsApp',
|
||||
desarrollo: 'SENDA',
|
||||
modelo: '1R',
|
||||
board: 'OPORTUNIDADES',
|
||||
etapa: 'Afluencia',
|
||||
motivo: null,
|
||||
submotivo: null,
|
||||
datosJson: null,
|
||||
mondayItemId: null,
|
||||
pendienteSync: true,
|
||||
usuarioId: 'u-1',
|
||||
createdAt: new Date('2026-09-02T10:00:00'),
|
||||
updatedAt: new Date('2026-09-03T10:00:00'),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
|
||||
saludMonday.restablecer()
|
||||
prismaMock.cliente.findFirst.mockResolvedValue(null)
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([capturaLocal])
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([])
|
||||
prismaMock.documento.findMany.mockResolvedValue([])
|
||||
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }])
|
||||
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('listarContactosEndpoint (fallback local)', () => {
|
||||
it('Monday caído (circuit breaker): lista local sin llamar a Monday', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarContactosEndpoint({} as Request, res)
|
||||
|
||||
expect(mondayMock.getContactos).not.toHaveBeenCalled()
|
||||
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
|
||||
expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' })
|
||||
})
|
||||
|
||||
it('Monday lanza 502: fallback local', async () => {
|
||||
mondayMock.getContactos.mockRejectedValue(new AppError(502, 'Monday no respondió'))
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarContactosEndpoint({} as Request, res)
|
||||
|
||||
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
|
||||
expect(lista[0]).toMatchObject({ id: 'loc_1', fuente: 'local' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('detalleContactoEndpoint (fallback local)', () => {
|
||||
it('Monday caído: detalle 100% local con la misma forma', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
|
||||
const { res, json } = resFake()
|
||||
|
||||
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
|
||||
|
||||
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
|
||||
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877' })
|
||||
expect(detalle['propiedad']).toBeNull()
|
||||
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
|
||||
expect(Array.isArray(detalle['historial'])).toBe(true)
|
||||
})
|
||||
|
||||
it('Monday caído y el id no existe en local → 404', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
await expect(
|
||||
detalleContactoEndpoint({ params: { id: 'nope' } } as unknown as Request, resFake().res),
|
||||
).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
|
||||
it('Monday lanza 502: cae al detalle local; el 404 de Monday se propaga intacto', async () => {
|
||||
mondayMock.getContactoDetalle.mockRejectedValue(new AppError(502, 'Monday no respondió'))
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
|
||||
const { res, json } = resFake()
|
||||
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
|
||||
expect(json).toHaveBeenCalledOnce()
|
||||
|
||||
mondayMock.getContactoDetalle.mockRejectedValue(new NotFoundError('Contacto no encontrado'))
|
||||
await expect(
|
||||
detalleContactoEndpoint({ params: { id: 'x' } } as unknown as Request, resFake().res),
|
||||
).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user