El bot (Luisa) es externo y no toca Postgres directamente. Cuatro endpoints autenticados con Bearer FUNNEL_API_KEY, validados con zod: - POST /api/leads/:id/conversacion → turno de chat (+ estado_wa/bot_step) - POST /api/leads/:id/perfil → update parcial del lead (extracción) - POST /api/leads/:id/calificacion → upsert de lead_calificacion - POST /api/leads/:id/intento → registro en intentos_contacto Helpers compartidos lib/api/funnel-auth.ts (autorizado + jsonResponse) y lib/api/bot-request.ts (validarBotRequest: auth + JSON + zod + lead existe). La ruta de ingesta se refactoriza para reutilizar funnel-auth (DRY). Schemas puros en lib/funnel/bot-schemas.ts con tests, y doc en api-docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
141 lines
4.3 KiB
TypeScript
141 lines
4.3 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
conversacionSchema,
|
|
perfilSchema,
|
|
calificacionSchema,
|
|
intentoSchema,
|
|
} from '@/lib/funnel/bot-schemas';
|
|
|
|
describe('conversacionSchema', () => {
|
|
it('acepta un turno mínimo (rol + mensaje)', () => {
|
|
const r = conversacionSchema.safeParse({ rol: 'user', mensaje: 'Hola' });
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it('acepta media y estado opcionales', () => {
|
|
const r = conversacionSchema.safeParse({
|
|
rol: 'assistant',
|
|
mensaje: 'Te mando una foto',
|
|
mediaType: 'image',
|
|
mediaUrl: 'https://x/y.jpg',
|
|
estadoWa: 'entregado',
|
|
botStep: 'pide_fotos',
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it('rechaza mensaje vacío', () => {
|
|
expect(conversacionSchema.safeParse({ rol: 'user', mensaje: ' ' }).success).toBe(false);
|
|
});
|
|
|
|
it('rechaza un rol fuera del enum', () => {
|
|
expect(conversacionSchema.safeParse({ rol: 'bot', mensaje: 'Hola' }).success).toBe(false);
|
|
});
|
|
|
|
it('rechaza un estadoWa inválido', () => {
|
|
const r = conversacionSchema.safeParse({ rol: 'user', mensaje: 'Hola', estadoWa: 'visto' });
|
|
expect(r.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('perfilSchema', () => {
|
|
it('acepta una actualización parcial con un solo campo', () => {
|
|
expect(perfilSchema.safeParse({ botStep: 'espacio' }).success).toBe(true);
|
|
});
|
|
|
|
it('acepta campos normalizados del motor de presupuesto', () => {
|
|
const r = perfilSchema.safeParse({
|
|
tipoReforma: 'cocina',
|
|
m2Suelo: 12.5,
|
|
calidadGlobal: 'premium',
|
|
urgencia: 'alta',
|
|
presupuestoTarget: 1500000,
|
|
viable: true,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it('acepta fotosSolicitadasAt como datetime ISO', () => {
|
|
const r = perfilSchema.safeParse({ fotosSolicitadasAt: '2026-06-07T10:00:00.000Z' });
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it('rechaza un cuerpo vacío', () => {
|
|
expect(perfilSchema.safeParse({}).success).toBe(false);
|
|
});
|
|
|
|
it('rechaza tipoReforma fuera del enum', () => {
|
|
expect(perfilSchema.safeParse({ tipoReforma: 'jardin' }).success).toBe(false);
|
|
});
|
|
|
|
it('rechaza m2Suelo no positivo', () => {
|
|
expect(perfilSchema.safeParse({ m2Suelo: 0 }).success).toBe(false);
|
|
expect(perfilSchema.safeParse({ m2Suelo: -3 }).success).toBe(false);
|
|
});
|
|
|
|
it('rechaza fotosSolicitadasAt que no es datetime', () => {
|
|
expect(perfilSchema.safeParse({ fotosSolicitadasAt: 'ayer' }).success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('calificacionSchema', () => {
|
|
it('acepta score + nivel + criterios libres', () => {
|
|
const r = calificacionSchema.safeParse({
|
|
score: 78,
|
|
nivel: 'B',
|
|
criterios: { presupuesto: 'ok', urgencia: 'media' },
|
|
notasAgente: 'Lead caliente',
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it('acepta solo notasAgente', () => {
|
|
expect(calificacionSchema.safeParse({ notasAgente: 'Pendiente de fotos' }).success).toBe(true);
|
|
});
|
|
|
|
it('rechaza un cuerpo vacío', () => {
|
|
expect(calificacionSchema.safeParse({}).success).toBe(false);
|
|
});
|
|
|
|
it('rechaza score fuera de 0-100', () => {
|
|
expect(calificacionSchema.safeParse({ score: 120 }).success).toBe(false);
|
|
expect(calificacionSchema.safeParse({ score: -1 }).success).toBe(false);
|
|
});
|
|
|
|
it('rechaza un nivel fuera del enum', () => {
|
|
expect(calificacionSchema.safeParse({ nivel: 'E' }).success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('intentoSchema', () => {
|
|
it('acepta un intento mínimo (canal + numeroIntento)', () => {
|
|
const r = intentoSchema.safeParse({ canal: 'whatsapp', numeroIntento: 1 });
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it('acepta resultado, duración y metadata', () => {
|
|
const r = intentoSchema.safeParse({
|
|
canal: 'llamada',
|
|
resultado: 'no_contesta',
|
|
completado: false,
|
|
numeroIntento: 2,
|
|
duracionSeg: 0,
|
|
metadata: { retellCallId: 'call_123' },
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it('rechaza canal fuera del enum', () => {
|
|
expect(intentoSchema.safeParse({ canal: 'email', numeroIntento: 1 }).success).toBe(false);
|
|
});
|
|
|
|
it('rechaza numeroIntento menor que 1', () => {
|
|
expect(intentoSchema.safeParse({ canal: 'whatsapp', numeroIntento: 0 }).success).toBe(false);
|
|
});
|
|
|
|
it('rechaza resultado fuera del enum', () => {
|
|
const r = intentoSchema.safeParse({ canal: 'llamada', resultado: 'colgado', numeroIntento: 1 });
|
|
expect(r.success).toBe(false);
|
|
});
|
|
});
|