feat(desarrollos): modulo de alta de desarrollos con inventario, reglas del cotizador por desarrollo y micrositio publico
Modulo nuevo (solo admin) para dar de alta desarrollos sin tocar codigo: - CRUD de la ficha, modelos e inventario (alta manual y generador por rango) - Reglas del cotizador por desarrollo, editables y persistidas en la BD - El cotizador consume las reglas desde /desarrollos/reglas-cotizador y cae a las constantes con aviso cuando un desarrollo aun no tiene reglas guardadas - Micrositio publico en /d/:clave (sin sesion) con ficha, modelos y disponibilidad; jamas expone datos de clientes ni cotizaciones Integracion: - La landing monta el modulo en el grid (clave 'desarrollos', solo admin) y el micrositio fuera del login; el rol manager NO accede (matriz y API con 403) - La tabla de roles de Configuracion ahora lista todos los modulos - Se alinea la FK de ReglaCotizadorDesarrollo con la BD viva (Desarrollo.id) Verificado: 708 pruebas del API, 115 del cotizador y 89 de la landing en verde; 12/12 cotizaciones identicas antes y despues del cambio de reglas; prueba en vivo del API con alta, modelos, inventario, reglas, micrositio y baja (30/30).
This commit is contained in:
253
api-gateway/src/services/reglas-desarrollo.service.test.ts
Normal file
253
api-gateway/src/services/reglas-desarrollo.service.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado (los tests no tocan BD) ----------
|
||||
// `reglaCotizadorDesarrollo` y `desarrollo` se activan/desactivan por test:
|
||||
// simulan el cliente Prisma con los modelos generados o sin ellos (lectura
|
||||
// cruda de las tablas).
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
reglaCotizadorDesarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
|
||||
desarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { DESARROLLOS, type ParametrosDesarrollo } from './cotizacion.service.js'
|
||||
import {
|
||||
getReglasDesarrollo,
|
||||
getReglasEfectivas,
|
||||
getReglasGuardadas,
|
||||
normalizarReglas,
|
||||
} from './reglas-desarrollo.service.js'
|
||||
|
||||
/** Reglas con la forma exacta que guarda la BD (JSON serializado) */
|
||||
function reglasJson(params: ParametrosDesarrollo, extras: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
...params,
|
||||
...extras,
|
||||
propuestas: params.propuestas.map((p) => ({ ...p, activa: true })),
|
||||
})
|
||||
}
|
||||
|
||||
function usarModeloPrisma(): ReturnType<typeof vi.fn> {
|
||||
const findMany = vi.fn()
|
||||
prismaMock.reglaCotizadorDesarrollo = { findMany }
|
||||
return findMany
|
||||
}
|
||||
|
||||
/** Ficha del desarrollo (tabla Desarrollo) para el catálogo del servicio */
|
||||
function ficha(id: string, clave: string, extras: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
clave,
|
||||
nombre: clave.charAt(0) + clave.slice(1).toLowerCase(),
|
||||
activoEnCotizador: true,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
function usarCatalogo(filas: unknown[]): void {
|
||||
prismaMock.desarrollo = { findMany: vi.fn().mockResolvedValue(filas) }
|
||||
}
|
||||
|
||||
/** Propuesta del fixture (JSON crudo) por tipo */
|
||||
function brinda(propuestas: Record<string, unknown>[], tipo: string): Record<string, unknown> {
|
||||
const p = propuestas.find((x) => x['tipo'] === tipo)
|
||||
if (!p) throw new Error(`La propuesta ${tipo} no está en el fixture`)
|
||||
return p
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.reglaCotizadorDesarrollo = undefined
|
||||
prismaMock.desarrollo = undefined
|
||||
prismaMock.$queryRawUnsafe.mockReset()
|
||||
prismaMock.$queryRawUnsafe.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('normalizarReglas (JSON de la BD)', () => {
|
||||
it('acepta el JSON con la forma de las constantes y conserva los valores', () => {
|
||||
const reglas = normalizarReglas('DIAGONAL', JSON.parse(reglasJson(DESARROLLOS['DIAGONAL']!)))
|
||||
expect(reglas).not.toBeNull()
|
||||
expect(reglas?.id).toBe('DIAGONAL')
|
||||
expect(reglas?.propuestas).toHaveLength(4)
|
||||
expect(reglas?.propuestas[2]?.descuentoDefault).toBe(0.02)
|
||||
})
|
||||
|
||||
it('marca la propuesta desactivada por el admin (activa/activo)', () => {
|
||||
const bruto = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as {
|
||||
propuestas: Record<string, unknown>[]
|
||||
}
|
||||
brinda(bruto.propuestas, 'CONTADO').activa = false
|
||||
const reglas = normalizarReglas('SENDA', bruto)
|
||||
expect(reglas?.propuestas.find((p) => p.tipo === 'CONTADO')?.activa).toBe(false)
|
||||
})
|
||||
|
||||
it('rechaza JSON sin la forma esperada (precioUnico, propuestas, claves)', () => {
|
||||
expect(normalizarReglas('DIAGONAL', null)).toBeNull()
|
||||
expect(normalizarReglas('DIAGONAL', 'DIAGONAL')).toBeNull()
|
||||
expect(normalizarReglas('DIAGONAL', { precioUnico: true })).toBeNull()
|
||||
expect(
|
||||
normalizarReglas('DIAGONAL', {
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'fijo',
|
||||
propuestas: [{ tipo: 'CONTADO' }],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReglasGuardadas / getReglasEfectivas', () => {
|
||||
it('sin filas guardadas devuelve las constantes actuales (compatibilidad)', async () => {
|
||||
usarModeloPrisma().mockResolvedValue([])
|
||||
|
||||
const efectivas = await getReglasEfectivas()
|
||||
|
||||
expect(Object.keys(efectivas).sort()).toEqual(Object.keys(DESARROLLOS).sort())
|
||||
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
|
||||
expect(efectivas[id]).toEqual(constantes)
|
||||
}
|
||||
expect(await getReglasDesarrollo('SENDA')).toEqual(DESARROLLOS['SENDA'])
|
||||
})
|
||||
|
||||
it('las reglas de la BD ganan a las constantes del desarrollo', async () => {
|
||||
const conDescuento = {
|
||||
...DESARROLLOS['DIAGONAL']!,
|
||||
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
|
||||
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.07 } : p,
|
||||
),
|
||||
}
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: reglasJson(conDescuento) },
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
const contado = reglas.propuestas.find((p) => p.tipo === 'CONTADO')
|
||||
expect(contado?.descuentoDefault).toBe(0.07)
|
||||
expect(DESARROLLOS['DIAGONAL']!.propuestas[2]?.descuentoDefault).toBe(0.02)
|
||||
|
||||
// Los demás desarrollos siguen en constantes
|
||||
expect((await getReglasDesarrollo('SOLANA')).propuestas[1]?.pctEnganche).toBe(0)
|
||||
})
|
||||
|
||||
it('usa la columna activoEnCotizador y también el campo dentro del JSON', async () => {
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{
|
||||
desarrolloId: 'SOLANA',
|
||||
parametrosJson: reglasJson(DESARROLLOS['SOLANA']!),
|
||||
activoEnCotizador: false,
|
||||
},
|
||||
{
|
||||
desarrolloId: 'ESTANZA',
|
||||
parametrosJson: reglasJson(DESARROLLOS['ESTANZA']!, { activoEnCotizador: false }),
|
||||
},
|
||||
])
|
||||
|
||||
expect((await getReglasDesarrollo('SOLANA')).activoEnCotizador).toBe(false)
|
||||
expect((await getReglasDesarrollo('ESTANZA')).activoEnCotizador).toBe(false)
|
||||
expect((await getReglasDesarrollo('DIAGONAL')).activoEnCotizador).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resuelve la clave del desarrollo por su id (las reglas apuntan al uuid) y lee su ficha', async () => {
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{
|
||||
desarrolloId: 'uuid-diagonal',
|
||||
parametrosJson: reglasJson({ ...DESARROLLOS['DIAGONAL']!, nombre: undefined as never }),
|
||||
},
|
||||
])
|
||||
usarCatalogo([
|
||||
ficha('uuid-diagonal', 'DIAGONAL', { activoEnCotizador: false, nombre: 'Diagonal Residencial' }),
|
||||
ficha('uuid-senda', 'SENDA'),
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
|
||||
// La clave sale del catálogo (id → clave), no del uuid
|
||||
expect(reglas.propuestas).toHaveLength(4)
|
||||
// El nombre y la bandera salen de la ficha del desarrollo
|
||||
expect(reglas.nombre).toBe('Diagonal Residencial')
|
||||
expect(reglas.activoEnCotizador).toBe(false)
|
||||
|
||||
// Las reglas efectivas y el desarrollo activo siguen saliendo de constantes
|
||||
const efectivas = await getReglasEfectivas()
|
||||
expect(efectivas['SENDA']).toEqual(DESARROLLOS['SENDA'])
|
||||
expect(efectivas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
|
||||
})
|
||||
|
||||
it('ignora filas sin desarrollo identificable o sin parámetros', async () => {
|
||||
const sinClave = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as Record<string, unknown>
|
||||
delete sinClave['id']
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: '', parametrosJson: JSON.stringify(sinClave) },
|
||||
{ desarrolloId: 'SENDA', parametrosJson: null },
|
||||
null,
|
||||
])
|
||||
|
||||
expect((await getReglasGuardadas()).size).toBe(0)
|
||||
})
|
||||
|
||||
it('lee la tabla por SQL crudo cuando el cliente Prisma no tiene el modelo', async () => {
|
||||
prismaMock.$queryRawUnsafe.mockImplementation(async (sql: string) => {
|
||||
if (sql.includes('"ReglaCotizadorDesarrollo"')) {
|
||||
return [
|
||||
{ desarrolloId: 'uuid-senda', parametrosJson: reglasJson(DESARROLLOS['SENDA']!) },
|
||||
{ desarrollo: 'SOLANA', parametros: reglasJson(DESARROLLOS['SOLANA']!) },
|
||||
]
|
||||
}
|
||||
return [ficha('uuid-senda', 'SENDA'), ficha('uuid-solana', 'SOLANA')]
|
||||
})
|
||||
|
||||
const reglas = await getReglasEfectivas()
|
||||
|
||||
expect(prismaMock.$queryRawUnsafe).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"ReglaCotizadorDesarrollo"'),
|
||||
)
|
||||
expect(reglas['SENDA']?.propuestas).toHaveLength(4)
|
||||
expect(reglas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('respaldo en constantes (nunca falla la cotización)', () => {
|
||||
it('JSON corrupto: respaldo en las constantes con warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: '{"propuestas": [' },
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
|
||||
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('JSON válido pero con forma inesperada: respaldo en las constantes con warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: JSON.stringify({ precioUnico: true }) },
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
|
||||
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('forma inesperada'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('tabla inexistente: respaldo en las constantes, sin lanzar y con un solo warning', async () => {
|
||||
vi.resetModules()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
prismaMock.$queryRawUnsafe.mockRejectedValue(new Error('no such table: ReglaCotizadorDesarrollo'))
|
||||
const servicio = await import('./reglas-desarrollo.service.js')
|
||||
|
||||
const reglas = await servicio.getReglasDesarrollo('SENDA')
|
||||
const efectivas = await servicio.getReglasEfectivas()
|
||||
|
||||
expect(reglas).toEqual(DESARROLLOS['SENDA'])
|
||||
expect(Object.keys(efectivas)).toHaveLength(Object.keys(DESARROLLOS).length)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('constantes'), expect.any(Error))
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user