up
This commit is contained in:
@@ -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 <CR><LF>.<CR><LF>")
|
||||
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: "<p>Hola {{.ClienteNombre}}, bienvenido a {{.ClienteEmpresa}}.</p>",
|
||||
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: "<p>Hola {{.ClienteNombre}}, recibimos tu pago de ${{.Total}}.</p>",
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,56 @@
|
||||
<strong>Nota:</strong> Al guardar, la configuración se aplica inmediatamente sin necesidad de reiniciar el servidor.
|
||||
La contraseña se almacena cifrada con AES-256.
|
||||
</div>
|
||||
|
||||
<!-- ─── Diagnóstico del ciclo completo ─────────────────────────────── -->
|
||||
<div class="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="bg-gray-50 px-5 py-3 border-b flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
|
||||
</svg>
|
||||
<h2 class="text-sm font-semibold text-gray-700">Diagnóstico — ciclo completo de notificaciones</h2>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-xs text-gray-500">Crea datos temporales, ejecuta el ciclo completo (bienvenida → pago recibido → idempotencia) usando tu SMTP real y los destruye al finalizar.</p>
|
||||
<div class="flex gap-2">
|
||||
<input type="email" x-model="diagEmail" placeholder="Email donde recibirás los correos de prueba"
|
||||
class="border rounded px-3 py-1.5 text-sm flex-1" />
|
||||
<button @click="runDiag()"
|
||||
:disabled="diagLoading || !diagEmail"
|
||||
class="px-4 py-1.5 bg-indigo-600 text-white rounded text-sm font-medium disabled:opacity-40 flex items-center gap-1.5">
|
||||
<svg x-show="!diagLoading" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z"/></svg>
|
||||
<svg x-show="diagLoading" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
<span x-text="diagLoading ? 'Ejecutando…' : 'Ejecutar test'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resultado -->
|
||||
<div x-show="diagPasos.length > 0">
|
||||
<!-- Resumen -->
|
||||
<div class="flex items-center gap-2 mb-3 px-3 py-2 rounded text-sm font-medium"
|
||||
:class="diagOK ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'">
|
||||
<svg x-show="diagOK" class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
<svg x-show="!diagOK" class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/></svg>
|
||||
<span x-text="diagResumen"></span>
|
||||
</div>
|
||||
<!-- Pasos -->
|
||||
<div class="border rounded divide-y text-xs">
|
||||
<template x-for="(p, i) in diagPasos" :key="i">
|
||||
<div class="flex items-start gap-2 px-3 py-2"
|
||||
:class="p.ok ? 'bg-white' : 'bg-red-50'">
|
||||
<svg x-show="p.ok" class="w-3.5 h-3.5 text-green-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
<svg x-show="!p.ok" class="w-3.5 h-3.5 text-red-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-medium" x-text="p.paso"></span>
|
||||
<span x-show="!p.ok" class="block text-red-600 mt-0.5" x-text="p.mensaje"></span>
|
||||
</div>
|
||||
<span class="text-gray-400 flex-shrink-0" x-text="p.ms + 'ms'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="toast.show" x-cloak x-transition
|
||||
@@ -87,6 +137,7 @@ document.addEventListener('alpine:init', () => {
|
||||
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);
|
||||
|
||||
@@ -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: "<p>Hola {{.ClienteNombre}}, bienvenido. Tu vencimiento es {{.FechaVencimiento}}.</p>",
|
||||
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: "<p>Hola {{.ClienteNombre}}, recibimos tu pago de ${{.Total}}. ¡Gracias!</p>",
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user