diff --git a/pkg/services/ciclo_notificaciones_test.go b/pkg/services/ciclo_notificaciones_test.go new file mode 100644 index 0000000..225b188 --- /dev/null +++ b/pkg/services/ciclo_notificaciones_test.go @@ -0,0 +1,435 @@ +//go:build integration + +package services_test + +// Test de integración del ciclo completo de notificaciones: +// 1. Envío de correo de bienvenida al crear contrato +// 2. Marca del contrato como pagado +// 3. Envío automático de confirmación de pago +// 4. Idempotencia: no reenviar si ya se envió hoy +// +// Requisitos para ejecutar: +// export TEST_DATABASE_URL="host=localhost user=postgres password=xxx dbname=soft_usite_test port=5432 sslmode=disable" +// go test -v -tags integration ./pkg/services/ -run TestCicloNotificaciones + +import ( + "bufio" + "fmt" + "net" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/config" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" + mail "github.com/xhit/go-simple-mail/v2" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +// ─── Servidor SMTP falso ────────────────────────────────────────────────────── + +// fakeSMTP implementa el mínimo del protocolo SMTP para aceptar envíos de +// go-simple-mail sin cifrado. No valida credenciales. +type fakeSMTP struct { + listener net.Listener + mu sync.Mutex + emails []capturedEmail +} + +type capturedEmail struct { + To string + Body string +} + +func newFakeSMTP(t *testing.T) *fakeSMTP { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("fakeSMTP listen: %v", err) + } + s := &fakeSMTP{listener: ln} + go s.serve() + return s +} + +func (s *fakeSMTP) Port() int { + return s.listener.Addr().(*net.TCPAddr).Port +} + +func (s *fakeSMTP) Close() { + s.listener.Close() +} + +func (s *fakeSMTP) Emails() []capturedEmail { + s.mu.Lock() + defer s.mu.Unlock() + cp := make([]capturedEmail, len(s.emails)) + copy(cp, s.emails) + return cp +} + +func (s *fakeSMTP) Count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.emails) +} + +func (s *fakeSMTP) serve() { + for { + conn, err := s.listener.Accept() + if err != nil { + return // listener cerrado + } + go s.handleConn(conn) + } +} + +func (s *fakeSMTP) handleConn(conn net.Conn) { + defer conn.Close() + w := bufio.NewWriter(conn) + r := bufio.NewReader(conn) + + send := func(line string) { + fmt.Fprintf(w, "%s\r\n", line) + w.Flush() + } + + send("220 fakesmtp ESMTP ready") + + var currentTo string + var inData bool + var body strings.Builder + + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + + if inData { + if line == "." { + s.mu.Lock() + s.emails = append(s.emails, capturedEmail{To: currentTo, Body: body.String()}) + s.mu.Unlock() + body.Reset() + currentTo = "" + inData = false + send("250 Message queued") + } else { + // El protocolo SMTP usa "byte stuffing" con punto inicial: strip leading dot + if strings.HasPrefix(line, "..") { + line = line[1:] + } + body.WriteString(line + "\n") + } + continue + } + + upper := strings.ToUpper(line) + switch { + case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"): + send("250-fakesmtp\r\n250 AUTH PLAIN LOGIN") + case strings.HasPrefix(upper, "AUTH"): + send("235 Authentication successful") + case strings.HasPrefix(upper, "MAIL FROM"): + send("250 OK") + case strings.HasPrefix(upper, "RCPT TO"): + start := strings.Index(line, "<") + end := strings.LastIndex(line, ">") + if start >= 0 && end > start { + currentTo = line[start+1 : end] + } + send("250 OK") + case upper == "DATA": + inData = true + send("354 End data with .") + case strings.HasPrefix(upper, "RSET"): + currentTo = "" + body.Reset() + inData = false + send("250 OK") + case strings.HasPrefix(upper, "QUIT"): + send("221 Bye") + return + default: + send("500 Unknown command") + } + } +} + +// ─── Helpers de test ────────────────────────────────────────────────────────── + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL no configurado — omitiendo test de integración.\n" + + "Ejemplo: export TEST_DATABASE_URL=\"host=localhost user=postgres password=xxx dbname=soft_usite_test port=5432 sslmode=disable\"") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Silent), + }) + if err != nil { + t.Fatalf("no se pudo conectar a la DB de test: %v", err) + } + return db +} + +// bootstrapApp inicializa app.Http con la DB y el SMTP falso dados. +func bootstrapApp(db *gorm.DB, smtp *fakeSMTP) { + if app.Http == nil { + app.Http = &config.AppConfig{} + } + + // DB + app.Http.Database.DB = db + + // Mail — configurar SMTPServer apuntando al fake server + smtpServer := mail.NewSMTPClient() + smtpServer.Host = "127.0.0.1" + smtpServer.Port = smtp.Port() + smtpServer.Encryption = mail.EncryptionNone + smtpServer.ConnectTimeout = 5 * time.Second + smtpServer.SendTimeout = 10 * time.Second + // Sin Username para evitar AUTH obligatorio + + app.Http.Mail.SMTPServer = smtpServer + app.Http.Mail.Host = "127.0.0.1" + app.Http.Mail.Port = smtp.Port() + app.Http.Mail.Encryption = "none" + app.Http.Mail.FromAddress = "test@sistema.local" + app.Http.Mail.FromName = "Test Sistema" +} + +// waitFor espera hasta que la condición sea true o timeout. +func waitFor(t *testing.T, label string, timeout time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Errorf("timeout esperando: %s", label) +} + +// ─── Test principal ─────────────────────────────────────────────────────────── + +func TestCicloNotificaciones(t *testing.T) { + db := setupTestDB(t) + smtp := newFakeSMTP(t) + defer smtp.Close() + bootstrapApp(db, smtp) + + // ── Sufijo único para no colisionar con datos reales ────────────────────── + suffix := fmt.Sprintf("_test_%d", time.Now().UnixNano()) + emailTest := fmt.Sprintf("ciclo%s@test.local", suffix) + + // ── Crear datos de prueba ───────────────────────────────────────────────── + + plantBienvenida := models.PlantillaCorreo{ + Nombre: "TEST Bienvenida" + suffix, + Asunto: "Bienvenido al sistema", + CuerpoHTML: "

