Añade el EP único de ingesta async del lead

POST /api/leads/:id/ingesta (Bearer FUNNEL_API_KEY): acepta items foto/texto
etiquetados por zona y momento, más flags perfilCompleto y finalizar.
- ingesta-schema.ts: zod del cuerpo (union discriminada foto|texto), exportado
  para test; rechaza llamadas vacías.
- route.ts: auth 401, valida lead (404), inserta fotos (orden continúa el máx)
  y notas, traza fotos_subidas; perfilCompleto→señalarPerfilCompleto,
  finalizar→finalizarYEntregar.
- 10 tests del schema. Verificado por HTTP: 401/200/422/404 y finalizar genera
  el pdf_url y avanza el lead a whatsapp_entregado.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-03 19:09:16 +02:00
parent 195ecf6cc3
commit ae8984fe13
3 changed files with 210 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
describe('ingestaBodySchema', () => {
it('acepta una foto con zona y momento por defecto "antes"', () => {
const r = ingestaBodySchema.safeParse({
items: [{ tipo: 'foto', zona: 'bano', imagen: 'data:image/png;base64,AAA' }],
});
expect(r.success).toBe(true);
if (r.success) {
const foto = r.data.items[0];
expect(foto.tipo).toBe('foto');
if (foto.tipo === 'foto') expect(foto.momento).toBe('antes');
}
});
it('acepta momento "despues" explícito', () => {
const r = ingestaBodySchema.safeParse({
items: [{ tipo: 'foto', zona: 'cocina', momento: 'despues', imagen: 'https://x/y.jpg' }],
});
expect(r.success).toBe(true);
});
it('acepta una nota de texto y zona opcional', () => {
const r = ingestaBodySchema.safeParse({ items: [{ tipo: 'texto', texto: 'suelo premium' }] });
expect(r.success).toBe(true);
});
it('acepta items mixtos foto + texto', () => {
const r = ingestaBodySchema.safeParse({
items: [
{ tipo: 'foto', zona: 'bano', imagen: 'data:image/png;base64,AAA' },
{ tipo: 'texto', zona: 'bano', texto: 'suelo premium' },
],
});
expect(r.success).toBe(true);
});
it('acepta una llamada solo con flag perfilCompleto (sin items)', () => {
const r = ingestaBodySchema.safeParse({ perfilCompleto: true });
expect(r.success).toBe(true);
if (r.success) expect(r.data.items).toEqual([]);
});
it('acepta una llamada solo con flag finalizar', () => {
expect(ingestaBodySchema.safeParse({ finalizar: true }).success).toBe(true);
});
it('rechaza una llamada totalmente vacía', () => {
expect(ingestaBodySchema.safeParse({}).success).toBe(false);
expect(ingestaBodySchema.safeParse({ items: [] }).success).toBe(false);
});
it('rechaza un tipo de item inválido', () => {
const r = ingestaBodySchema.safeParse({ items: [{ tipo: 'video', imagen: 'x' }] });
expect(r.success).toBe(false);
});
it('rechaza una zona fuera del enum', () => {
const r = ingestaBodySchema.safeParse({
items: [{ tipo: 'foto', zona: 'jardin', imagen: 'data:...' }],
});
expect(r.success).toBe(false);
});
it('rechaza foto sin imagen y texto vacío', () => {
expect(ingestaBodySchema.safeParse({ items: [{ tipo: 'foto', imagen: '' }] }).success).toBe(
false,
);
expect(ingestaBodySchema.safeParse({ items: [{ tipo: 'texto', texto: ' ' }] }).success).toBe(
false,
);
});
});