package notify import ( "bufio" "context" "encoding/base64" "net" "strconv" "strings" "testing" "time" "dana/internal/domain" ) // fakeSMTP — минимальный SMTP-сервер без TLS и авторизации: принимает одно письмо. func fakeSMTP(t *testing.T) (host string, port int, got <-chan string) { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } t.Cleanup(func() { ln.Close() }) ch := make(chan string, 1) go func() { conn, err := ln.Accept() if err != nil { return } defer conn.Close() r := bufio.NewReader(conn) w := func(s string) { conn.Write([]byte(s + "\r\n")) } w("220 fake") var transcript strings.Builder inData := false for { line, err := r.ReadString('\n') if err != nil { return } if inData { if line == ".\r\n" { inData = false w("250 queued") continue } transcript.WriteString(line) continue } cmd := strings.ToUpper(strings.TrimSpace(line)) switch { case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"): w("250 fake") case strings.HasPrefix(cmd, "MAIL FROM"), strings.HasPrefix(cmd, "RCPT TO"): transcript.WriteString(strings.TrimSpace(line) + "\n") w("250 ok") case cmd == "DATA": inData = true w("354 go") case cmd == "QUIT": w("221 bye") ch <- transcript.String() return default: w("250 ok") } } }() addr := ln.Addr().(*net.TCPAddr) return "127.0.0.1", addr.Port, ch } func TestEmailLeadCreated(t *testing.T) { host, port, got := fakeSMTP(t) e := &Email{Host: host, Port: port, From: "DANA сайт ", To: []string{"admin@example.ru"}, SiteURL: "https://danamedical.ru"} lead := domain.LeadNotice{ ID: 42, FormTitle: "Запись на приём", Name: "Иван\r\nBcc: evil@example.com", Phone: "+7 (928) 000-00-99", Service: "Консультация педиатра", Extra: map[string]any{"date": "2026-09-20", "time": "Утро, 08:00–12:00"}, Message: "Тестовая заявка", PageURL: "/services/detskaya-klinika/priem-pediatra/", CreatedAt: time.Date(2026, 9, 15, 9, 30, 0, 0, time.UTC), } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := e.LeadCreated(ctx, lead); err != nil { t.Fatalf("LeadCreated: %v", err) } var transcript string select { case transcript = <-got: case <-time.After(5 * time.Second): t.Fatal("fake SMTP did not receive message") } if !strings.Contains(transcript, "MAIL FROM:") || !strings.Contains(transcript, "RCPT TO:") { t.Fatalf("envelope not as expected:\n%s", transcript) } // перевод строки в имени не должен стать заголовком письма if strings.Contains(transcript, "\r\nBcc:") { t.Fatalf("header injection:\n%s", transcript) } body := decodeBody(t, transcript) for _, want := range []string{ "Новая заявка № 42", "Время: 15.09.2026 12:30", "Форма: Запись на приём", "Имя: Иван Bcc: evil@example.com", "Телефон: +7 (928) 000-00-99", "Услуга: Консультация педиатра", "Желаемая дата: 2026-09-20", "Удобное время: Утро, 08:00–12:00", "Тестовая заявка", "Все заявки: https://danamedical.ru/admin/", } { if !strings.Contains(body, want) { t.Errorf("body has no %q:\n%s", want, body) } } } func TestFormRecipientsOverrideDefault(t *testing.T) { host, port, got := fakeSMTP(t) e := &Email{Host: host, Port: port, From: "site@example.ru", To: []string{"default@example.ru"}} lead := domain.LeadNotice{ID: 1, Recipients: []string{"doctors@example.ru", "не адрес"}} if err := e.LeadCreated(context.Background(), lead); err != nil { t.Fatal(err) } transcript := <-got if !strings.Contains(transcript, "RCPT TO:") || strings.Contains(transcript, "default@example.ru") { t.Fatalf("recipients of the form must replace defaults:\n%s", transcript) } } func TestNoRecipients(t *testing.T) { e := &Email{Host: "127.0.0.1", Port: 1, From: "site@example.ru"} if err := e.LeadCreated(context.Background(), domain.LeadNotice{ID: 1}); err != errNoRecipients { t.Fatalf("want errNoRecipients, got %v", err) } } func decodeBody(t *testing.T, transcript string) string { t.Helper() parts := strings.SplitN(transcript, "\r\n\r\n", 2) if len(parts) != 2 { t.Fatalf("no body separator:\n%s", transcript) } raw, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(parts[1], "\r\n", "")) if err != nil { t.Fatalf("decode body: %v", err) } return string(raw) } var _ = strconv.Itoa