Hola {{.ClienteNombre}}, bienvenido a {{.ClienteEmpresa}}.

", + Tipo: "bienvenida", + } + if err := db.Create(&plantBienvenida).Error; err != nil { + t.Fatalf("crear plantilla bienvenida: %v", err) + } + + plantPago := models.PlantillaCorreo{ + Nombre: "TEST Pago Confirmado" + suffix, + Asunto: "Tu pago fue recibido", + CuerpoHTML: "

Hola {{.ClienteNombre}}, recibimos tu pago de ${{.Total}}.

", + Tipo: "pago", + } + if err := db.Create(&plantPago).Error; err != nil { + t.Fatalf("crear plantilla pago: %v", err) + } + + reglaBienvenida := models.NotificacionRegla{ + Nombre: "TEST Regla Bienvenida" + suffix, + TipoEvento: "bienvenida", + PlantillaID: plantBienvenida.ID, + Activo: true, + AplicaA: "todos", + PasarelaEnlace: "ninguna", // evita llamadas a Bold/dLocal + } + if err := db.Create(®laBienvenida).Error; err != nil { + t.Fatalf("crear regla bienvenida: %v", err) + } + + reglaPago := models.NotificacionRegla{ + Nombre: "TEST Regla Pago" + suffix, + TipoEvento: "pago_recibido", + PlantillaID: plantPago.ID, + Activo: true, + AplicaA: "todos", + PasarelaEnlace: "ninguna", + } + if err := db.Create(®laPago).Error; err != nil { + t.Fatalf("crear regla pago: %v", err) + } + + cliente := models.Cliente{ + Nombre: "Cliente Test" + suffix, + Email: emailTest, + Empresa: "Empresa Test", + Activo: true, + } + if err := db.Create(&cliente).Error; err != nil { + t.Fatalf("crear cliente: %v", err) + } + + now := time.Now() + contrato := models.Contrato{ + ClienteID: cliente.ID, + Cliente: cliente, + FechaInicio: now, + FechaVencimiento: now.AddDate(1, 0, 0), + PrecioAcordado: 150.0, + Estado: "activo", + } + if err := db.Create(&contrato).Error; err != nil { + t.Fatalf("crear contrato: %v", err) + } + contrato.Cliente = cliente // relación en memoria para el servicio + + // ── Cleanup al finalizar ───────────────────────────────────────────────── + t.Cleanup(func() { + // notificaciones_log → contratos → clientes → reglas → plantillas + db.Unscoped().Where("cliente_id = ?", cliente.ID).Delete(&models.NotificacionLog{}) + db.Unscoped().Delete(&contrato) + db.Unscoped().Delete(&cliente) + db.Unscoped().Delete(®laBienvenida) + db.Unscoped().Delete(®laPago) + db.Unscoped().Delete(&plantBienvenida) + db.Unscoped().Delete(&plantPago) + }) + + // ══════════════════════════════════════════════════════════════════════════ + // PASO 1 — Correo de bienvenida + // ══════════════════════════════════════════════════════════════════════════ + t.Run("paso1_bienvenida", func(t *testing.T) { + countBefore := smtp.Count() + + err := services.EnviarNotificacionGrupo( + ®laBienvenida, + &cliente, + []models.Contrato{contrato}, + ) + if err != nil { + t.Fatalf("EnviarNotificacionGrupo (bienvenida): %v", err) + } + + // Esperar entrega al fake SMTP + waitFor(t, "email de bienvenida recibido", 3*time.Second, func() bool { + return smtp.Count() > countBefore + }) + + emails := smtp.Emails() + ultimo := emails[len(emails)-1] + if ultimo.To != emailTest { + t.Errorf("destinatario: esperado %q, recibido %q", emailTest, ultimo.To) + } + if !strings.Contains(ultimo.Body, "Bienvenido") || !strings.Contains(ultimo.Body, cliente.Nombre) { + t.Errorf("cuerpo del email no contiene el contenido esperado.\nBody:\n%s", ultimo.Body) + } + + // Verificar log en DB + var logCount int64 + db.Model(&models.NotificacionLog{}). + Where("cliente_id = ? AND regla_id = ? AND estado = 'enviado'", cliente.ID, reglaBienvenida.ID). + Count(&logCount) + if logCount == 0 { + t.Error("no se encontró log de bienvenida con estado='enviado' en la DB") + } + t.Logf("✓ Email de bienvenida enviado a %s — logs en DB: %d", emailTest, logCount) + }) + + // ══════════════════════════════════════════════════════════════════════════ + // PASO 2 — Recibir pago → marcar contrato → enviar confirmación + // ══════════════════════════════════════════════════════════════════════════ + t.Run("paso2_pago_confirmado", func(t *testing.T) { + // 2a. Simular llegada del webhook: marcar contrato como pagado + if err := models.MarcarContratoPagado(contrato.ID); err != nil { + t.Fatalf("MarcarContratoPagado: %v", err) + } + + // 2b. Verificar campo en DB + var c models.Contrato + db.Select("pago_confirmado, fecha_pago").First(&c, contrato.ID) + if !c.PagoConfirmado { + t.Error("contrato.pago_confirmado debería ser true tras MarcarContratoPagado") + } + if c.FechaPago == nil { + t.Error("contrato.fecha_pago debería tener valor") + } + t.Logf("✓ Contrato %d marcado como pagado en %s", contrato.ID, c.FechaPago.Format("02/01/2006 15:04")) + + // 2c. Disparar correo de confirmación (igual que hace el webhook) + countBefore := smtp.Count() + services.EnviarCorreoConfirmacionPago(contrato.ID) + + waitFor(t, "email de confirmación de pago recibido", 3*time.Second, func() bool { + return smtp.Count() > countBefore + }) + + emails := smtp.Emails() + ultimo := emails[len(emails)-1] + if ultimo.To != emailTest { + t.Errorf("destinatario pago: esperado %q, recibido %q", emailTest, ultimo.To) + } + if !strings.Contains(ultimo.Body, "pago") && !strings.Contains(strings.ToLower(ultimo.Body), "pago") { + t.Logf("advertencia: cuerpo no contiene 'pago' — puede ser OK según la plantilla.\nBody:\n%s", ultimo.Body) + } + + // Verificar log en DB + var logCount int64 + db.Model(&models.NotificacionLog{}). + Where("cliente_id = ? AND regla_id = ? AND estado = 'enviado'", cliente.ID, reglaPago.ID). + Count(&logCount) + if logCount == 0 { + t.Error("no se encontró log de confirmación de pago con estado='enviado' en la DB") + } + t.Logf("✓ Email de confirmación de pago enviado a %s — logs en DB: %d", emailTest, logCount) + }) + + // ══════════════════════════════════════════════════════════════════════════ + // PASO 3 — Idempotencia: un segundo intento NO debe reenviar + // ══════════════════════════════════════════════════════════════════════════ + t.Run("paso3_idempotencia", func(t *testing.T) { + countBefore := smtp.Count() + + // Llamar de nuevo como si llegara un webhook duplicado + services.EnviarCorreoConfirmacionPago(contrato.ID) + time.Sleep(300 * time.Millisecond) // dar tiempo a que lo intente si hubiera bug + + if smtp.Count() > countBefore { + t.Errorf("YaEnviadoHoy debería bloquear el reenvío, pero se enviaron %d email(s) extra", + smtp.Count()-countBefore) + } else { + t.Logf("✓ Idempotencia OK — no se reenvió el correo de confirmación") + } + }) + + // ══════════════════════════════════════════════════════════════════════════ + // RESUMEN + // ══════════════════════════════════════════════════════════════════════════ + t.Logf("\nResumen SMTP fake — total emails recibidos: %d", smtp.Count()) + for i, e := range smtp.Emails() { + preview := e.Body + if len(preview) > 80 { + preview = preview[:80] + "..." + } + t.Logf(" [%d] To:%s — %s", i+1, e.To, preview) + } +} diff --git a/resources/views/renovaciones/smtp.html b/resources/views/renovaciones/smtp.html index 40e0eae..2fbd1f6 100644 --- a/resources/views/renovaciones/smtp.html +++ b/resources/views/renovaciones/smtp.html @@ -74,6 +74,56 @@ Nota: Al guardar, la configuración se aplica inmediatamente sin necesidad de reiniciar el servidor. La contraseña se almacena cifrada con AES-256. + + +
+
+ + + +

