diff --git a/functions/infra/email_build_test.go b/functions/infra/email_build_test.go new file mode 100644 index 00000000..c3dcae0e --- /dev/null +++ b/functions/infra/email_build_test.go @@ -0,0 +1,125 @@ +package infra + +import ( + "testing" +) + +func TestEmailBuildHTML(t *testing.T) { + t.Run("construye mensaje html con campos basicos", func(t *testing.T) { + msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "Hola", "Hola") + if msg.From != "alice@example.com" { + t.Errorf("From: got %q, want %q", msg.From, "alice@example.com") + } + if len(msg.To) != 1 || msg.To[0] != "bob@example.com" { + t.Errorf("To: got %v, want [bob@example.com]", msg.To) + } + if msg.Subject != "Hola" { + t.Errorf("Subject: got %q, want %q", msg.Subject, "Hola") + } + if msg.BodyHTML != "Hola" { + t.Errorf("BodyHTML: got %q, want %q", msg.BodyHTML, "Hola") + } + }) + + t.Run("copia el slice to para evitar aliasing", func(t *testing.T) { + to := []string{"a@example.com", "b@example.com"} + msg := EmailBuildHTML("x@x.com", to, "s", "h") + to[0] = "mutated@example.com" + if msg.To[0] == "mutated@example.com" { + t.Error("To slice was aliased — mutation affected the message") + } + }) + + t.Run("campos no usados quedan vacios", func(t *testing.T) { + msg := EmailBuildHTML("f@example.com", []string{"t@example.com"}, "s", "h") + if msg.BodyText != "" { + t.Errorf("BodyText should be empty, got %q", msg.BodyText) + } + if len(msg.CC) != 0 { + t.Errorf("CC should be empty, got %v", msg.CC) + } + if len(msg.BCC) != 0 { + t.Errorf("BCC should be empty, got %v", msg.BCC) + } + if len(msg.Attachments) != 0 { + t.Errorf("Attachments should be empty, got %v", msg.Attachments) + } + }) +} + +func TestEmailBuildText(t *testing.T) { + t.Run("construye mensaje text con campos basicos", func(t *testing.T) { + msg := EmailBuildText("alice@example.com", []string{"bob@example.com"}, "Alerta", "Servidor caido") + if msg.BodyText != "Servidor caido" { + t.Errorf("BodyText: got %q, want %q", msg.BodyText, "Servidor caido") + } + if msg.From != "alice@example.com" { + t.Errorf("From: got %q, want %q", msg.From, "alice@example.com") + } + if msg.Subject != "Alerta" { + t.Errorf("Subject: got %q, want %q", msg.Subject, "Alerta") + } + }) + + t.Run("body html queda vacio", func(t *testing.T) { + msg := EmailBuildText("f@x.com", []string{"t@x.com"}, "s", "body") + if msg.BodyHTML != "" { + t.Errorf("BodyHTML should be empty, got %q", msg.BodyHTML) + } + }) +} + +func TestEmailWithAttachment(t *testing.T) { + t.Run("agrega adjunto sin mutar el original", func(t *testing.T) { + orig := EmailBuildHTML("a@example.com", []string{"b@example.com"}, "s", "h") + att := EmailAttachment{Filename: "f.pdf", ContentType: "application/pdf", Data: []byte("data")} + msg2 := EmailWithAttachment(orig, att) + + if len(orig.Attachments) != 0 { + t.Errorf("original was mutated: got %d attachments, want 0", len(orig.Attachments)) + } + if len(msg2.Attachments) != 1 { + t.Errorf("got %d attachments, want 1", len(msg2.Attachments)) + } + if msg2.Attachments[0].Filename != "f.pdf" { + t.Errorf("Filename: got %q, want %q", msg2.Attachments[0].Filename, "f.pdf") + } + }) + + t.Run("multiples adjuntos se acumulan", func(t *testing.T) { + msg := EmailBuildHTML("a@x.com", []string{"b@x.com"}, "s", "h") + msg = EmailWithAttachment(msg, EmailAttachment{Filename: "a1.txt", ContentType: "text/plain", Data: []byte("1")}) + msg = EmailWithAttachment(msg, EmailAttachment{Filename: "a2.txt", ContentType: "text/plain", Data: []byte("2")}) + if len(msg.Attachments) != 2 { + t.Errorf("got %d attachments, want 2", len(msg.Attachments)) + } + }) + + t.Run("copia todos los campos del mensaje", func(t *testing.T) { + orig := EmailMessage{ + From: "a@x.com", + To: []string{"b@x.com"}, + CC: []string{"c@x.com"}, + BCC: []string{"d@x.com"}, + Subject: "Test", + BodyHTML: "

Hi

", + BodyText: "Hi", + Headers: map[string]string{"X-Test": "value"}, + } + att := EmailAttachment{Filename: "f.png", ContentType: "image/png", Data: []byte("img")} + msg2 := EmailWithAttachment(orig, att) + + if msg2.From != orig.From { + t.Errorf("From mismatch: %q vs %q", msg2.From, orig.From) + } + if msg2.Subject != orig.Subject { + t.Errorf("Subject mismatch: %q vs %q", msg2.Subject, orig.Subject) + } + if len(msg2.CC) != 1 || msg2.CC[0] != "c@x.com" { + t.Errorf("CC not copied: %v", msg2.CC) + } + if msg2.Headers["X-Test"] != "value" { + t.Errorf("Headers not copied: %v", msg2.Headers) + } + }) +} diff --git a/functions/infra/email_template_render_test.go b/functions/infra/email_template_render_test.go new file mode 100644 index 00000000..598badad --- /dev/null +++ b/functions/infra/email_template_render_test.go @@ -0,0 +1,46 @@ +package infra + +import ( + "strings" + "testing" +) + +func TestEmailTemplateRender(t *testing.T) { + t.Run("renderiza template simple con datos", func(t *testing.T) { + got, err := EmailTemplateRender("Hola {{.Name}}", map[string]any{"Name": "Alice"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "Hola Alice" { + t.Errorf("got %q, want %q", got, "Hola Alice") + } + }) + + t.Run("sustituye multiples variables", func(t *testing.T) { + tmpl := "Pedido {{.OrderID}} para {{.Customer}} listo." + got, err := EmailTemplateRender(tmpl, map[string]any{"OrderID": "ORD-42", "Customer": "Bob"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(got, "ORD-42") || !strings.Contains(got, "Bob") { + t.Errorf("got %q, expected OrderID and Customer", got) + } + }) + + t.Run("error en template invalido", func(t *testing.T) { + _, err := EmailTemplateRender("{{.Unclosed", nil) + if err == nil { + t.Error("expected error for invalid template, got nil") + } + }) + + t.Run("template vacio retorna string vacio", func(t *testing.T) { + got, err := EmailTemplateRender("", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("got %q, want empty string", got) + } + }) +} diff --git a/functions/infra/smtp_connect_test.go b/functions/infra/smtp_connect_test.go new file mode 100644 index 00000000..dfdccf78 --- /dev/null +++ b/functions/infra/smtp_connect_test.go @@ -0,0 +1,120 @@ +package infra + +import ( + "bufio" + "fmt" + "net" + "strings" + "testing" +) + +// mockSMTPServer starts a minimal SMTP server on a random port that greets +// and accepts any commands. Returns the listener address and a function to stop it. +func mockSMTPServer(t *testing.T) (addr string, stop func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("mock smtp listen: %v", err) + } + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleMockSMTP(conn) + } + }() + return ln.Addr().String(), func() { ln.Close() } +} + +// handleMockSMTP is a minimal SMTP session handler (no TLS, no auth required). +func handleMockSMTP(conn net.Conn) { + defer conn.Close() + w := bufio.NewWriter(conn) + r := bufio.NewReader(conn) + fmt.Fprintf(w, "220 mock SMTP ready\r\n") + w.Flush() + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSpace(line) + upper := strings.ToUpper(line) + switch { + case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"): + fmt.Fprintf(w, "250 mock\r\n") + case strings.HasPrefix(upper, "MAIL FROM"): + fmt.Fprintf(w, "250 OK\r\n") + case strings.HasPrefix(upper, "RCPT TO"): + fmt.Fprintf(w, "250 OK\r\n") + case strings.HasPrefix(upper, "DATA"): + fmt.Fprintf(w, "354 End with .\r\n") + w.Flush() + // read until ".\r\n" + for { + dl, derr := r.ReadString('\n') + if derr != nil { + return + } + if strings.TrimSpace(dl) == "." { + break + } + } + fmt.Fprintf(w, "250 OK\r\n") + case strings.HasPrefix(upper, "QUIT"): + fmt.Fprintf(w, "221 Bye\r\n") + w.Flush() + return + default: + fmt.Fprintf(w, "502 Command not recognized\r\n") + } + w.Flush() + } +} + +func TestSMTPConnect(t *testing.T) { + t.Run("conecta sin cifrado a servidor mock", func(t *testing.T) { + addr, stop := mockSMTPServer(t) + defer stop() + + host, portStr, _ := net.SplitHostPort(addr) + port := 0 + fmt.Sscanf(portStr, "%d", &port) + + cfg := SMTPConfig{ + Host: host, + Port: port, + TLSMode: "", // no encryption + } + client, err := SMTPConnect(cfg) + if err != nil { + t.Fatalf("SMTPConnect: %v", err) + } + client.Quit() + }) + + t.Run("error si el servidor no existe", func(t *testing.T) { + // Use a port that is listening but immediately closes (RST) — we start + // and immediately close a listener so the port is known-closed. + ln2, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("setup: %v", err) + } + h2, p2str, _ := net.SplitHostPort(ln2.Addr().String()) + ln2.Close() // now nothing listens — connections get refused + p2 := 0 + fmt.Sscanf(p2str, "%d", &p2) + + cfg := SMTPConfig{ + Host: h2, + Port: p2, + TLSMode: "", + } + _, connErr := SMTPConnect(cfg) + if connErr == nil { + t.Error("expected error for refused connection, got nil") + } + }) +} diff --git a/functions/infra/smtp_send_test.go b/functions/infra/smtp_send_test.go new file mode 100644 index 00000000..215cc01e --- /dev/null +++ b/functions/infra/smtp_send_test.go @@ -0,0 +1,152 @@ +package infra + +import ( + "fmt" + "net" + "strings" + "testing" +) + +// startMockSMTPForSend starts a mock SMTP server and returns host/port separately. +func startMockSMTPForSend(t *testing.T) (host string, port int, stop func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("mock smtp listen: %v", err) + } + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleMockSMTP(conn) + } + }() + h, ps, _ := net.SplitHostPort(ln.Addr().String()) + p := 0 + fmt.Sscanf(ps, "%d", &p) + return h, p, func() { ln.Close() } +} + + +func TestSMTPSend(t *testing.T) { + t.Run("envia mensaje texto plano via mock smtp", func(t *testing.T) { + host, port, stop := startMockSMTPForSend(t) + defer stop() + + cfg := SMTPConfig{Host: host, Port: port, TLSMode: ""} + client, err := SMTPConnect(cfg) + if err != nil { + t.Fatalf("SMTPConnect: %v", err) + } + defer client.Quit() + + msg := EmailBuildText("alice@example.com", []string{"bob@example.com"}, "Test", "Hello Bob") + if err := SMTPSend(client, msg); err != nil { + t.Fatalf("SMTPSend: %v", err) + } + }) + + t.Run("envia mensaje html via mock smtp", func(t *testing.T) { + host, port, stop := startMockSMTPForSend(t) + defer stop() + + cfg := SMTPConfig{Host: host, Port: port, TLSMode: ""} + client, err := SMTPConnect(cfg) + if err != nil { + t.Fatalf("SMTPConnect: %v", err) + } + defer client.Quit() + + msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "HTML Test", "Hello") + if err := SMTPSend(client, msg); err != nil { + t.Fatalf("SMTPSend: %v", err) + } + }) + + t.Run("envia con adjunto via mock smtp", func(t *testing.T) { + host, port, stop := startMockSMTPForSend(t) + defer stop() + + cfg := SMTPConfig{Host: host, Port: port, TLSMode: ""} + client, err := SMTPConnect(cfg) + if err != nil { + t.Fatalf("SMTPConnect: %v", err) + } + defer client.Quit() + + msg := EmailBuildText("alice@example.com", []string{"bob@example.com"}, "Att Test", "See attachment") + att := EmailAttachment{Filename: "data.txt", ContentType: "text/plain", Data: []byte("file content")} + msg = EmailWithAttachment(msg, att) + + if err := SMTPSend(client, msg); err != nil { + t.Fatalf("SMTPSend with attachment: %v", err) + } + }) +} + +// TestExtractAddr tests the internal extractAddr helper. +func TestExtractAddr(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"Alice ", "alice@example.com"}, + {"bob@example.com", "bob@example.com"}, + {" carol@example.com ", "carol@example.com"}, + } + for _, c := range cases { + got := extractAddr(c.input) + if got != c.want { + t.Errorf("extractAddr(%q) = %q, want %q", c.input, got, c.want) + } + } +} + +// TestBuildMIME tests the MIME builder with different body combinations. +func TestBuildMIME(t *testing.T) { + t.Run("solo texto plano", func(t *testing.T) { + msg := EmailBuildText("a@a.com", []string{"b@b.com"}, "Subj", "Hello plain") + body, err := buildMIME(msg) + if err != nil { + t.Fatalf("buildMIME: %v", err) + } + s := string(body) + if !strings.Contains(s, "text/plain") { + t.Errorf("expected text/plain in MIME, got:\n%s", s) + } + if !strings.Contains(s, "Hello plain") { + t.Errorf("expected body text in MIME, got:\n%s", s) + } + }) + + t.Run("solo html", func(t *testing.T) { + msg := EmailBuildHTML("a@a.com", []string{"b@b.com"}, "Subj", "Bold") + body, err := buildMIME(msg) + if err != nil { + t.Fatalf("buildMIME: %v", err) + } + s := string(body) + if !strings.Contains(s, "text/html") { + t.Errorf("expected text/html in MIME, got:\n%s", s) + } + }) + + t.Run("multipart con adjunto", func(t *testing.T) { + msg := EmailBuildText("a@a.com", []string{"b@b.com"}, "Subj", "body") + att := EmailAttachment{Filename: "f.pdf", ContentType: "application/pdf", Data: []byte("pdf")} + msg = EmailWithAttachment(msg, att) + body, err := buildMIME(msg) + if err != nil { + t.Fatalf("buildMIME: %v", err) + } + s := string(body) + if !strings.Contains(s, "multipart/mixed") { + t.Errorf("expected multipart/mixed in MIME, got:\n%s", s) + } + if !strings.Contains(s, "f.pdf") { + t.Errorf("expected attachment filename in MIME, got:\n%s", s) + } + }) +}