diff --git a/README.md b/README.md index 1b66c4a4..1c61d21d 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Yes, please! Contributions of all kinds are very welcome! Feel free to check our | [WeChat](https://www.wechat.com) | [service/wechat](service/wechat) | [silenceper/wechat](https://github.com/silenceper/wechat) | :heavy_check_mark: | | [Webpush Notification](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) | [service/webpush](service/webpush) | [SherClockHolmes/webpush-go](https://github.com/SherClockHolmes/webpush-go/) | :heavy_check_mark: | | [WhatsApp](https://www.whatsapp.com) | [service/whatsapp](service/whatsapp) | [Rhymen/go-whatsapp](https://github.com/Rhymen/go-whatsapp) | :x: | +| [Zulip](https://zulip.com/) | [service/zulip](service/zulip) | - | :heavy_check_mark: | ## Special Thanks diff --git a/service/zulip/README.md b/service/zulip/README.md new file mode 100644 index 00000000..328a6ae2 --- /dev/null +++ b/service/zulip/README.md @@ -0,0 +1,43 @@ +# Zulip + +## Steps for creating a Zulip Bot + +Follow the below instructions to create a bot email and api key required for the service: + +1. Create a Zulip Organization +2. Go to settings and create a new bot. Copy the bot email and api key. +3. Copy your Oranization URL. Copy the entire url `https://your-domain.zulipchat.com` +4. Copy the stream name of the stream and its topic if you want to post a message to stream or just copy an email address of the receiver. + +## Sample Code + +```go + package main + + import ( + "context" + "log" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/zulip" + ) + + func main() { + zulipSvc, err := zulip.New("server-base-url", "bot-email", "api-key") + if err != nil { + log.Fatalf("zulip.New() failed: %s", err.Error()) + } + + zulipSvc.AddReceivers(Direct("test2@gmail.com"), Stream("stream", "topic")) + + notifier := notify.New() + notifier.UseServices(zulipSvc) + + err = notifier.Send(context.Background(), "subject", "message") + if err != nil { + log.Fatalf("notifier.Send() failed: %s", err.Error()) + } + + log.Println("notification sent") + } +``` diff --git a/service/zulip/client/client.go b/service/zulip/client/client.go new file mode 100644 index 00000000..5edc36cd --- /dev/null +++ b/service/zulip/client/client.go @@ -0,0 +1,135 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + // DefaultBaseURL contains example base URL of zulip service. + DefaultBaseURL = "https://yourZulipDomain.zulipchat.com" + + // DefaultTimeout duration in second + DefaultTimeout time.Duration = 30 * time.Second +) + +// Client abstracts the interaction between the application server and the +// Zulip server via HTTP protocol. The developer must obtain an API key from the +// Zulip's personal settings page and pass it to the `Client` so that it can +// perform authorized requests on the application server's behalf. +// To send a message to one or more devices use the Client's Send. +// +// If the `HTTP` field is nil, a zeroed http.Client will be allocated and used +// to send messages. +type Client struct { + email string + apiKey string + baseURL string + client *http.Client + timeout time.Duration +} + +type Option func(*Client) error + +// NewClient creates new Zulip Client based on opts passed and +// with default endpoint and http client. +func NewClient(opts ...Option) (*Client, error) { + c := &Client{ + baseURL: DefaultBaseURL, + client: &http.Client{}, + timeout: DefaultTimeout, + } + + for _, opt := range opts { + if err := opt(c); err != nil { + return nil, err + } + } + + if c.apiKey == "" || c.email == "" { + return nil, ErrInvalidCreds + } + + return c, nil +} + +type Response struct { + ID int `json:"id"` + Msg string `json:"msg"` + Result string `json:"result"` + Code string `json:"code"` +} + +// SendWithContext sends a message to the Zulip server without retrying in case of service +// unavailability. A non-nil error is returned if a non-recoverable error +// occurs (i.e. if the response status is not "200 OK"). +// Behaves just like regular send, but uses external context. +func (c *Client) SendWithContext(ctx context.Context, msg *Message) (*Response, error) { + // validate + if err := msg.Validate(); err != nil { + return nil, err + } + + return c.send(ctx, msg) +} + +// Send sends a message to the Zulip server without retrying in case of service +// unavailability. A non-nil error is returned if a non-recoverable error +// occurs (i.e. if the response status is not "200 OK"). +func (c *Client) Send(msg *Message) (*Response, error) { + ctx, cancel := context.WithTimeout(context.Background(), c.timeout) + defer cancel() + + return c.SendWithContext(ctx, msg) +} + +// send sends a request. +func (c *Client) send(ctx context.Context, msg *Message) (*Response, error) { + // set the message data + data := url.Values{} + data.Set("type", msg.Type) + data.Set("to", fmt.Sprintf("%v", msg.To)) + data.Set("topic", msg.Topic) + data.Set("content", msg.Content) + + // create request + url, _ := url.JoinPath(c.baseURL, "/api/v1/messages") + req, err := http.NewRequest("POST", url, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req = req.WithContext(ctx) + + // add headers + req.SetBasicAuth(c.email, c.apiKey) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // execute request + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // check response status + if resp.StatusCode != http.StatusOK { + if resp.StatusCode >= http.StatusInternalServerError { + return nil, fmt.Errorf("%d error: %s", resp.StatusCode, resp.Status) + } + return nil, fmt.Errorf("%d error: %s", resp.StatusCode, resp.Status) + } + + // build return + response := new(Response) + if err := json.NewDecoder(resp.Body).Decode(response); err != nil { + return nil, err + } + + return response, nil +} diff --git a/service/zulip/client/client_test.go b/service/zulip/client/client_test.go new file mode 100644 index 00000000..dda56361 --- /dev/null +++ b/service/zulip/client/client_test.go @@ -0,0 +1,213 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSend(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + t.Run("send=success", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + email, apiKey, _ := req.BasicAuth() + assert.Equal(email, "email", "invalid email provided") + assert.Equal(apiKey, "apiKey", "invalid apiKey provided") + + rw.WriteHeader(http.StatusOK) + rw.Header().Set("Content-Type", "application/json") + fmt.Fprint(rw, `{ + "id":42, + "msg": "", + "result": "success" + }`) + })) + defer server.Close() + + client, err := NewClient( + WithBaseURL(server.URL), + WithCreds("email", "apiKey"), + WithTimeout(10*time.Second), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + resp, err := client.Send(&Message{ + Type: "stream", + To: "test", + Topic: "general", + Content: "some content goes here", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.Result != "success" { + t.Fatalf("invalid response: %v", err) + } + }) + + t.Run("send=failure", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + email, apiKey, _ := req.BasicAuth() + assert.Equal(email, "email", "invalid email provided") + assert.Equal(apiKey, "apiKey", "invalid apiKey provided") + + rw.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + client, err := NewClient( + WithBaseURL(server.URL), + WithCreds("email", "apiKey"), + WithTimeout(10*time.Second), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + resp, err := client.Send(&Message{ + Type: "stream", + To: "test", + Topic: "general", + Content: "some content goes here", + }) + if err == nil { + t.Fatal("expected error but got nil") + } + if resp != nil { + t.Fatalf("expected nil response\ngot: %v response", resp) + } + }) + + t.Run("send=invalid_token", func(t *testing.T) { + t.Parallel() + + _, err := NewClient( + WithCreds("", ""), + WithTimeout(10*time.Second), + ) + if err == nil { + t.Fatal("expected error but got nil") + } + }) + + t.Run("send=invalid_message", func(t *testing.T) { + t.Parallel() + + client, err := NewClient( + WithBaseURL(""), + WithCreds("email", "apiKey"), + WithTimeout(10*time.Second), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err = client.Send(&Message{}) + if err == nil { + t.Fatal("expected error but go nil") + } + }) +} + +func TestSendWithContext(t *testing.T) { + t.Run("send_context=success", func(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + email, apiKey, _ := req.BasicAuth() + assert.Equal(email, "email", "invalid email provided") + assert.Equal(apiKey, "apiKey", "invalid apiKey provided") + + rw.WriteHeader(http.StatusOK) + rw.Header().Set("Content-Type", "application/json") + fmt.Fprint(rw, `{ + "id":42, + "msg": "", + "result": "success" + }`) + })) + defer server.Close() + + client, err := NewClient( + WithBaseURL(server.URL), + WithCreds("email", "apiKey"), + WithTimeout(10*time.Second), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ctx := context.Background() + resp, err := client.SendWithContext(ctx, &Message{ + Type: "stream", + To: "test", + Topic: "general", + Content: "some content goes here", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Result != "success" { + t.Fatalf("invalid response: %v", err) + } + }) + + t.Run("send_context=timeout", func(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + email, apiKey, _ := req.BasicAuth() + assert.Equal(email, "email", "invalid email provided") + assert.Equal(apiKey, "apiKey", "invalid apiKey provided") + + time.Sleep(time.Millisecond * 100) + + rw.WriteHeader(http.StatusOK) + rw.Header().Set("Content-Type", "application/json") + fmt.Fprint(rw, `{ + "id":42, + "msg": "", + "result": "success" + }`) + })) + defer server.Close() + + client, err := NewClient( + WithBaseURL(server.URL), + WithCreds("email", "apiKey"), + WithTimeout(100*time.Millisecond), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50) + defer cancel() + _, err = client.SendWithContext(ctx, &Message{ + Type: "stream", + To: "test", + Topic: "general", + Content: "some content goes here", + }) + if err == nil { + t.Fatalf("no context timeout") + } + }) +} diff --git a/service/zulip/client/errors.go b/service/zulip/client/errors.go new file mode 100644 index 00000000..bed2f2ae --- /dev/null +++ b/service/zulip/client/errors.go @@ -0,0 +1,18 @@ +package client + +import "errors" + +// ErrInvalidCreds occurs if API key or Email is not set. +var ErrInvalidCreds = errors.New("client credentials are invalid") + +// ErrInvalidMessageType occurs if Message Type is not 'direct','stream' or 'private' +var ErrInvalidMessageType = errors.New("invalid message type. Type should be 'direct','stream' or 'private'") + +// ErrInvalidMessageTo occurs if Message To is not string, int, []string, []int +var ErrInvalidMessageTo = errors.New("invalid message to. To should be string, int, []string or []int") + +// ErrInvalidMessageTopic occurs if Message Topic is empty +var ErrInvalidMessageTopic = errors.New("invalid message topic. Topic should be a non-empty string") + +// ErrInvalidMessageContent occurs if Message Content is empty +var ErrInvalidMessageContent = errors.New("invalid message content. Content should be a non-empty string") diff --git a/service/zulip/client/hooks.go b/service/zulip/client/hooks.go new file mode 100644 index 00000000..d11a5f01 --- /dev/null +++ b/service/zulip/client/hooks.go @@ -0,0 +1,28 @@ +package client + +import "time" + +// Hook to add baseURL to the Zulip client +func WithBaseURL(baseURL string) Option { + return func(c *Client) error { + c.baseURL = baseURL + return nil + } +} + +// Hook to add necessary creds to the Zulip client +func WithCreds(email string, apiKey string) Option { + return func(c *Client) error { + c.email = email + c.apiKey = apiKey + return nil + } +} + +// Hook to add timeout to the Zulip client +func WithTimeout(timeout time.Duration) Option { + return func(c *Client) error { + c.timeout = timeout + return nil + } +} diff --git a/service/zulip/client/hooks_test.go b/service/zulip/client/hooks_test.go new file mode 100644 index 00000000..7cbd976c --- /dev/null +++ b/service/zulip/client/hooks_test.go @@ -0,0 +1,85 @@ +package client + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestWithBaseURL(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + urls := []string{ + "https://localhost:5000", + "https://zulip.com", + "https://test.domain.co", + } + + for i, url := range urls { + client, _ := NewClient(WithBaseURL(url), WithCreds("", "")) + assert.Equal( + client.baseURL, + url, + fmt.Sprintf("TEST %d: WithBaseURL hook failed", i), + ) + } +} + +func TestWithCreds(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + creds := []struct { + email string + apiKey string + }{ + {email: "test@gmail.com", apiKey: ""}, + {email: "zulip@gmail.com", apiKey: ""}, + {email: "notify@gmail.com", apiKey: ""}, + } + + for i, cred := range creds { + client, err := NewClient(WithCreds(cred.email, cred.apiKey)) + if err != nil { + t.Errorf("TEST %d: WithCreds hook errored", i) + } else { + assert.Equal( + client.email, + cred.email, + fmt.Sprintf("TEST %d: WithCreds hook failed for email", i), + ) + assert.Equal( + client.apiKey, + cred.apiKey, + fmt.Sprintf("TEST %d: WithCreds hook failed for apiKey", i), + ) + } + } +} + +func TestWithTimeout(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + timeouts := []time.Duration{ + 1 * time.Minute, + 50 * time.Second, + 200 * time.Millisecond, + } + + for i, timeout := range timeouts { + client, _ := NewClient(WithTimeout(timeout), WithCreds("", "")) + + assert.Equal( + client.timeout, + timeout, + fmt.Sprintf("TEST %d: WithTimeout hook failed", i), + ) + } +} diff --git a/service/zulip/client/message.go b/service/zulip/client/message.go new file mode 100644 index 00000000..23bd672d --- /dev/null +++ b/service/zulip/client/message.go @@ -0,0 +1,46 @@ +package client + +import ( + "reflect" +) + +type Message struct { + Type string + To any + Topic string + Content string +} + +// Validate returns an error if the message is not well-formed. +func (m *Message) Validate() error { + // Type must be "direct", "stream" or "private" + switch m.Type { + case "direct": + case "stream": + case "private": + break + default: + return ErrInvalidMessageType + } + + // To must be int,string, []int, []string + switch reflect.TypeOf(m.To) { + case reflect.TypeOf(""): + case reflect.TypeOf(1): + case reflect.SliceOf(reflect.TypeOf("")): + case reflect.SliceOf(reflect.TypeOf(1)): + break + default: + return ErrInvalidMessageTo + } + + if m.Topic == "" { + return ErrInvalidMessageTopic + } + + if m.Content == "" { + return ErrInvalidMessageContent + } + + return nil +} diff --git a/service/zulip/client/message_test.go b/service/zulip/client/message_test.go new file mode 100644 index 00000000..95611032 --- /dev/null +++ b/service/zulip/client/message_test.go @@ -0,0 +1,70 @@ +package client + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +type MessageValidationTest struct { + msg Message + err error +} + +func TestMessageValidation(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + msgs := []MessageValidationTest{ + { + msg: Message{Type: "stream", To: "Sai Sumith", Topic: "something", Content: "content"}, + err: nil, + }, + { + msg: Message{Type: "direct", To: "Sai Sumith", Topic: "something", Content: "content"}, + err: nil, + }, + { + msg: Message{Type: "private", To: "Sai Sumith", Topic: "something", Content: "content"}, + err: nil, + }, + { + msg: Message{Type: "stream", To: "Sai Sumith", Topic: "", Content: "content"}, + err: ErrInvalidMessageTopic, + }, + { + msg: Message{Type: "stream", To: "Sai Sumith", Topic: "something", Content: ""}, + err: ErrInvalidMessageContent, + }, + { + msg: Message{Type: "stream", To: 1, Topic: "something", Content: "content"}, + err: nil, + }, + { + msg: Message{Type: "stream", To: []int{1, 2, 3}, Topic: "something", Content: "content"}, + err: nil, + }, + { + msg: Message{Type: "stream", To: []string{"Sai", "Sumith"}, Topic: "something", Content: "content"}, + err: nil, + }, + { + msg: Message{Type: "somethingRandom", To: []string{"Sai", "Sumith"}, Topic: "something", Content: "content"}, + err: ErrInvalidMessageType, + }, + { + msg: Message{Type: "stream", To: true, Topic: "something", Content: "content"}, + err: ErrInvalidMessageTo, + }, + } + + for i, v := range msgs { + assert.Equal( + v.msg.Validate(), + v.err, + fmt.Sprintf("TEST %d: validation failed", i), + ) + } +} diff --git a/service/zulip/doc.go b/service/zulip/doc.go new file mode 100644 index 00000000..893bd113 --- /dev/null +++ b/service/zulip/doc.go @@ -0,0 +1,35 @@ +/* +Package zulip provides message integration for the zulip service. + +Usage: + + package main + + import ( + "context" + "log" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/zulip" + ) + + func main() { + zulipSvc, err := zulip.New("server-base-url", "bot-email", "api-key") + if err != nil { + log.Fatalf("zulip.New() failed: %s", err.Error()) + } + + zulipSvc.AddReceivers(Direct("test2@gmail.com"), Stream("stream", "topic")) + + notifier := notify.New() + notifier.UseServices(zulipSvc) + + err = notifier.Send(context.Background(), "subject", "message") + if err != nil { + log.Fatalf("notifier.Send() failed: %s", err.Error()) + } + + log.Println("notification sent") + } +*/ +package zulip diff --git a/service/zulip/mock_zulip_client.go b/service/zulip/mock_zulip_client.go new file mode 100644 index 00000000..990063ff --- /dev/null +++ b/service/zulip/mock_zulip_client.go @@ -0,0 +1,54 @@ +// Code generated by mockery v2.35.1. DO NOT EDIT. + +package zulip + +import ( + mock "github.com/stretchr/testify/mock" + + client "github.com/nikoksr/notify/service/zulip/client" +) + +// mockZulipClient is an autogenerated mock type for the zulipClient type +type mockZulipClient struct { + mock.Mock +} + +// Send provides a mock function with given fields: msg +func (_m *mockZulipClient) Send(msg *client.Message) (*client.Response, error) { + ret := _m.Called(msg) + + var r0 *client.Response + var r1 error + if rf, ok := ret.Get(0).(func(*client.Message) (*client.Response, error)); ok { + return rf(msg) + } + if rf, ok := ret.Get(0).(func(*client.Message) *client.Response); ok { + r0 = rf(msg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.Response) + } + } + + if rf, ok := ret.Get(1).(func(*client.Message) error); ok { + r1 = rf(msg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// newMockZulipClient creates a new instance of mockZulipClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockZulipClient(t interface { + mock.TestingT + Cleanup(func()) +}) *mockZulipClient { + mock := &mockZulipClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/service/zulip/zulip.go b/service/zulip/zulip.go new file mode 100644 index 00000000..b8ff626a --- /dev/null +++ b/service/zulip/zulip.go @@ -0,0 +1,86 @@ +package zulip + +import ( + "context" + + z "github.com/nikoksr/notify/service/zulip/client" +) + +//go:generate mockery --name=zulipClient --output=. --case=underscore --inpackage +type zulipClient interface { + Send(msg *z.Message) (*z.Response, error) +} + +// Compile-time check to ensure that zulip client send function implements the zulipClient interface +var _ zulipClient = new(z.Client) + +type Receiver struct { + _type string + _to string + _topic string +} + +type Service struct { + client zulipClient + recv []*Receiver +} + +func New(baseURL, botEmail, apiKey string) (*Service, error) { + client, err := z.NewClient( + z.WithBaseURL(baseURL), + z.WithCreds(botEmail, apiKey), + ) + if err != nil { + return nil, err + } + + service := &Service{ + client, + []*Receiver{}, + } + + return service, nil +} + +func Direct(email string) *Receiver { + return &Receiver{ + _type: "direct", + _to: email, + _topic: "", + } +} + +func Stream(stream, topic string) *Receiver { + return &Receiver{ + _type: "stream", + _to: stream, + _topic: topic, + } +} + +func (s *Service) AddReceivers(recvs ...*Receiver) { + s.recv = append(s.recv, recvs...) +} + +func (s *Service) Send(ctx context.Context, subject, message string) error { + for _, recv := range s.recv { + select { + case <-ctx.Done(): + return ctx.Err() + default: + msg := z.Message{ + Type: recv._type, + To: recv._to, + Topic: recv._topic, + Content: subject + " " + message, + } + + _, err := s.client.Send(&msg) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/service/zulip/zulip_test.go b/service/zulip/zulip_test.go new file mode 100644 index 00000000..0d37a4de --- /dev/null +++ b/service/zulip/zulip_test.go @@ -0,0 +1,125 @@ +package zulip + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + z "github.com/nikoksr/notify/service/zulip/client" +) + +func TestZulip_New(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + service, err := New("server-base-url", "bot-email", "api-key") + assert.NotNil(service) + assert.Nil(err) +} + +func TestDirectHook(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + receiver := Direct("test@gmail.com") + + assert.Equal(receiver._type, "direct") + assert.Equal(receiver._to, "test@gmail.com") + assert.Equal(receiver._topic, "") + + assert.NotNil(receiver) + assert.IsType(new(Receiver), receiver) +} + +func TestStreamHook(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + receiver := Stream("group", "topic") + + assert.Equal(receiver._type, "stream") + assert.Equal(receiver._to, "group") + assert.Equal(receiver._topic, "topic") + + assert.NotNil(receiver) + assert.IsType(new(Receiver), receiver) +} + +func TestZulip_AddReceivers(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + service, _ := New("server-base-url", "bot-email", "api-key") + + service.AddReceivers(Direct("test@gmail.com")) + assert.Len(service.recv, 1) + + service.AddReceivers(Direct("test2@gmail.com"), Stream("stream", "topic")) + assert.Len(service.recv, 3) + + service.AddReceivers(Stream("stream2", "topic2")) + assert.Len(service.recv, 4) +} + +func TestZulip_Send(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + t.Run("send_message=direct", func(t *testing.T) { + t.Parallel() + + service := &Service{} + + mockZulipClient := newMockZulipClient(t) + mockZulipClient.On("Send", &z.Message{ + Type: "direct", + To: "test@gmail.com", + Topic: "", + Content: "subject message", + }).Return(&z.Response{ + ID: 0, + Msg: "", + Result: "", + Code: "", + }, nil) + + service.client = mockZulipClient + + service.AddReceivers(Direct("test@gmail.com")) + err := service.Send(context.Background(), "subject", "message") + assert.Nil(err) + mockZulipClient.AssertExpectations(t) + }) + + t.Run("send_message=stream", func(t *testing.T) { + t.Parallel() + + service := &Service{} + + mockZulipClient := newMockZulipClient(t) + mockZulipClient.On("Send", &z.Message{ + Type: "stream", + To: "group", + Topic: "topic", + Content: "subject message", + }).Return(&z.Response{ + ID: 0, + Msg: "", + Result: "", + Code: "", + }, nil) + + service.client = mockZulipClient + + service.AddReceivers(Stream("group", "topic")) + err := service.Send(context.Background(), "subject", "message") + assert.Nil(err) + mockZulipClient.AssertExpectations(t) + }) +}