Diagnóstico — ciclo completo de notificaciones

+
+
+

Crea datos temporales, ejecuta el ciclo completo (bienvenida → pago recibido → idempotencia) usando tu SMTP real y los destruye al finalizar.

+
+ + +
+ + +
+ +
+ + + +
+ +
+ +
+
+
+
{ Alpine.data('app', () => ({ loading: false, testEmail: '', + diagEmail: '', diagLoading: false, diagPasos: [], diagOK: false, diagResumen: '', form: { host:'', port:587, username:'', password:'', encryption:'tls', from_name:'', from_address:'' }, toast: { show:false, msg:'', type:'ok' }, @@ -124,6 +175,27 @@ document.addEventListener('alpine:init', () => { this.loading = false; }, + async runDiag() { + if (!this.diagEmail) { this.showToast('Ingresa un email para el diagnóstico', 'error'); return; } + this.diagLoading = true; + this.diagPasos = []; + this.diagResumen = ''; + try { + const { data } = await axios.post('/app/api/diagnostico/ciclo-notificaciones', { test_email: this.diagEmail }); + this.diagPasos = data.pasos || []; + this.diagOK = data.ok; + this.diagResumen = data.resumen || (data.ok ? 'Ciclo completado sin errores' : 'Ciclo con errores'); + if (data.ok) this.showToast('✓ Test del ciclo completado exitosamente'); + else this.showToast('Test completado con errores — ver detalle', 'error'); + } catch(e) { + this.diagPasos = []; + this.diagOK = false; + this.diagResumen = e.response?.data?.error || 'Error al ejecutar el test'; + this.showToast(this.diagResumen, 'error'); + } + this.diagLoading = false; + }, + showToast(msg, type='ok') { this.toast = { show:true, msg, type }; setTimeout(() => this.toast.show = false, 3500); diff --git a/rest/controllers/diagnostico_controller.go b/rest/controllers/diagnostico_controller.go new file mode 100644 index 0000000..582c806 --- /dev/null +++ b/rest/controllers/diagnostico_controller.go @@ -0,0 +1,258 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +type PasoDiagnostico struct { + Paso string `json:"paso"` + OK bool `json:"ok"` + Mensaje string `json:"mensaje"` + Ms int64 `json:"ms"` +} + +// EjecutarTestCiclo ejecuta el ciclo completo de notificaciones sobre datos temporales: +// 1. Envío de bienvenida +// 2. Marcar contrato como pagado + enviar confirmación de pago +// 3. Idempotencia (no reenviar si ya se envió hoy) +// +// Crea y destruye los datos de prueba dentro de la misma transacción de limpieza. +// POST /app/api/diagnostico/ciclo-notificaciones +func EjecutarTestCiclo(c *fiber.Ctx) error { + var body struct { + TestEmail string `json:"test_email"` + } + if err := c.BodyParser(&body); err != nil || body.TestEmail == "" { + return c.Status(400).JSON(fiber.Map{"error": "test_email requerido"}) + } + + db := app.Http.Database.DB + suffix := fmt.Sprintf("_diag_%d", time.Now().UnixNano()) + pasos := []PasoDiagnostico{} + allOK := true + + paso := func(nombre string, fn func() error) { + t0 := time.Now() + err := fn() + ms := time.Since(t0).Milliseconds() + ok := err == nil + msg := "OK" + if !ok { + msg = err.Error() + allOK = false + } + pasos = append(pasos, PasoDiagnostico{Paso: nombre, OK: ok, Mensaje: msg, Ms: ms}) + } + + // ── Datos temporales ────────────────────────────────────────────────────── + var plantBienvenida, plantPago models.PlantillaCorreo + var reglaBienvenida, reglaPago models.NotificacionRegla + var cliente models.Cliente + var contrato models.Contrato + + // ── Cleanup siempre al finalizar ────────────────────────────────────────── + defer func() { + if contrato.ID > 0 { + db.Unscoped().Where("cliente_id = ?", cliente.ID).Delete(&models.NotificacionLog{}) + db.Unscoped().Delete(&contrato) + } + if cliente.ID > 0 { + db.Unscoped().Delete(&cliente) + } + if reglaBienvenida.ID > 0 { + db.Unscoped().Delete(®laBienvenida) + } + if reglaPago.ID > 0 { + db.Unscoped().Delete(®laPago) + } + if plantBienvenida.ID > 0 { + db.Unscoped().Delete(&plantBienvenida) + } + if plantPago.ID > 0 { + db.Unscoped().Delete(&plantPago) + } + }() + + // ── SETUP: crear datos de prueba ────────────────────────────────────────── + paso("setup: plantilla bienvenida", func() error { + plantBienvenida = models.PlantillaCorreo{ + Nombre: "DIAG Bienvenida" + suffix, + Asunto: "[TEST] Bienvenido al sistema", + CuerpoHTML: "

Hola {{.ClienteNombre}}, bienvenido. Tu vencimiento es {{.FechaVencimiento}}.

", + Tipo: "bienvenida", + } + return db.Create(&plantBienvenida).Error + }) + + paso("setup: plantilla pago confirmado", func() error { + plantPago = models.PlantillaCorreo{ + Nombre: "DIAG Pago Confirmado" + suffix, + Asunto: "[TEST] Tu pago fue recibido", + CuerpoHTML: "

Hola {{.ClienteNombre}}, recibimos tu pago de ${{.Total}}. ¡Gracias!

", + Tipo: "pago", + } + return db.Create(&plantPago).Error + }) + + paso("setup: regla bienvenida", func() error { + if plantBienvenida.ID == 0 { + return fmt.Errorf("plantilla bienvenida no creada") + } + reglaBienvenida = models.NotificacionRegla{ + Nombre: "DIAG Regla Bienvenida" + suffix, + TipoEvento: "bienvenida", + PlantillaID: plantBienvenida.ID, + Activo: true, + AplicaA: "todos", + PasarelaEnlace: "ninguna", + } + return db.Create(®laBienvenida).Error + }) + + paso("setup: regla pago_recibido", func() error { + if plantPago.ID == 0 { + return fmt.Errorf("plantilla pago no creada") + } + reglaPago = models.NotificacionRegla{ + Nombre: "DIAG Regla Pago" + suffix, + TipoEvento: "pago_recibido", + PlantillaID: plantPago.ID, + Activo: true, + AplicaA: "todos", + PasarelaEnlace: "ninguna", + } + return db.Create(®laPago).Error + }) + + paso("setup: cliente y contrato temporales", func() error { + cliente = models.Cliente{ + Nombre: "DIAG Cliente" + suffix, + Email: body.TestEmail, + Empresa: "Diagnóstico Sistema", + Activo: true, + } + if err := db.Create(&cliente).Error; err != nil { + return err + } + now := time.Now() + contrato = models.Contrato{ + ClienteID: cliente.ID, + Cliente: cliente, + FechaInicio: now, + FechaVencimiento: now.AddDate(1, 0, 0), + PrecioAcordado: 99.00, + Estado: "activo", + } + if err := db.Create(&contrato).Error; err != nil { + return err + } + contrato.Cliente = cliente + return nil + }) + + // Si el setup falló, no continuar con los pasos funcionales + if !allOK { + return c.JSON(fiber.Map{"pasos": pasos, "ok": false, "resumen": "Setup falló — ver pasos anteriores"}) + } + + // ── PASO 1: Enviar correo de bienvenida ─────────────────────────────────── + paso("paso 1 — envío de bienvenida", func() error { + return services.EnviarNotificacionGrupo(®laBienvenida, &cliente, []models.Contrato{contrato}) + }) + + // Verificar log en DB + paso("paso 1 — verificar log en historial", func() error { + var count int64 + db.Model(&models.NotificacionLog{}). + Where("cliente_id = ? AND regla_id = ? AND estado = 'enviado'", cliente.ID, reglaBienvenida.ID). + Count(&count) + if count == 0 { + return fmt.Errorf("no se encontró log con estado='enviado' para la regla bienvenida") + } + return nil + }) + + // ── PASO 2: Simular recepción de pago ───────────────────────────────────── + paso("paso 2 — marcar contrato como pagado", func() error { + return models.MarcarContratoPagado(contrato.ID) + }) + + paso("paso 2 — verificar pago_confirmado en DB", func() error { + var c models.Contrato + db.Select("pago_confirmado, fecha_pago").First(&c, contrato.ID) + if !c.PagoConfirmado { + return fmt.Errorf("pago_confirmado sigue siendo false tras MarcarContratoPagado") + } + if c.FechaPago == nil { + return fmt.Errorf("fecha_pago es nil tras MarcarContratoPagado") + } + return nil + }) + + paso("paso 2 — envío de confirmación de pago", func() error { + // EnviarCorreoConfirmacionPago es void — usar EnviarNotificacionGrupo directamente + // para capturar el error (la función void loguea pero no retorna) + reloadContrato, err := models.GetContratoByID(contrato.ID) + if err != nil { + return err + } + return services.EnviarNotificacionGrupo(®laPago, &cliente, []models.Contrato{*reloadContrato}) + }) + + paso("paso 2 — verificar log de pago en historial", func() error { + var count int64 + db.Model(&models.NotificacionLog{}). + Where("cliente_id = ? AND regla_id = ? AND estado = 'enviado'", cliente.ID, reglaPago.ID). + Count(&count) + if count == 0 { + return fmt.Errorf("no se encontró log con estado='enviado' para la regla pago_recibido") + } + return nil + }) + + // ── PASO 3: Idempotencia ────────────────────────────────────────────────── + paso("paso 3 — idempotencia (no reenviar si ya enviado hoy)", func() error { + // Verificar que YaEnviadoHoy retorna true para ambas reglas + if !models.YaEnviadoHoy(cliente.ID, reglaBienvenida.ID) { + return fmt.Errorf("YaEnviadoHoy debería ser true para bienvenida, pero retornó false") + } + if !models.YaEnviadoHoy(cliente.ID, reglaPago.ID) { + return fmt.Errorf("YaEnviadoHoy debería ser true para pago_recibido, pero retornó false") + } + return nil + }) + + // Contar emails en historial para el resumen + var totalLogs int64 + db.Model(&models.NotificacionLog{}).Where("cliente_id = ?", cliente.ID).Count(&totalLogs) + + // Serializar IDs para debug + idsJSON, _ := json.Marshal(map[string]uint{ + "cliente_id": cliente.ID, + "contrato_id": contrato.ID, + "regla_bienvenida_id": reglaBienvenida.ID, + "regla_pago_id": reglaPago.ID, + "plantilla_bienvenida": plantBienvenida.ID, + "plantilla_pago": plantPago.ID, + }) + + resumen := fmt.Sprintf("Ciclo completado — %d emails enviados al historial, destinatario: %s", + totalLogs, body.TestEmail) + if !allOK { + resumen = "Ciclo con errores — revisar pasos" + } + + return c.JSON(fiber.Map{ + "pasos": pasos, + "ok": allOK, + "resumen": resumen, + "ids_tmp": string(idsJSON), + }) +} diff --git a/rest/routes/renovaciones.go b/rest/routes/renovaciones.go index 5c02a58..7a293a3 100644 --- a/rest/routes/renovaciones.go +++ b/rest/routes/renovaciones.go @@ -64,6 +64,9 @@ func RenovacionesRoutes(protected fiber.Router) { protected.Post("/api/smtp-config", controllers.SaveSmtpConfig) protected.Post("/api/smtp-config/test", controllers.TestSmtpConfig) + // ─── Diagnóstico ────────────────────────────────────────────────── + protected.Post("/api/diagnostico/ciclo-notificaciones", controllers.EjecutarTestCiclo) + // ─── Admin: seed de módulos (idempotente) ───────────────────────── protected.Post("/api/admin/seed-renovaciones", func(c *fiber.Ctx) error { migrations.SeedRenovaciones()