diff --git a/backend/app/notify/email.go b/backend/app/notify/email.go new file mode 100644 index 00000000..158b7bdb --- /dev/null +++ b/backend/app/notify/email.go @@ -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}} + +↦ {{if .PostTitle}}{{.PostTitle}}{{else}}original comment{{end}} +` + 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 +} diff --git a/backend/app/notify/email_test.go b/backend/app/notify/email_test.go new file mode 100644 index 00000000..7c977a79 --- /dev/null +++ b/backend/app/notify/email_test.go @@ -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 + + + +↦ test_title +` + 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 +} diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go index 8766089e..7d07da8c 100644 --- a/backend/app/notify/notify.go +++ b/backend/app/notify/notify.go @@ -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) } } diff --git a/backend/app/notify/notify_test.go b/backend/app/notify/notify_test.go index bddc625b..3927d3a7 100644 --- a/backend/app/notify/notify_test.go +++ b/backend/app/notify/notify_test.go @@ -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"}}) diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index 83bf532a..4d253711 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -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) diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go index 2dd3fdd6..e10e096c 100644 --- a/backend/app/notify/telegram_test.go +++ b/backend/app/notify/telegram_test.go @@ -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 {