437 lines
15 KiB
Go
437 lines
15 KiB
Go
//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)
|
|
}
|
|
}
|