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:
2026-09-06 08:08:56 +00:00
parent af2fb10956
commit 60649890eb
70 changed files with 8763 additions and 240 deletions

View File

@@ -1,9 +1,37 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
cambiarEstadoApartado,
cambiarEstadoApartadoEndpoint,
FASES_APARTADO_PERMITIDAS,
subirDocumentosApartado,
TIPOS_DOCUMENTO_APARTADO,
} from './apartados.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
create: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
cliente: {
create: vi.fn(),
findFirst: vi.fn(),
},
documento: {
create: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
function mondayMock(grupoActual = 'topics') {
@@ -71,3 +99,85 @@ describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
})
})
// ---------- Modo local (saludMonday caído / ids loc_*) ----------
describe('ruteo modo local (apartados)', () => {
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-local', ...data }))
prismaMock.documento.create.mockImplementation(async ({ data }) => ({ id: 'doc-1', ...data }))
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('cambiarEstadoApartadoEndpoint con id loc_*: fase local + CAMBIO_ETAPA encolado', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // el id loc_* manda aunque Monday viva
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_apt', board: 'APARTADO' })
const { res, json } = resFake()
await cambiarEstadoApartadoEndpoint(
{ params: { itemId: 'loc_apt' }, body: { estado: 'Alta Enkontrol' } } as unknown as Request,
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_apt' },
data: { etapa: 'Alta Enkontrol', pendienteSync: true },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tipo: 'CAMBIO_ETAPA', referenciaLocal: 'loc_apt' }),
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_apt', estado: 'Alta Enkontrol' })
})
it('subirDocumentosApartado con Monday caído: nombre desde LeadCaptura local y checkbox encolado (CHECKBOX_DOC)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
// El apartado local nació del "Aparto" de la oportunidad: su fila tiene el nombre
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_apt', nombreLead: 'Luis Demo' })
const archivo = {
originalname: 'curp.pdf',
path: '/tmp/uploads/apartados/loc_apt/CURP_1.pdf',
mimetype: 'application/pdf',
size: 1234,
} as Express.Multer.File
const { res, status } = resFake()
await subirDocumentosApartado(
{ params: { itemId: 'loc_apt' }, body: { tipo: 'CURP' }, files: [archivo], usuarioToken: { sub: 'u-1' } } as unknown as Request,
res,
)
// Cliente mínimo creado con el nombre de la fila local (no truena por obtenerNombreItem)
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Luis Demo', mondayApartadoId: 'loc_apt' }),
})
expect(prismaMock.documento.create).toHaveBeenCalledWith({
data: expect.objectContaining({ clienteId: 'cli-local', tipo: 'CURP' }),
})
// El checkbox no se intenta en Monday: queda encolado para el worker
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'CHECKBOX_DOC',
payloadJson: JSON.stringify({ checkbox: 'CURP', itemId: 'loc_apt' }),
referenciaLocal: 'loc_apt',
}),
})
expect(status).toHaveBeenCalledWith(201)
})
})