Email sender implementation (#471)

* email sender initial implementation

* add VerificationMetadata into notify.Request

* multiple review fixes

- customizable verification notification message subject
- clarify autoFlush nature in its commentary
- defer writer.Close in Email.sendEmail instead of calling it manually,
  potentially missing the call if error happened between the creation and
  closing.

* add explanatory commentary to notify.Request structure

* fix TCP connection timout commentary typo

* improve table tests presence

* introduce parallelism to tests

* abstract smtpClientWithMaker away

* fix incorrect Email reference in Email.sendMessages

* naming fixes, remove t.Parallel() from tests

* consistent space in commentary

* rename sendEmail to smtpSend, rearrange variables definitions

* switch Email to create new connection for every Send request

* fix tests for connection-per-submit email sending

* fix tests

* simplify sender object
This commit is contained in:
Dmitry Verkhoturov
2019-12-02 18:59:32 -06:00
committed by Umputun
parent ea8ac08c72
commit b40cb7866b
6 changed files with 657 additions and 6 deletions
+339
View File
@@ -0,0 +1,339 @@
package notify
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/smtp"
"text/template"
"time"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/repeater"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
)
// EmailParams contain settings for email notifications
type EmailParams struct {
From string // From email field
MsgTemplate string // request message template
VerificationSubject string // verification message subject
VerificationTemplate string // verification message template
BufferSize int // email send buffer size
FlushDuration time.Duration // maximum time after which email will me sent, 30s by default
}
// SmtpParams contain settings for smtp server connection
type SmtpParams struct {
Host string // SMTP host
Port int // SMTP port
TLS bool // TLS auth
Username string // user name
Password string // password
TimeOut time.Duration // TCP connection timeout
}
// Email implements notify.Destination for email
type Email struct {
EmailParams
SmtpParams
smtp smtpClientCreator
msgTmpl *template.Template // parsed request message template
verifyTmpl *template.Template // parsed verification message template
}
// default email client implementation
type emailClient struct{ smtpClientCreator }
// smtpClient interface defines subset of net/smtp used by email client
type smtpClient interface {
Mail(string) error
Auth(smtp.Auth) error
Rcpt(string) error
Data() (io.WriteCloser, error)
Quit() error
Close() error
}
// smtpClientCreator interface defines function for creating new smtpClients
type smtpClientCreator interface {
Create(SmtpParams) (smtpClient, error)
}
type emailMessage struct {
from string
to string
message string
}
// msgTmplData store data for message from request template execution
type msgTmplData struct {
From string
To string
Orig string
Link string
PostTitle string
}
// verifyTmplData store data for verification message template execution
type verifyTmplData struct {
User string
Email string
Token string
Site string
}
const (
defaultVerificationSubject = "Email verification"
defaultEmailTimeout = 10 * time.Second
defaultFlushDuration = time.Second * 30
defaultEmailTemplate = `{{.From}}{{if .To}}{{.To}}{{end}}
{{.Orig}}
↦ <a href="{{.Link}}">{{if .PostTitle}}{{.PostTitle}}{{else}}original comment{{end}}</a>
`
defaultEmailVerificationTemplate = `Confirmation for {{.User}} {{.Email}}, site {{.Site}}
Token: {{.Token}}
`
)
// NewEmail makes new Email object, returns it even in case of problems
// (e.MsgTemplate parsing error or error while testing smtp connection by credentials provided in emailParams)
func NewEmail(emailParams EmailParams, smtpParams SmtpParams) (*Email, error) {
var err error
// set up Email emailParams
res := Email{EmailParams: emailParams}
if res.FlushDuration <= 0 {
res.FlushDuration = defaultFlushDuration
}
if res.BufferSize <= 0 {
res.BufferSize = 1
}
if res.MsgTemplate == "" {
res.MsgTemplate = defaultEmailTemplate
}
if res.VerificationTemplate == "" {
res.VerificationTemplate = defaultEmailVerificationTemplate
}
if res.VerificationSubject == "" {
res.VerificationSubject = defaultVerificationSubject
}
// set up SMTP emailParams
res.smtp = &emailClient{}
res.SmtpParams = smtpParams
if res.TimeOut <= 0 {
res.TimeOut = defaultEmailTimeout
}
log.Printf("[DEBUG] Create new email notifier for server %s with user %s, timeout=%s",
res.Host, res.Username, res.TimeOut)
// initialise templates
res.msgTmpl, err = template.New("messageFromRequest").Parse(res.MsgTemplate)
if err != nil {
return &res, errors.Wrapf(err, "can't parse message template")
}
res.verifyTmpl, err = template.New("messageFromRequest").Parse(res.VerificationTemplate)
if err != nil {
return &res, errors.Wrapf(err, "can't parse verification template")
}
// establish test connection
testSmtpClient, err := res.smtp.Create(res.SmtpParams)
if err != nil {
return &res, errors.Wrapf(err, "can't establish test connection")
}
if err = testSmtpClient.Quit(); err != nil {
log.Printf("[WARN] failed to send quit command to %s:%d, %v", res.Host, res.Port, err)
if err = testSmtpClient.Close(); err != nil {
return &res, errors.Wrapf(err, "can't close test smtp connection")
}
}
return &res, err
}
// Send email about reply to Request.Email if it's set, otherwise do nothing and return nil, thread safe
// do not returns sending error, only following:
// 1. (likely impossible) template execution error from email message creation from Request
// 2. message dropped without sending in case of closed ctx
func (e *Email) Send(ctx context.Context, req Request) (err error) {
if req.Email == "" {
// this means we can't send this request via Email
return nil
}
select {
case <-ctx.Done():
return errors.Errorf("sending message to %q aborted due to canceled context", req.Email)
default:
}
var msg string
if req.Verification.Token != "" {
log.Printf("[DEBUG] send verification via %s, user %s", e, req.Verification.User)
msg, err = e.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.Locator.SiteID)
if err != nil {
return err
}
}
if req.Comment.ID != "" {
if req.parent.User == req.Comment.User {
// don't send anything if if user replied to their own Comment
return nil
}
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
msg, err = e.buildMessageFromRequest(req, req.Email)
if err != nil {
return err
}
}
return e.sendMessage(ctx, emailMessage{from: e.From, to: req.Email, message: msg})
}
// buildVerificationMessage generates verification email message based on given input
func (e *Email) buildVerificationMessage(user, address, token, site string) (string, error) {
subject := e.VerificationSubject
msg := bytes.Buffer{}
err := e.verifyTmpl.Execute(&msg, verifyTmplData{user, address, token, site})
if err != nil {
return "", errors.Wrapf(err, "error executing template to build verifying message from request")
}
return e.buildMessage(subject, msg.String(), address, "text/html"), nil
}
// buildMessage generates email message to send using net/smtp.Data()
func (e *Email) buildMessage(subject, body, to, contentType string) (message string) {
message += fmt.Sprintf("From: %s\n", e.From)
message += fmt.Sprintf("To: %s\n", to)
message += fmt.Sprintf("Subject: %s\n", subject)
if contentType != "" {
message += fmt.Sprintf("MIME-version: 1.0;\nContent-Type: %s; charset=\"UTF-8\";\n", contentType)
}
message += "\n" + body
return message
}
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
func (e *Email) buildMessageFromRequest(req Request, to string) (string, error) {
subject := "New comment"
if req.Comment.PostTitle != "" {
subject += fmt.Sprintf(" for \"%s\"", req.Comment.PostTitle)
}
msg := bytes.Buffer{}
err := e.msgTmpl.Execute(&msg, msgTmplData{
req.Comment.User.Name,
req.parent.User.Name,
req.Comment.Orig,
req.Comment.Locator.URL + uiNav + req.Comment.ID,
req.Comment.PostTitle,
})
if err != nil {
return "", errors.Wrapf(err, "error executing template to build message from request")
}
return e.buildMessage(subject, msg.String(), to, "text/html"), nil
}
// sendMessage sends messages to server in a new connection, closing the connection after finishing.
// Thread safe.
func (e *Email) sendMessage(ctx context.Context, m emailMessage) error {
if e.smtp == nil {
return errors.New("sendMessage called without smtpClient set")
}
smtpClient, err := e.smtp.Create(e.SmtpParams)
if err != nil {
return errors.Wrap(err, "failed to make smtp Create")
}
errs := new(multierror.Error)
err = repeater.NewDefault(5, time.Millisecond*250).Do(ctx, func() error {
if err := smtpClient.Mail(m.from); err != nil {
return errors.Wrapf(err, "bad from address %q", m.from)
}
if err := smtpClient.Rcpt(m.to); err != nil {
return errors.Wrapf(err, "bad to address %q", m.to)
}
writer, err := smtpClient.Data()
if err != nil {
return errors.Wrap(err, "can't make email writer")
}
defer func() {
if err = writer.Close(); err != nil {
log.Printf("[WARN] can't close smtp body writer, %v", err)
}
}()
buf := bytes.NewBufferString(m.message)
if _, err = buf.WriteTo(writer); err != nil {
return errors.Wrapf(err, "failed to send email body to %q", m.to)
}
return nil
})
if err != nil {
errs = multierror.Append(errs, errors.Wrapf(err, "can't send message to %s", m.to))
}
if err := smtpClient.Quit(); err != nil {
log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err)
if err := smtpClient.Close(); err != nil {
log.Printf("[WARN] can't close smtp connection, %v", err)
errs = multierror.Append(errs, err)
}
}
return errors.Wrapf(errs.ErrorOrNil(), "problems with sending message")
}
// String representation of Email object
func (e *Email) String() string {
return fmt.Sprintf("email: from %q using '%s'@'%s':%d", e.From, e.Username, e.Host, e.Port)
}
// Create establish SMTP connection with server using credentials in smtpClientWithCreator.SmtpParams
// and returns pointer to it. Thread safe.
func (s *emailClient) Create(params SmtpParams) (smtpClient, error) {
var c *smtp.Client
srvAddress := fmt.Sprintf("%s:%d", params.Host, params.Port)
if params.TLS {
tlsConf := &tls.Config{
InsecureSkipVerify: false,
ServerName: params.Host,
}
conn, err := tls.Dial("tcp", srvAddress, tlsConf)
if err != nil {
return nil, errors.Wrapf(err, "failed to dial smtp tls to %s", srvAddress)
}
if c, err = smtp.NewClient(conn, params.Host); err != nil {
return nil, errors.Wrapf(err, "failed to make smtp client for %s", srvAddress)
}
return c, nil
}
conn, err := net.DialTimeout("tcp", srvAddress, params.TimeOut)
if err != nil {
return nil, errors.Wrapf(err, "timeout connecting to %s", srvAddress)
}
c, err = smtp.NewClient(conn, srvAddress)
if err != nil {
return nil, errors.Wrap(err, "failed to dial")
}
if params.Username != "" && params.Password != "" {
auth := smtp.PlainAuth("", params.Username, params.Password, params.Host)
if err := c.Auth(auth); err != nil {
return nil, errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port)
}
}
return c, nil
}
+295
View File
@@ -0,0 +1,295 @@
package notify
import (
"bytes"
"context"
"errors"
"io"
"net/smtp"
"sync"
"testing"
"text/template"
"time"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark/backend/app/store"
)
func TestEmailNew(t *testing.T) {
var testSet = []struct {
name string
template bool
err bool
errText string
emailParams EmailParams
smtpParams SmtpParams
}{
{name: "with connection error", template: true, err: true},
{name: "with template parse error",
err: true, errText: "can't parse message template: template: messageFromRequest:1: unexpected unclosed action in command",
emailParams: EmailParams{
From: "test@from",
MsgTemplate: "{{",
BufferSize: 10,
FlushDuration: time.Second,
}},
{name: "with verification template parse error",
err: true, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command",
template: true,
emailParams: EmailParams{
VerificationTemplate: "{{",
},
smtpParams: SmtpParams{
Host: "test@host",
Port: 1000,
TLS: true,
Username: "test@username",
Password: "test@password",
TimeOut: time.Second,
},
},
}
for _, d := range testSet {
d := d // capture range variable
t.Run(d.name, func(t *testing.T) {
email, err := NewEmail(d.emailParams, d.smtpParams)
if d.err && d.errText == "" {
assert.Error(t, err)
} else if d.err && d.errText != "" {
assert.EqualError(t, err, d.errText)
} else {
assert.NoError(t, err)
}
assert.NotNil(t, email, "email returned")
if d.template {
assert.NotNil(t, email.msgTmpl, "e.template is set")
} else {
assert.Nil(t, email.msgTmpl, "e.template is not set")
}
if d.emailParams.MsgTemplate == "" {
assert.Equal(t, defaultEmailTemplate, email.EmailParams.MsgTemplate, "empty emailParams.MsgTemplate changed to default")
} else {
assert.Equal(t, d.emailParams.MsgTemplate, email.EmailParams.MsgTemplate, "emailParams.MsgTemplate unchanged after creation")
}
if d.emailParams.FlushDuration == 0 {
assert.Equal(t, defaultFlushDuration, email.EmailParams.FlushDuration, "empty emailParams.FlushDuration changed to default")
} else {
assert.Equal(t, d.emailParams.FlushDuration, email.EmailParams.FlushDuration, "emailParams.FlushDuration unchanged after creation")
}
if d.emailParams.BufferSize == 0 {
assert.Equal(t, 1, email.EmailParams.BufferSize, "empty emailParams.BufferSize changed to default")
} else {
assert.Equal(t, d.emailParams.BufferSize, email.EmailParams.BufferSize, "emailParams.BufferSize unchanged after creation")
}
assert.Equal(t, d.emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
if d.smtpParams.TimeOut == 0 {
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
} else {
assert.Equal(t, d.smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation")
}
assert.Equal(t, d.smtpParams.Host, email.Host, "emailParams.Host unchanged after creation")
assert.Equal(t, d.smtpParams.Username, email.Username, "emailParams.Username unchanged after creation")
assert.Equal(t, d.smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
assert.Equal(t, d.smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
assert.Equal(t, d.smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
})
}
}
func TestEmailSendErrors(t *testing.T) {
var err error
e := Email{EmailParams: EmailParams{FlushDuration: time.Second}}
e.verifyTmpl, err = template.New("test").Parse("{{.Test}}")
assert.NoError(t, err)
assert.EqualError(t, e.Send(context.Background(), Request{Email: "bad@example.org", Verification: VerificationMetadata{Token: "some"}}),
"error executing template to build verifying message from request: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.verifyTmplData")
e.verifyTmpl, err = template.New("test").Parse(defaultEmailVerificationTemplate)
assert.NoError(t, err)
e.msgTmpl, err = template.New("test").Parse("{{.Test}}")
assert.NoError(t, err)
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}),
"error executing template to build message from request: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.msgTmplData")
e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate)
assert.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}),
"sending message to \"bad@example.org\" aborted due to canceled context")
}
func TestEmailSend_ExitConditions(t *testing.T) {
email, err := NewEmail(EmailParams{}, SmtpParams{})
assert.Error(t, err, "error match expected")
assert.NotNil(t, email, "expecting email returned")
// prevent triggering e.autoFlush creation
emptyRequest := Request{Comment: store.Comment{ID: "999"}}
assert.Nil(t, email.Send(context.Background(), emptyRequest),
"Message without parent comment User.Email is not sent and returns nil")
requestWithEqualUsersWithEmails := Request{Comment: store.Comment{ID: "999"}, Email: "good_example@example.org"}
assert.Nil(t, email.Send(context.Background(), requestWithEqualUsersWithEmails),
"Message with parent comment User equals comment User is not sent and returns nil")
}
func TestEmailSendClientError(t *testing.T) {
var testSet = []struct {
name string
smtp *fakeTestSMTP
err string
}{
{name: "failed to verify receiver", smtp: &fakeTestSMTP{fail: map[string]bool{"mail": true}},
err: "problems with sending message: 1 error occurred:\n\t* can't send message to : bad from address \"\": failed to verify sender\n\n"},
{name: "failed to verify sender", smtp: &fakeTestSMTP{fail: map[string]bool{"rcpt": true}},
err: "problems with sending message: 1 error occurred:\n\t* can't send message to : bad to address \"\": failed to verify receiver\n\n"},
{name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}},
err: "problems with sending message: 1 error occurred:\n\t* failed to close\n\n"},
{name: "failed to make email writer", smtp: &fakeTestSMTP{fail: map[string]bool{"data": true}},
err: "problems with sending message: 1 error occurred:\n\t* can't send message to : can't make email writer: failed to send\n\n"},
}
for _, d := range testSet {
d := d // capture range variable
t.Run(d.name, func(t *testing.T) {
e := Email{smtp: d.smtp}
assert.EqualError(t, e.sendMessage(context.Background(), emailMessage{}), d.err,
"expected error for e.sendMessage")
})
}
e := Email{}
e.smtp = nil
assert.Error(t, e.sendMessage(context.Background(), emailMessage{}),
"nil e.smtp should return error")
e.smtp = &fakeTestSMTP{}
assert.NoError(t, e.sendMessage(context.Background(), emailMessage{}), "",
"no error expected for e.sendMessage in normal flow")
e.smtp = &fakeTestSMTP{fail: map[string]bool{"quit": true}}
assert.NoError(t, e.sendMessage(context.Background(), emailMessage{}), "",
"no error expected for e.sendMessage with failed smtpClient.Quit but successful smtpClient.Close")
e.smtp = &fakeTestSMTP{fail: map[string]bool{"create": true}}
assert.EqualError(t, e.sendMessage(context.Background(), emailMessage{}), "failed to make smtp Create: failed to create client",
"e.send called without smtpClient set returns error")
}
func TestEmail_Send(t *testing.T) {
const expectedAnswer = `From: from@example.org
To: test@example.org
Subject: New comment for "test_title"
MIME-version: 1.0;
Content-Type: text/html; charset="UTF-8";
test_user
↦ <a href="#remark42__comment-999">test_title</a>
`
req := Request{Comment: store.Comment{ID: "999", User: store.User{Name: "test_user"}, PostTitle: "test_title"}, Email: "test@example.org"}
e, err := NewEmail(EmailParams{From: "from@example.org"}, SmtpParams{})
assert.Error(t, err, "connection error expected")
assert.NotNil(t, e)
fakeSmtp := fakeTestSMTP{}
e.smtp = &fakeSmtp
assert.NoError(t, e.Send(context.TODO(), req))
assert.Equal(t, "from@example.org", fakeSmtp.readMail())
assert.Equal(t, 1, fakeSmtp.readQuitCount())
assert.Equal(t, "test@example.org", fakeSmtp.readRcpt())
// test buildMessageFromRequest separately for message text
res, err := e.buildMessageFromRequest(req, "test@example.org")
assert.NoError(t, err)
assert.Equal(t, expectedAnswer, res)
}
type fakeTestSMTP struct {
fail map[string]bool
buff bytes.Buffer
mail, rcpt string
auth bool
close bool
quitCount int
lock sync.RWMutex
}
func (f *fakeTestSMTP) Create(SmtpParams) (smtpClient, error) {
if f.fail["create"] {
return nil, errors.New("failed to create client")
}
return f, nil
}
func (f *fakeTestSMTP) Auth(smtp.Auth) error { f.auth = true; return nil }
func (f *fakeTestSMTP) Mail(m string) error {
f.lock.Lock()
f.mail = m
f.lock.Unlock()
if f.fail["mail"] {
return errors.New("failed to verify sender")
}
return nil
}
func (f *fakeTestSMTP) Rcpt(r string) error {
f.lock.Lock()
f.rcpt = r
f.lock.Unlock()
if f.fail["rcpt"] {
return errors.New("failed to verify receiver")
}
return nil
}
func (f *fakeTestSMTP) Quit() error {
f.lock.Lock()
f.quitCount++
f.lock.Unlock()
if f.fail["quit"] {
return errors.New("failed to quit")
}
return nil
}
func (f *fakeTestSMTP) Close() error {
f.close = true
if f.fail["close"] {
return errors.New("failed to close")
}
return nil
}
func (f *fakeTestSMTP) Data() (io.WriteCloser, error) {
if f.fail["data"] {
return nil, errors.New("failed to send")
}
return nopCloser{&f.buff}, nil
}
func (f *fakeTestSMTP) readRcpt() string {
f.lock.RLock()
defer f.lock.RUnlock()
return f.rcpt
}
func (f *fakeTestSMTP) readMail() string {
f.lock.RLock()
defer f.lock.RUnlock()
return f.mail
}
func (f *fakeTestSMTP) readQuitCount() int {
f.lock.RLock()
defer f.lock.RUnlock()
return f.quitCount
}
type nopCloser struct {
io.Writer
}
func (nopCloser) Close() error {
return nil
}
+15 -4
View File
@@ -34,9 +34,19 @@ type Store interface {
Get(locator store.Locator, id string, user store.User) (store.Comment, error)
}
// Request notification either about comment or about particular user verification
type Request struct {
Comment store.Comment
parent store.Comment
Comment store.Comment // if set sent notifications about new comment
parent store.Comment // fetched only in case Comment is set
Email string // if set (also) send email
Verification VerificationMetadata // if set sent verification notification
}
// VerificationMetadata required to send notify method verification message
type VerificationMetadata struct {
Locator store.Locator // only SiteID is used
User string
Token string
}
const defaultQueueSize = 100
@@ -67,7 +77,8 @@ func (s *Service) Submit(req Request) {
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
return
}
if s.dataService != nil {
// parent comment is fetched only if comment is present in the Request
if s.dataService != nil && req.Comment.ParentID != "" {
if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil {
req.parent = p
}
@@ -75,7 +86,7 @@ func (s *Service) Submit(req Request) {
select {
case s.queue <- req:
default:
log.Printf("[WARN] can't send comment notification to queue, %+v", req.Comment)
log.Printf("[WARN] can't send notification to queue, %+v", req.Comment)
}
}
+2 -1
View File
@@ -17,7 +17,8 @@ import (
)
func TestService_NoDestinations(t *testing.T) {
s := NewService(nil, 1)
s := NewService(nil, 0)
assert.Equal(t, defaultQueueSize, cap(s.queue))
assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Submit(Request{Comment: store.Comment{ID: "123"}})
+4
View File
@@ -86,6 +86,10 @@ func NewTelegram(token string, channelID string, timeout time.Duration, api stri
// Send to telegram channel
func (t *Telegram) Send(ctx context.Context, req Request) error {
if req.Comment.ID == "" {
// verification request received, send nothing
return nil
}
client := http.Client{Timeout: telegramTimeOut}
log.Printf("[DEBUG] send telegram notification to %s, comment id %s", t.channelID, req.Comment.ID)
+2 -1
View File
@@ -58,7 +58,7 @@ func TestTelegram_Send(t *testing.T) {
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
assert.NoError(t, err)
assert.NotNil(t, tb)
c := store.Comment{Text: "some text", ParentID: "1"}
c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
c.User.Name = "from"
cp := store.Comment{Text: "some parent text"}
cp.User.Name = "to"
@@ -76,6 +76,7 @@ func TestTelegram_Send(t *testing.T) {
assert.Contains(t, err.Error(), "unexpected telegram status code 404", "send on broken tg")
assert.Equal(t, "telegram: @remark_test", tb.String())
require.Nil(t, tb.Send(context.TODO(), Request{}), "Empty Comment doesn't send anything")
}
func mockTelegramServer() *httptest.Server {