diff --git a/internal/api/response/ticker_test.go b/internal/api/response/ticker_test.go index 66e05c77..de2045be 100644 --- a/internal/api/response/ticker_test.go +++ b/internal/api/response/ticker_test.go @@ -45,6 +45,13 @@ func (s *TickersResponseTestSuite) TestTickersResponse() { Avatar: "https://example.com/avatar.png", }, }, + SignalGroup: storage.TickerSignalGroup{ + Active: true, + GroupID: "example", + GroupName: "Example", + GroupDescription: "Example", + GroupInviteLink: "https://signal.group/#example", + }, Location: storage.TickerLocation{ Lat: 0.0, Lon: 0.0, @@ -86,6 +93,11 @@ func (s *TickersResponseTestSuite) TestTickersResponse() { s.Equal(ticker.Mastodon.Server, tickerResponse[0].Mastodon.Server) s.Equal(ticker.Mastodon.User.DisplayName, tickerResponse[0].Mastodon.ScreenName) s.Equal(ticker.Mastodon.User.Avatar, tickerResponse[0].Mastodon.ImageURL) + s.Equal(ticker.SignalGroup.Active, tickerResponse[0].SignalGroup.Active) + s.Equal(ticker.SignalGroup.Connected(), tickerResponse[0].SignalGroup.Connected) + s.Equal(ticker.SignalGroup.GroupID, tickerResponse[0].SignalGroup.GroupID) + s.Equal(ticker.SignalGroup.GroupName, tickerResponse[0].SignalGroup.GroupName) + s.Equal(ticker.SignalGroup.GroupDescription, tickerResponse[0].SignalGroup.GroupDescription) s.Equal(ticker.Location.Lat, tickerResponse[0].Location.Lat) s.Equal(ticker.Location.Lon, tickerResponse[0].Location.Lon) } diff --git a/internal/api/tickers_test.go b/internal/api/tickers_test.go index 4771426d..739cc979 100644 --- a/internal/api/tickers_test.go +++ b/internal/api/tickers_test.go @@ -41,6 +41,10 @@ func (s *TickerTestSuite) Run(name string, subtest func()) { s.ctx, _ = gin.CreateTestContext(s.w) s.store = &storage.MockStorage{} s.cfg = config.LoadConfig("") + s.cfg.SignalGroup = config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "+1234567890", + } s.cache = cache.NewCache(time.Minute) subtest() @@ -570,6 +574,224 @@ func (s *TickerTestSuite) TestDeleteTickerBluesky() { }) } +func (s *TickerTestSuite) TestPutTickerSignalGroup() { + s.Run("when ticker not found", func() { + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusNotFound, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when body is invalid", func() { + s.ctx.Set("ticker", storage.Ticker{}) + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", nil) + s.ctx.Request.Header.Add("Content-Type", "application/json") + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns error", func() { + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTicker", mock.Anything).Return(errors.New("storage error")).Once() + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns ticker", func() { + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTicker", mock.Anything).Return(nil).Once() + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.Equal(gock.IsDone(), true) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when signal-cli API call updateGroup returns error", func() { + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(500) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true,"GroupName":"Example","GroupDescription":"Example"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.True(gock.IsDone()) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when signal-cli API call getGroups returns error", func() { + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "groupId": "sample-group-id", + "timestamp": 1, + }, + "id": 1, + }) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(500) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true,"GroupName":"Example","GroupDescription":"Example"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.True(gock.IsDone()) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when enabling signal group successfully", func() { + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "groupId": "sample-group-id", + "timestamp": 1, + }, + "id": 1, + }) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": []map[string]interface{}{ + { + "id": "sample-group-id", + "name": "Example", + "description": "Example", + "groupInviteLink": "https://signal.group/#example", + }, + }, + "id": 1, + }) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true,"GroupName":"Example","GroupDescription":"Example"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTicker", mock.Anything).Return(nil).Once() + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.True(gock.IsDone()) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when updating signal group successfully", func() { + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "groupId": "sample-group-id", + "timestamp": 1, + }, + "id": 1, + }) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": []map[string]interface{}{ + { + "id": "sample-group-id", + "name": "Example", + "description": "Example", + "groupInviteLink": "https://signal.group/#example", + }, + }, + "id": 1, + }) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true,"GroupId":"sample-group-id","GroupName":"Example","GroupDescription":"Example"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTicker", mock.Anything).Return(nil).Once() + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.True(gock.IsDone()) + s.store.AssertExpectations(s.T()) + }) +} + +func (s *TickerTestSuite) TestDeleteTickerSignalGroup() { + s.Run("when ticker not found", func() { + h := s.handler() + h.DeleteTickerSignalGroup(s.ctx) + + s.Equal(http.StatusNotFound, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns error", func() { + s.ctx.Set("ticker", storage.Ticker{}) + s.store.On("SaveTicker", mock.Anything).Return(errors.New("storage error")).Once() + h := s.handler() + h.DeleteTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns ticker", func() { + s.ctx.Set("ticker", storage.Ticker{}) + s.store.On("SaveTicker", mock.Anything).Return(nil).Once() + h := s.handler() + h.DeleteTickerSignalGroup(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.store.AssertExpectations(s.T()) + }) +} + func (s *TickerTestSuite) TestDeleteTicker() { s.Run("when ticker not found", func() { h := s.handler() diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go index 65e6864e..837c2956 100644 --- a/internal/bridge/bridge_test.go +++ b/internal/bridge/bridge_test.go @@ -53,6 +53,12 @@ func (s *BridgeTestSuite) SetupTest() { Handle: "handle", AppKey: "app_key", }, + SignalGroup: storage.TickerSignalGroup{ + Active: true, + GroupID: "group_id", + GroupName: "group_name", + GroupDescription: "group_description", + }, } messageWithoutBridges = storage.Message{ Text: "Hello World", @@ -90,6 +96,9 @@ func (s *BridgeTestSuite) SetupTest() { Uri: "at://did:plc:sample-uri", Cid: "cid", }, + SignalGroup: storage.SignalGroupMeta{ + Timestamp: 123, + }, } } @@ -141,7 +150,7 @@ func (s *BridgeTestSuite) TestDelete() { func (s *BridgeTestSuite) TestRegisterBridges() { bridges := RegisterBridges(config.Config{}, nil) - s.Equal(3, len(bridges)) + s.Equal(4, len(bridges)) } func TestBrigde(t *testing.T) { diff --git a/internal/bridge/signal_group.go b/internal/bridge/signal_group.go index ea89c232..2438c60f 100644 --- a/internal/bridge/signal_group.go +++ b/internal/bridge/signal_group.go @@ -78,7 +78,7 @@ func (sb *SignalGroupBridge) Send(ticker storage.Ticker, message *storage.Messag } func (sb *SignalGroupBridge) Delete(ticker storage.Ticker, message *storage.Message) error { - if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active || message.SignalGroup.Timestamp != 0 { + if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active || message.SignalGroup.Timestamp == 0 { return nil } diff --git a/internal/bridge/signal_group_test.go b/internal/bridge/signal_group_test.go new file mode 100644 index 00000000..8aa9f0ef --- /dev/null +++ b/internal/bridge/signal_group_test.go @@ -0,0 +1,185 @@ +package bridge + +import ( + "github.com/h2non/gock" + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/storage" +) + +func (s *BridgeTestSuite) TestSignalGroupSend() { + s.Run("when signalGroup is inactive", func() { + bridge := s.signalGroupBridge(config.Config{}, &storage.MockStorage{}) + + err := bridge.Send(tickerWithoutBridges, &messageWithoutBridges) + s.NoError(err) + }) + + s.Run("when signalGroup is active but signal-cli api fails", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(500) + + err := bridge.Send(tickerWithBridges, &storage.Message{}) + s.Error(err) + s.True(gock.IsDone()) + }) + + s.Run("when response timestamp == 0", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]int{ + "timestamp": 0, + }, + "id": 1, + }) + + err := bridge.Send(tickerWithBridges, &storage.Message{}) + s.Error(err) + s.True(gock.IsDone()) + }) + + s.Run("send message with attachment file not found", func() { + mockStorage := &storage.MockStorage{} + mockStorage.On("FindUploadByUUID", "123").Return(storage.Upload{UUID: "123", ContentType: "image/gif"}, nil).Once() + mockStorage.On("FindUploadByUUID", "456").Return(storage.Upload{UUID: "456", ContentType: "image/jpeg"}, nil).Once() + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, mockStorage) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]int{ + "timestamp": 1, + }, + "id": 1, + }) + + err := bridge.Send(tickerWithBridges, &messageWithBridges) + s.NoError(err) + s.True(gock.IsDone()) + s.True(mockStorage.AssertExpectations(s.T())) + }) + + s.Run("send message without attachments", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]int{ + "timestamp": 1, + }, + "id": 1, + }) + + err := bridge.Send(tickerWithBridges, &storage.Message{}) + s.NoError(err) + s.True(gock.IsDone()) + }) +} + +func (s *BridgeTestSuite) TestSignalDelete() { + s.Run("when signal not connected", func() { + bridge := s.signalGroupBridge(config.Config{}, &storage.MockStorage{}) + + err := bridge.Delete(tickerWithoutBridges, &messageWithoutBridges) + s.NoError(err) + }) + + s.Run("when message has no signal meta", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + err := bridge.Delete(tickerWithBridges, &messageWithoutBridges) + s.NoError(err) + }) + + s.Run("when signal is inactive", func() { + bridge := s.signalGroupBridge(config.Config{}, &storage.MockStorage{}) + + err := bridge.Delete(tickerWithBridges, &messageWithoutBridges) + s.NoError(err) + }) + + s.Run("when delete fails", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(500) + + err := bridge.Delete(tickerWithBridges, &messageWithBridges) + s.Error(err) + s.True(gock.IsDone()) + }) + + s.Run("happy path", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]int{ + "timestamp": 1, + }, + "id": 1, + }) + + err := bridge.Delete(tickerWithBridges, &messageWithBridges) + s.NoError(err) + s.True(gock.IsDone()) + }) +} + +func (s *BridgeTestSuite) signalGroupBridge(config config.Config, storage storage.Storage) *SignalGroupBridge { + return &SignalGroupBridge{ + config: config, + storage: storage, + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6285376e..8c83ca34 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -47,6 +47,9 @@ func (s *ConfigTestSuite) TestConfig() { s.Equal("http://localhost:8080", c.Upload.URL) s.Empty(c.Telegram.Token) s.False(c.Telegram.Enabled()) + s.Empty(c.SignalGroup.ApiUrl) + s.Empty(c.SignalGroup.Account) + s.False(c.SignalGroup.Enabled()) }) s.Run("loads config from env", func() { @@ -66,6 +69,8 @@ func (s *ConfigTestSuite) TestConfig() { s.Equal(s.envs["TICKER_UPLOAD_PATH"], c.Upload.Path) s.Equal(s.envs["TICKER_UPLOAD_URL"], c.Upload.URL) s.Equal(s.envs["TICKER_TELEGRAM_TOKEN"], c.Telegram.Token) + s.Equal(s.envs["TICKER_SIGNAL_GROUP_API_URL"], c.SignalGroup.ApiUrl) + s.Equal(s.envs["TICKER_SIGNAL_GROUP_ACCOUNT"], c.SignalGroup.Account) s.True(c.Telegram.Enabled()) for key := range s.envs { diff --git a/internal/storage/sql_storage_test.go b/internal/storage/sql_storage_test.go index a542a2b6..caa2c8a5 100644 --- a/internal/storage/sql_storage_test.go +++ b/internal/storage/sql_storage_test.go @@ -30,6 +30,7 @@ func (s *SqlStorageTestSuite) SetupTest() { &TickerTelegram{}, &TickerMastodon{}, &TickerBluesky{}, + &TickerSignalGroup{}, &User{}, &Message{}, &Upload{}, @@ -46,6 +47,8 @@ func (s *SqlStorageTestSuite) BeforeTest(suiteName, testName string) { s.NoError(s.db.Exec("DELETE FROM tickers").Error) s.NoError(s.db.Exec("DELETE FROM ticker_mastodons").Error) s.NoError(s.db.Exec("DELETE FROM ticker_telegrams").Error) + s.NoError(s.db.Exec("DELETE FROM ticker_blueskies").Error) + s.NoError(s.db.Exec("DELETE FROM ticker_signal_groups").Error) s.NoError(s.db.Exec("DELETE FROM settings").Error) s.NoError(s.db.Exec("DELETE FROM uploads").Error) } diff --git a/internal/storage/ticker.go b/internal/storage/ticker.go index 914d2d33..8aab99ab 100644 --- a/internal/storage/ticker.go +++ b/internal/storage/ticker.go @@ -36,6 +36,7 @@ func (t *Ticker) Reset() { t.Telegram.Reset() t.Mastodon.Reset() t.Bluesky.Reset() + t.SignalGroup.Reset() } func (t *Ticker) AsMap() map[string]interface{} { diff --git a/internal/storage/ticker_test.go b/internal/storage/ticker_test.go index f58cb0ce..5f0117ed 100644 --- a/internal/storage/ticker_test.go +++ b/internal/storage/ticker_test.go @@ -30,6 +30,14 @@ func TestTickerBlueskyConnected(t *testing.T) { assert.True(t, ticker.Bluesky.Connected()) } +func TestTickerSignalGroupConnect(t *testing.T) { + assert.False(t, ticker.SignalGroup.Connected()) + + ticker.SignalGroup.GroupID = "GroupID" + + assert.True(t, ticker.SignalGroup.Connected()) +} + func TestTickerReset(t *testing.T) { ticker.Active = true ticker.Description = "Description" @@ -38,6 +46,8 @@ func TestTickerReset(t *testing.T) { ticker.Information.Twitter = "Twitter" ticker.Telegram.Active = true ticker.Telegram.ChannelName = "ChannelName" + ticker.SignalGroup.Active = true + ticker.SignalGroup.GroupID = "GroupID" ticker.Location.Lat = 1 ticker.Location.Lon = 2 @@ -50,6 +60,7 @@ func TestTickerReset(t *testing.T) { assert.Empty(t, ticker.Information.Email) assert.Empty(t, ticker.Information.Twitter) assert.Empty(t, ticker.Telegram.ChannelName) + assert.Empty(t, ticker.SignalGroup.GroupID) assert.Empty(t, ticker.Location) }