package zulip import ( "fmt" "net/http" "net/url" "os" "strings" "testing" "github.com/jarcoal/httpmock" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/nicholas-fedor/shoutrrr/internal/testutils" "github.com/nicholas-fedor/shoutrrr/pkg/types" ) func TestZulip(t *testing.T) { gomega.RegisterFailHandler(ginkgo.Fail) ginkgo.RunSpecs(t, "Shoutrrr Zulip Suite") } var ( service *Service envZulipURL *url.URL ) var _ = ginkgo.BeforeSuite(func() { service = &Service{} envZulipURL, _ = url.Parse(os.Getenv("SHOUTRRR_ZULIP_URL")) }) // Helper function to create Zulip URLs with optional overrides. func createZulipURL(botMail, botKey, host, stream, topic string) *url.URL { query := url.Values{} if stream != "" { query.Set("stream", stream) } if topic != "" { query.Set("topic", topic) } u := &url.URL{ Scheme: "zulip", User: url.UserPassword(botMail, botKey), Host: host, RawQuery: query.Encode(), } return u } var _ = ginkgo.Describe("the zulip service", func() { ginkgo.When("running integration tests", func() { ginkgo.It("should not error out", func() { if envZulipURL.String() == "" { return } serviceURL, _ := url.Parse(envZulipURL.String()) err := service.Initialize(serviceURL, testutils.TestLogger()) gomega.Expect(err).NotTo(gomega.HaveOccurred()) err = service.Send("This is an integration test message", nil) gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) }) ginkgo.When("given a service url with missing parts", func() { ginkgo.It("should return an error if bot mail is missing", func() { zulipURL := createZulipURL( "", "correcthorsebatterystable", "example.zulipchat.com", "foo", "bar", ) expectErrorMessageGivenURL("bot mail missing from config URL", zulipURL) }) ginkgo.It("should return an error if api key is missing", func() { zulipURL := &url.URL{ Scheme: "zulip", User: url.User("bot-name@zulipchat.com"), Host: "example.zulipchat.com", RawQuery: url.Values{ "stream": []string{"foo"}, "topic": []string{"bar"}, }.Encode(), } expectErrorMessageGivenURL("API key missing from config URL", zulipURL) }) ginkgo.It("should return an error if host is missing", func() { zulipURL := createZulipURL( "bot-name@zulipchat.com", "correcthorsebatterystable", "", "foo", "bar", ) expectErrorMessageGivenURL("host missing from config URL", zulipURL) }) }) ginkgo.When("given a valid service url is provided", func() { ginkgo.It("should not return an error", func() { zulipURL := createZulipURL( "bot-name@zulipchat.com", "correcthorsebatterystable", "example.zulipchat.com", "foo", "bar", ) err := service.Initialize(zulipURL, testutils.TestLogger()) gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) ginkgo.It("should not return an error with a different bot key", func() { zulipURL := createZulipURL( "bot-name@zulipchat.com", "differentkey123456789", "example.zulipchat.com", "foo", "bar", ) err := service.Initialize(zulipURL, testutils.TestLogger()) gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) }) ginkgo.When("sending a message", func() { ginkgo.It("should error if topic exceeds max length", func() { zulipURL := createZulipURL( "bot-name@zulipchat.com", "correcthorsebatterystable", "example.zulipchat.com", "foo", "", ) err := service.Initialize(zulipURL, testutils.TestLogger()) gomega.Expect(err).NotTo(gomega.HaveOccurred()) longTopic := strings.Repeat("a", topicMaxLength+1) // 61 chars params := &types.Params{"topic": longTopic} err = service.Send("test message", params) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()).To(gomega.Equal( fmt.Sprintf( "topic exceeds max length: %d characters, got %d", topicMaxLength, len([]rune(longTopic)), ), )) }) ginkgo.It("should error if message exceeds max size", func() { zulipURL := createZulipURL( "bot-name@zulipchat.com", "correcthorsebatterystable", "example.zulipchat.com", "foo", "bar", ) err := service.Initialize(zulipURL, testutils.TestLogger()) gomega.Expect(err).NotTo(gomega.HaveOccurred()) longMessage := strings.Repeat("a", contentMaxSize+1) // 10001 bytes err = service.Send(longMessage, nil) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()).To(gomega.Equal( fmt.Sprintf( "message exceeds max size: %d bytes, got %d bytes", contentMaxSize, len(longMessage), ), )) }) ginkgo.It("should override stream from params", func() { zulipURL := createZulipURL( "bot-name@zulipchat.com", "correcthorsebatterystable", "example.zulipchat.com", "original", "", ) err := service.Initialize(zulipURL, testutils.TestLogger()) gomega.Expect(err).NotTo(gomega.HaveOccurred()) params := &types.Params{"stream": "newstream"} httpmock.Activate() defer httpmock.DeactivateAndReset() apiURL := service.getAPIURL(&Config{ BotMail: "bot-name@zulipchat.com", BotKey: "correcthorsebatterystable", Host: "example.zulipchat.com", Stream: "newstream", }) httpmock.RegisterResponder( "POST", apiURL, httpmock.NewStringResponder(http.StatusOK, ""), ) err = service.Send("test message", params) gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) ginkgo.It("should override topic from params", func() { zulipURL := createZulipURL( "bot-name@zulipchat.com", "correcthorsebatterystable", "example.zulipchat.com", "foo", "original", ) err := service.Initialize(zulipURL, testutils.TestLogger()) gomega.Expect(err).NotTo(gomega.HaveOccurred()) params := &types.Params{"topic": "newtopic"} httpmock.Activate() defer httpmock.DeactivateAndReset() config := &Config{ BotMail: "bot-name@zulipchat.com", BotKey: "correcthorsebatterystable", Host: "example.zulipchat.com", Stream: "foo", Topic: "newtopic", } apiURL := service.getAPIURL(config) httpmock.RegisterResponder( "POST", apiURL, func(req *http.Request) (*http.Response, error) { gomega.Expect(req.FormValue("topic")).To(gomega.Equal("newtopic")) return httpmock.NewStringResponse(http.StatusOK, ""), nil }, ) err = service.Send("test message", params) gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) ginkgo.It("should handle HTTP errors", func() { zulipURL := createZulipURL( "bot-name@zulipchat.com", "correcthorsebatterystable", "example.zulipchat.com", "foo", "bar", ) err := service.Initialize(zulipURL, testutils.TestLogger()) gomega.Expect(err).NotTo(gomega.HaveOccurred()) httpmock.Activate() defer httpmock.DeactivateAndReset() apiURL := service.getAPIURL(service.Config) httpmock.RegisterResponder( "POST", apiURL, httpmock.NewStringResponder(http.StatusBadRequest, "Bad Request"), ) err = service.Send("test message", nil) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()).To(gomega.ContainSubstring( "failed to send zulip message: response status code unexpected: 400", )) }) }) ginkgo.Describe("the zulip config", func() { ginkgo.When("cloning a config object", func() { ginkgo.It("the clone should have equal values", func() { // Covers zulip_config.go:75-84 (Clone equality) config1 := &Config{ BotMail: "bot-name@zulipchat.com", BotKey: "correcthorsebatterystable", Host: "example.zulipchat.com", Stream: "foo", Topic: "bar", } config2 := config1.Clone() gomega.Expect(config1).To(gomega.Equal(config2)) }) ginkgo.It("the clone should not be the same struct", func() { // Covers zulip_config.go:75-84 (Clone identity) config1 := &Config{ BotMail: "bot-name@zulipchat.com", BotKey: "correcthorsebatterystable", Host: "example.zulipchat.com", Stream: "foo", Topic: "bar", } config2 := config1.Clone() gomega.Expect(config1).NotTo(gomega.BeIdenticalTo(config2)) }) }) ginkgo.When("generating a config object", func() { ginkgo.It("should generate a correct config object using CreateConfigFromURL", func() { // Covers zulip_config.go:92-98 (CreateConfigFromURL), zulip_config.go:49-72 (setURL) zulipURL := createZulipURL( "bot-name@zulipchat.com", "correcthorsebatterystable", "example.zulipchat.com", "foo", "bar", ) serviceConfig, err := CreateConfigFromURL(zulipURL) gomega.Expect(err).NotTo(gomega.HaveOccurred()) config := &Config{ BotMail: "bot-name@zulipchat.com", BotKey: "correcthorsebatterystable", Host: "example.zulipchat.com", Stream: "foo", Topic: "bar", } gomega.Expect(serviceConfig).To(gomega.Equal(config)) }) ginkgo.It("should update config correctly using SetURL", func() { // Covers zulip_config.go:27-29 (SetURL), zulip_config.go:49-72 (setURL) config := &Config{} // Start with empty config zulipURL := createZulipURL( "bot-name@zulipchat.com", "correcthorsebatterystable", "example.zulipchat.com", "foo", "bar", ) err := config.SetURL(zulipURL) gomega.Expect(err).NotTo(gomega.HaveOccurred()) expected := &Config{ BotMail: "bot-name@zulipchat.com", BotKey: "correcthorsebatterystable", Host: "example.zulipchat.com", Stream: "foo", Topic: "bar", } gomega.Expect(config).To(gomega.Equal(expected)) }) }) ginkgo.When("given a config object with stream and topic", func() { ginkgo.It("should build the correct service url", func() { // Covers zulip_config.go:27-46 (GetURL with Topic) config := Config{ BotMail: "bot-name@zulipchat.com", BotKey: "correcthorsebatterystable", Host: "example.zulipchat.com", Stream: "foo", Topic: "bar", } url := config.GetURL() gomega.Expect(url.String()). To(gomega.Equal("zulip://bot-name%40zulipchat.com:correcthorsebatterystable@example.zulipchat.com?stream=foo&topic=bar")) }) }) ginkgo.When("given a config object with stream but without topic", func() { ginkgo.It("should build the correct service url", func() { // Covers zulip_config.go:27-46 (GetURL without Topic) config := Config{ BotMail: "bot-name@zulipchat.com", BotKey: "correcthorsebatterystable", Host: "example.zulipchat.com", Stream: "foo", } url := config.GetURL() gomega.Expect(url.String()). To(gomega.Equal("zulip://bot-name%40zulipchat.com:correcthorsebatterystable@example.zulipchat.com?stream=foo")) }) }) }) ginkgo.Describe("the zulip payload", func() { ginkgo.When("creating a payload with topic", func() { ginkgo.It("should include all fields", func() { // Covers zulip_payload.go:7-18 (CreatePayload with Topic) config := &Config{ Stream: "foo", Topic: "bar", } payload := CreatePayload(config, "test message") gomega.Expect(payload.Get("type")).To(gomega.Equal("stream")) gomega.Expect(payload.Get("to")).To(gomega.Equal("foo")) gomega.Expect(payload.Get("content")).To(gomega.Equal("test message")) gomega.Expect(payload.Get("topic")).To(gomega.Equal("bar")) }) }) ginkgo.When("creating a payload without topic", func() { ginkgo.It("should exclude topic field", func() { // Covers zulip_payload.go:7-18 (CreatePayload without Topic) config := &Config{ Stream: "foo", } payload := CreatePayload(config, "test message") gomega.Expect(payload.Get("type")).To(gomega.Equal("stream")) gomega.Expect(payload.Get("to")).To(gomega.Equal("foo")) gomega.Expect(payload.Get("content")).To(gomega.Equal("test message")) gomega.Expect(payload.Get("topic")).To(gomega.Equal("")) }) }) }) ginkgo.It("should return the correct service ID", func() { service := &Service{} gomega.Expect(service.GetID()).To(gomega.Equal("zulip")) }) }) func expectErrorMessageGivenURL(msg ErrorMessage, zulipURL *url.URL) { err := service.Initialize(zulipURL, testutils.TestLogger()) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()).To(gomega.Equal(string(msg))) }