//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) } }