From 5e6ac5f0498dc649bd64378aaa5d9fc97849e713 Mon Sep 17 00:00:00 2001 From: louis Date: Thu, 23 Jan 2025 18:30:02 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Extract=20Websites=20from=20Ticker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/api.go | 2 + internal/api/init_test.go | 2 +- internal/api/response/init.go | 2 - internal/api/response/init_test.go | 1 - internal/api/response/ticker.go | 19 +++- internal/api/response/ticker_test.go | 8 +- internal/api/tickers.go | 58 ++++++++++-- internal/api/tickers_test.go | 126 ++++++++++++++++++++++----- internal/storage/migrations.go | 30 ++++++- internal/storage/migrations_test.go | 46 ++++++++++ internal/storage/mock_Storage.go | 54 ++++++++++++ internal/storage/sql_storage.go | 82 ++++++++++++++--- internal/storage/sql_storage_test.go | 77 +++++++++++++++- internal/storage/storage.go | 3 + internal/storage/ticker.go | 13 ++- 15 files changed, 466 insertions(+), 57 deletions(-) create mode 100644 internal/storage/migrations_test.go diff --git a/internal/api/api.go b/internal/api/api.go index ed63c880..224aef52 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -67,6 +67,8 @@ func API(config config.Config, store storage.Storage, log *logrus.Logger) *gin.E admin.GET(`/tickers/:tickerID`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.GetTicker) admin.POST(`/tickers`, user.NeedAdmin(), handler.PostTicker) admin.PUT(`/tickers/:tickerID`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTicker) + admin.DELETE(`/tickers/:tickerID/websites/:domain`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerWebsite) + admin.POST(`/tickers/:tickerID/websites`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PostTickerWebsite) admin.PUT(`/tickers/:tickerID/telegram`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerTelegram) admin.DELETE(`/tickers/:tickerID/telegram`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerTelegram) admin.PUT(`/tickers/:tickerID/mastodon`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerMastodon) diff --git a/internal/api/init_test.go b/internal/api/init_test.go index 1e52c4b4..9e3cf612 100644 --- a/internal/api/init_test.go +++ b/internal/api/init_test.go @@ -39,7 +39,7 @@ func (s *InitTestSuite) TestGetInit() { s.Equal(http.StatusOK, s.w.Code) s.Equal(`{"data":{"settings":{"refreshInterval":10000},"ticker":null},"status":"success","error":{}}`, s.w.Body.String()) - s.store.AssertNotCalled(s.T(), "FindTickerByDomain", mock.AnythingOfType("string"), mock.Anything) + s.store.AssertNotCalled(s.T(), "FindTickerByDomain", "", mock.Anything) s.store.AssertExpectations(s.T()) }) diff --git a/internal/api/response/init.go b/internal/api/response/init.go index 1d218749..5815367e 100644 --- a/internal/api/response/init.go +++ b/internal/api/response/init.go @@ -9,7 +9,6 @@ import ( type InitTicker struct { ID int `json:"id"` CreatedAt time.Time `json:"createdAt"` - Domain string `json:"domain"` Title string `json:"title"` Description string `json:"description"` Information InitTickerInformation `json:"information"` @@ -30,7 +29,6 @@ func InitTickerResponse(ticker storage.Ticker) InitTicker { return InitTicker{ ID: ticker.ID, CreatedAt: ticker.CreatedAt, - Domain: ticker.Domain, Title: ticker.Title, Description: ticker.Description, Information: InitTickerInformation{ diff --git a/internal/api/response/init_test.go b/internal/api/response/init_test.go index 19078206..1fe78fe4 100644 --- a/internal/api/response/init_test.go +++ b/internal/api/response/init_test.go @@ -35,7 +35,6 @@ func (s *InitTickerResponseTestSuite) TestInitTickerResponse() { s.Equal(ticker.ID, response.ID) s.Equal(ticker.CreatedAt, response.CreatedAt) - s.Equal(ticker.Domain, response.Domain) s.Equal(ticker.Title, response.Title) s.Equal(ticker.Description, response.Description) s.Equal(ticker.Information.Author, response.Information.Author) diff --git a/internal/api/response/ticker.go b/internal/api/response/ticker.go index 7dd7eeb0..d06e8fc2 100644 --- a/internal/api/response/ticker.go +++ b/internal/api/response/ticker.go @@ -10,11 +10,11 @@ import ( type Ticker struct { ID int `json:"id"` CreatedAt time.Time `json:"createdAt"` - Domain string `json:"domain"` Title string `json:"title"` Description string `json:"description"` Active bool `json:"active"` Information Information `json:"information"` + Websites []Website `json:"websites"` Telegram Telegram `json:"telegram"` Mastodon Mastodon `json:"mastodon"` Bluesky Bluesky `json:"bluesky"` @@ -33,6 +33,12 @@ type Information struct { Bluesky string `json:"bluesky"` } +type Website struct { + ID int `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Domain string `json:"domain"` +} + type Telegram struct { Active bool `json:"active"` Connected bool `json:"connected"` @@ -69,10 +75,18 @@ type Location struct { } func TickerResponse(t storage.Ticker, config config.Config) Ticker { + websites := make([]Website, 0) + for _, website := range t.Websites { + websites = append(websites, Website{ + ID: website.ID, + CreatedAt: website.CreatedAt, + Domain: website.Domain, + }) + } + return Ticker{ ID: t.ID, CreatedAt: t.CreatedAt, - Domain: t.Domain, Title: t.Title, Description: t.Description, Active: t.Active, @@ -86,6 +100,7 @@ func TickerResponse(t storage.Ticker, config config.Config) Ticker { Mastodon: t.Information.Mastodon, Bluesky: t.Information.Bluesky, }, + Websites: websites, Telegram: Telegram{ Active: t.Telegram.Active, Connected: t.Telegram.Connected(), diff --git a/internal/api/response/ticker_test.go b/internal/api/response/ticker_test.go index 8959c85f..bac3a4be 100644 --- a/internal/api/response/ticker_test.go +++ b/internal/api/response/ticker_test.go @@ -32,6 +32,11 @@ func (s *TickersResponseTestSuite) TestTickersResponse() { Mastodon: "https://systemli.social/@example", Bluesky: "https://example.com", }, + Websites: []storage.TickerWebsite{ + { + Domain: "example.com", + }, + }, Telegram: storage.TickerTelegram{ Active: true, ChannelName: "example", @@ -69,7 +74,6 @@ func (s *TickersResponseTestSuite) TestTickersResponse() { s.Equal(1, len(tickerResponse)) s.Equal(ticker.ID, tickerResponse[0].ID) s.Equal(ticker.CreatedAt, tickerResponse[0].CreatedAt) - s.Equal(ticker.Domain, tickerResponse[0].Domain) s.Equal(ticker.Title, tickerResponse[0].Title) s.Equal(ticker.Description, tickerResponse[0].Description) s.Equal(ticker.Active, tickerResponse[0].Active) @@ -81,6 +85,8 @@ func (s *TickersResponseTestSuite) TestTickersResponse() { s.Equal(ticker.Information.Telegram, tickerResponse[0].Information.Telegram) s.Equal(ticker.Information.Mastodon, tickerResponse[0].Information.Mastodon) s.Equal(ticker.Information.Bluesky, tickerResponse[0].Information.Bluesky) + s.Equal(1, len(ticker.Websites)) + s.Equal(ticker.Websites[0].Domain, tickerResponse[0].Websites[0].Domain) s.Equal(ticker.Telegram.Active, tickerResponse[0].Telegram.Active) s.Equal(ticker.Telegram.Connected(), tickerResponse[0].Telegram.Connected) s.Equal(config.Telegram.User.UserName, tickerResponse[0].Telegram.BotUsername) diff --git a/internal/api/tickers.go b/internal/api/tickers.go index 8ee7b85b..8749b9df 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -130,6 +130,53 @@ func (h *handler) PutTickerUsers(c *gin.Context) { c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"users": response.UsersResponse(ticker.Users)})) } +func (h *handler) PostTickerWebsite(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + var body struct { + Domain string `json:"domain" binding:"required"` + } + + err = c.Bind(&body) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.FormError)) + return + } + + err = h.storage.SaveTickerWebsite(&ticker, body.Domain) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) + return + } + + h.ClearTickerCache(&ticker) + + c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) +} + +func (h *handler) DeleteTickerWebsite(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + domain := c.Param("domain") + err = h.storage.DeleteTickerWebsite(&ticker, domain) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) + return + } + + h.ClearTickerCache(&ticker) + + c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) +} + func (h *handler) PutTickerTelegram(c *gin.Context) { ticker, err := helper.Ticker(c) if err != nil { @@ -450,8 +497,10 @@ func (h *handler) ResetTicker(c *gin.Context) { // ClearTickerCache clears the cache for the init endpoint of a ticker func (h *handler) ClearTickerCache(ticker *storage.Ticker) { h.cache.Range(func(key, value interface{}) bool { - if strings.HasPrefix(key.(string), fmt.Sprintf("response:%s:/v1/init", ticker.Domain)) { - h.cache.Delete(key) + for _, website := range ticker.Websites { + if strings.HasPrefix(key.(string), fmt.Sprintf("response:%s:/v1/init", website.Domain)) { + h.cache.Delete(key) + } } return true }) @@ -459,7 +508,6 @@ func (h *handler) ClearTickerCache(ticker *storage.Ticker) { func updateTicker(t *storage.Ticker, c *gin.Context) error { var body struct { - Domain string `json:"domain" binding:"required"` Title string `json:"title" binding:"required"` Description string `json:"description"` Active bool `json:"active"` @@ -484,10 +532,6 @@ func updateTicker(t *storage.Ticker, c *gin.Context) error { return err } - me, _ := helper.Me(c) - if me.IsSuperAdmin { - t.Domain = body.Domain - } t.Title = body.Title t.Description = body.Description t.Active = body.Active diff --git a/internal/api/tickers_test.go b/internal/api/tickers_test.go index 88006506..981c346f 100644 --- a/internal/api/tickers_test.go +++ b/internal/api/tickers_test.go @@ -177,7 +177,7 @@ func (s *TickerTestSuite) TestPutTicker() { s.Run("when storage returns error", func() { s.ctx.Set("ticker", storage.Ticker{}) - body := `{"domain":"localhost","title":"title","description":"description"}` + body := `{"title":"title","description":"description"}` s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1", strings.NewReader(body)) s.ctx.Request.Header.Add("Content-Type", "application/json") s.store.On("SaveTicker", mock.Anything).Return(errors.New("storage error")).Once() @@ -188,28 +188,11 @@ func (s *TickerTestSuite) TestPutTicker() { s.store.AssertExpectations(s.T()) }) - s.Run("user tries to update the domain", func() { - s.ctx.Set("ticker", storage.Ticker{Domain: "localhost"}) - s.cache.Set("response:localhost:/v1/init", true, time.Minute) - s.ctx.Set("me", storage.User{IsSuperAdmin: false}) - body := `{"domain":"new_domain","title":"title","description":"description"}` - s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1", strings.NewReader(body)) - s.ctx.Request.Header.Add("Content-Type", "application/json") - ticker := &storage.Ticker{Domain: "localhost", Title: "title", Description: "description"} - s.store.On("SaveTicker", ticker).Return(nil).Once() - h := s.handler() - h.PutTicker(s.ctx) - - s.Equal(http.StatusOK, s.w.Code) - s.Nil(s.cache.Get("response:localhost:/v1/init")) - s.store.AssertExpectations(s.T()) - }) - s.Run("happy path", func() { - s.ctx.Set("ticker", storage.Ticker{}) + s.ctx.Set("ticker", storage.Ticker{Websites: []storage.TickerWebsite{{Domain: "localhost"}}}) s.ctx.Set("me", storage.User{IsSuperAdmin: true}) s.cache.Set("response:localhost:/v1/init", true, time.Minute) - body := `{"domain":"localhost","title":"title","description":"description"}` + body := `{"title":"title","description":"description"}` s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1", strings.NewReader(body)) s.ctx.Request.Header.Add("Content-Type", "application/json") s.store.On("SaveTicker", mock.Anything).Return(nil).Once() @@ -269,6 +252,97 @@ func (s *TickerTestSuite) TestPutTickerUsers() { }) } +func (s *TickerTestSuite) TestPostTickerWebsite() { + s.Run("when ticker not found", func() { + h := s.handler() + h.PostTickerWebsite(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.MethodPost, "/v1/admin/tickers/1/websites", nil) + s.ctx.Request.Header.Add("Content-Type", "application/json") + h := s.handler() + h.PostTickerWebsite(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 := `{"domain":"example.org"}` + s.ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/admin/tickers/1/websites", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTickerWebsite", mock.Anything, "example.org").Return(errors.New("storage error")).Once() + + h := s.handler() + h.PostTickerWebsite(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns ticker", func() { + s.cache.Set("response:example.org:/v1/init", true, time.Minute) + s.ctx.Set("ticker", storage.Ticker{Websites: []storage.TickerWebsite{{Domain: "example.org"}}}) + body := `{"domain":"example.org"}` + s.ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/admin/tickers/1/websites", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTickerWebsite", mock.Anything, "example.org").Return(nil).Once() + + h := s.handler() + h.PostTickerWebsite(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.store.AssertExpectations(s.T()) + s.Nil(s.cache.Get("response:example.org:/v1/init")) + }) +} + +func (s *TickerTestSuite) TestDeleteTickerWebsite() { + s.Run("when ticker not found", func() { + h := s.handler() + h.DeleteTickerWebsite(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.ctx.Params = gin.Params{{Key: "domain", Value: "example.org"}} + s.ctx.Request = httptest.NewRequest(http.MethodDelete, "/v1/admin/tickers/1/websites/example.org", nil) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("DeleteTickerWebsite", mock.Anything, "example.org").Return(errors.New("storage error")).Once() + + h := s.handler() + h.DeleteTickerWebsite(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns ticker", func() { + s.cache.Set("response:example.org:/v1/init", true, time.Minute) + s.ctx.Set("ticker", storage.Ticker{Websites: []storage.TickerWebsite{{Domain: "example.org"}}}) + s.ctx.Params = gin.Params{{Key: "domain", Value: "example.org"}} + s.ctx.Request = httptest.NewRequest(http.MethodDelete, "/v1/admin/tickers/1/websites/example.org", nil) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("DeleteTickerWebsite", mock.Anything, "example.org").Return(nil).Once() + + h := s.handler() + h.DeleteTickerWebsite(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.store.AssertExpectations(s.T()) + s.Nil(s.cache.Get("response:example.org:/v1/init")) + }) +} + func (s *TickerTestSuite) TestPutTickerTelegram() { s.Run("when ticker not found", func() { h := s.handler() @@ -1166,13 +1240,15 @@ func (s *TickerTestSuite) TestDeleteTicker() { s.Run("happy path", func() { s.cache.Set("response:localhost:/v1/init", true, time.Minute) - s.ctx.Set("ticker", storage.Ticker{Domain: "localhost"}) + s.ctx.Set("ticker", storage.Ticker{Websites: []storage.TickerWebsite{{Domain: "localhost"}}}) s.store.On("DeleteTicker", mock.Anything).Return(nil) h := s.handler() h.DeleteTicker(s.ctx) s.Equal(http.StatusOK, s.w.Code) - s.Nil(s.cache.Get("response:localhost:/v1/init")) + item, found := s.cache.Get("response:localhost:/v1/init") + s.Nil(item) + s.False(found) s.store.AssertExpectations(s.T()) }) } @@ -1252,13 +1328,15 @@ func (s *TickerTestSuite) TestResetTicker() { s.Run("happy path", func() { s.cache.Set("response:localhost:/v1/init", true, time.Minute) - s.ctx.Set("ticker", storage.Ticker{Domain: "localhost"}) + s.ctx.Set("ticker", storage.Ticker{Websites: []storage.TickerWebsite{{Domain: "localhost"}}}) s.store.On("ResetTicker", mock.Anything).Return(nil).Once() h := s.handler() h.ResetTicker(s.ctx) s.Equal(http.StatusOK, s.w.Code) - s.Nil(s.cache.Get("response:localhost:/v1/init")) + item, found := s.cache.Get("response:localhost:/v1/init") + s.Nil(item) + s.False(found) s.store.AssertExpectations(s.T()) }) } diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 013ec8a2..77b9f0ee 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -4,16 +4,42 @@ import "gorm.io/gorm" // MigrateDB migrates the database func MigrateDB(db *gorm.DB) error { - return db.AutoMigrate( + if err := db.AutoMigrate( &Ticker{}, &TickerMastodon{}, &TickerTelegram{}, &TickerBluesky{}, &TickerSignalGroup{}, + &TickerWebsite{}, &User{}, &Setting{}, &Upload{}, &Message{}, &Attachment{}, - ) + ); err != nil { + return err + } + + // Migrate all Ticker.Domain to TickerWebsite + var tickers []Ticker + if err := db.Find(&tickers).Error; err != nil { + return err + } + + for _, ticker := range tickers { + if ticker.Domain != "" { + if err := db.Create(&TickerWebsite{ + TickerID: ticker.ID, + Domain: ticker.Domain, + }).Error; err != nil { + return err + } + + if err := db.Model(&ticker).Update("Domain", "").Error; err != nil { + return err + } + } + } + + return nil } diff --git a/internal/storage/migrations_test.go b/internal/storage/migrations_test.go new file mode 100644 index 00000000..905ded21 --- /dev/null +++ b/internal/storage/migrations_test.go @@ -0,0 +1,46 @@ +package storage + +import ( + "github.com/stretchr/testify/suite" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "testing" +) + +type MigrationTestSuite struct { + db *gorm.DB + suite.Suite +} + +func (s *MigrationTestSuite) SetupSuite() { + db, err := gorm.Open(sqlite.Open("file:testdatabase?mode=memory&cache=shared"), &gorm.Config{}) + s.NoError(err) + + s.db = db +} + +func (s *MigrationTestSuite) TestMigrateDB() { + s.Run("without existing data", func() { + err := MigrateDB(s.db) + s.NoError(err) + }) + + s.Run("with existing data", func() { + ticker := Ticker{Domain: "example.org"} + err := s.db.Create(&ticker).Error + s.NoError(err) + + err = MigrateDB(s.db) + s.NoError(err) + + var tickerWebsite TickerWebsite + err = s.db.First(&tickerWebsite).Error + s.NoError(err) + s.Equal(tickerWebsite.TickerID, ticker.ID) + s.Equal(tickerWebsite.Domain, "example.org") + }) +} + +func TestMigrationTestSuite(t *testing.T) { + suite.Run(t, new(MigrationTestSuite)) +} diff --git a/internal/storage/mock_Storage.go b/internal/storage/mock_Storage.go index cfa9417f..db9d0d2c 100644 --- a/internal/storage/mock_Storage.go +++ b/internal/storage/mock_Storage.go @@ -211,6 +211,42 @@ func (_m *MockStorage) DeleteTickerUsers(ticker *Ticker) error { return r0 } +// DeleteTickerWebsite provides a mock function with given fields: ticker, domain +func (_m *MockStorage) DeleteTickerWebsite(ticker *Ticker, domain string) error { + ret := _m.Called(ticker, domain) + + if len(ret) == 0 { + panic("no return value specified for DeleteTickerWebsite") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*Ticker, string) error); ok { + r0 = rf(ticker, domain) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteTickerWebsites provides a mock function with given fields: ticker +func (_m *MockStorage) DeleteTickerWebsites(ticker *Ticker) error { + ret := _m.Called(ticker) + + if len(ret) == 0 { + panic("no return value specified for DeleteTickerWebsites") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*Ticker) error); ok { + r0 = rf(ticker) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DeleteUpload provides a mock function with given fields: upload func (_m *MockStorage) DeleteUpload(upload Upload) error { ret := _m.Called(upload) @@ -923,6 +959,24 @@ func (_m *MockStorage) SaveTicker(ticker *Ticker) error { return r0 } +// SaveTickerWebsite provides a mock function with given fields: ticker, domain +func (_m *MockStorage) SaveTickerWebsite(ticker *Ticker, domain string) error { + ret := _m.Called(ticker, domain) + + if len(ret) == 0 { + panic("no return value specified for SaveTickerWebsite") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*Ticker, string) error); ok { + r0 = rf(ticker, domain) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SaveUpload provides a mock function with given fields: upload func (_m *MockStorage) SaveUpload(upload *Upload) error { ret := _m.Called(upload) diff --git a/internal/storage/sql_storage.go b/internal/storage/sql_storage.go index 3349f0d9..3c1d5891 100644 --- a/internal/storage/sql_storage.go +++ b/internal/storage/sql_storage.go @@ -10,6 +10,11 @@ import ( "gorm.io/gorm/clause" ) +const ( + EqualTickerID = "ticker_id = ?" + EqualName = "name = ?" +) + type SqlStorage struct { DB *gorm.DB uploadPath string @@ -161,7 +166,9 @@ func (s *SqlStorage) FindTickerByDomain(domain string, opts ...func(*gorm.DB) *g var ticker Ticker db := s.prepareDb(opts...) - err := db.First(&ticker, "domain = ?", domain).Error + err := db.Joins("JOIN ticker_websites ON tickers.id = ticker_websites.ticker_id"). + Where("ticker_websites.domain = ?", domain). + First(&ticker).Error return ticker, err } @@ -198,6 +205,48 @@ func (s *SqlStorage) DeleteTicker(ticker *Ticker) error { return s.DB.Delete(&ticker).Error } +func (s *SqlStorage) SaveTickerWebsite(ticker *Ticker, domain string) error { + err := s.DB.Create(&TickerWebsite{ + TickerID: ticker.ID, + Domain: domain, + }).Error + + if err != nil { + return err + } + + return s.findTickerWebsites(ticker) +} + +func (s *SqlStorage) DeleteTickerWebsite(ticker *Ticker, domain string) error { + err := s.DB.Delete(TickerWebsite{}, "ticker_id = ? AND domain = ?", ticker.ID, domain).Error + if err != nil { + return err + } + + return s.findTickerWebsites(ticker) +} + +func (s *SqlStorage) DeleteTickerWebsites(ticker *Ticker) error { + if err := s.DB.Delete(TickerWebsite{}, EqualTickerID, ticker.ID).Error; err != nil { + return err + } + + return s.findTickerWebsites(ticker) +} + +func (s *SqlStorage) findTickerWebsites(ticker *Ticker) error { + var websites []TickerWebsite + + if err := s.DB.Model(&ticker).Association("Websites").Find(&websites); err != nil { + return err + } + + ticker.Websites = websites + + return nil +} + func (s *SqlStorage) ResetTicker(ticker *Ticker) error { if err := s.deleteTickerAssociations(ticker); err != nil { return err @@ -219,6 +268,11 @@ func (s *SqlStorage) deleteTickerAssociations(ticker *Ticker) error { return err } + if err := s.DeleteTickerWebsites(ticker); err != nil { + log.WithError(err).WithField("ticker_id", ticker.ID).Error("failed to delete ticker websites") + return err + } + if err := s.DeleteUploadsByTicker(ticker); err != nil { log.WithError(err).WithField("ticker_id", ticker.ID).Error("failed to delete ticker uploads") return err @@ -260,25 +314,25 @@ func (s *SqlStorage) DeleteIntegrations(ticker *Ticker) error { func (s *SqlStorage) DeleteMastodon(ticker *Ticker) error { ticker.Mastodon = TickerMastodon{} - return s.DB.Delete(TickerMastodon{}, "ticker_id = ?", ticker.ID).Error + return s.DB.Delete(TickerMastodon{}, EqualTickerID, ticker.ID).Error } func (s *SqlStorage) DeleteTelegram(ticker *Ticker) error { ticker.Telegram = TickerTelegram{} - return s.DB.Delete(TickerTelegram{}, "ticker_id = ?", ticker.ID).Error + return s.DB.Delete(TickerTelegram{}, EqualTickerID, ticker.ID).Error } func (s *SqlStorage) DeleteBluesky(ticker *Ticker) error { ticker.Bluesky = TickerBluesky{} - return s.DB.Delete(TickerBluesky{}, "ticker_id = ?", ticker.ID).Error + return s.DB.Delete(TickerBluesky{}, EqualTickerID, ticker.ID).Error } func (s *SqlStorage) DeleteSignalGroup(ticker *Ticker) error { ticker.SignalGroup = TickerSignalGroup{} - return s.DB.Delete(TickerSignalGroup{}, "ticker_id = ?", ticker.ID).Error + return s.DB.Delete(TickerSignalGroup{}, EqualTickerID, ticker.ID).Error } func (s *SqlStorage) FindUploadByUUID(uuid string) (Upload, error) { @@ -328,7 +382,7 @@ func (s *SqlStorage) DeleteUploads(uploads []Upload) { func (s *SqlStorage) DeleteUploadsByTicker(ticker *Ticker) error { uploads := make([]Upload, 0) - s.DB.Model(&Upload{}).Where("ticker_id = ?", ticker.ID).Find(&uploads) + s.DB.Model(&Upload{}).Where(EqualTickerID, ticker.ID).Find(&uploads) for _, upload := range uploads { if err := s.DeleteUpload(upload); err != nil { @@ -352,7 +406,7 @@ func (s *SqlStorage) FindMessagesByTicker(ticker Ticker, opts ...func(*gorm.DB) messages := make([]Message, 0) db := s.prepareDb(opts...) - err := db.Model(&Message{}).Where("ticker_id = ?", ticker.ID).Find(&messages).Error + err := db.Model(&Message{}).Where(EqualTickerID, ticker.ID).Find(&messages).Error return messages, err } @@ -360,7 +414,7 @@ func (s *SqlStorage) FindMessagesByTicker(ticker Ticker, opts ...func(*gorm.DB) func (s *SqlStorage) FindMessagesByTickerAndPagination(ticker Ticker, pagination pagination.Pagination, opts ...func(*gorm.DB) *gorm.DB) ([]Message, error) { messages := make([]Message, 0) db := s.prepareDb(opts...) - query := db.Where("ticker_id = ?", ticker.ID) + query := db.Where(EqualTickerID, ticker.ID) if pagination.GetBefore() > 0 { query = query.Where("id < ?", pagination.GetBefore()) @@ -393,7 +447,7 @@ func (s *SqlStorage) DeleteMessage(message Message) error { func (s *SqlStorage) DeleteMessages(ticker *Ticker) error { var msgIds []int - err := s.DB.Model(&Message{}).Where("ticker_id = ?", ticker.ID).Pluck("id", &msgIds).Error + err := s.DB.Model(&Message{}).Where(EqualTickerID, ticker.ID).Pluck("id", &msgIds).Error if err != nil { return err } @@ -403,12 +457,12 @@ func (s *SqlStorage) DeleteMessages(ticker *Ticker) error { return err } - return s.DB.Where("ticker_id = ?", ticker.ID).Delete(&Message{}).Error + return s.DB.Where(EqualTickerID, ticker.ID).Delete(&Message{}).Error } func (s *SqlStorage) GetInactiveSettings() InactiveSettings { var setting Setting - err := s.DB.First(&setting, "name = ?", SettingInactiveName).Error + err := s.DB.First(&setting, EqualName, SettingInactiveName).Error if err != nil { return DefaultInactiveSettings() } @@ -424,7 +478,7 @@ func (s *SqlStorage) GetInactiveSettings() InactiveSettings { func (s *SqlStorage) GetRefreshIntervalSettings() RefreshIntervalSettings { var setting Setting - err := s.DB.First(&setting, "name = ?", SettingRefreshInterval).Error + err := s.DB.First(&setting, EqualName, SettingRefreshInterval).Error if err != nil { return DefaultRefreshIntervalSettings() } @@ -440,7 +494,7 @@ func (s *SqlStorage) GetRefreshIntervalSettings() RefreshIntervalSettings { func (s *SqlStorage) SaveInactiveSettings(inactiveSettings InactiveSettings) error { var setting Setting - err := s.DB.First(&setting, "name = ?", SettingInactiveName).Error + err := s.DB.First(&setting, EqualName, SettingInactiveName).Error if err != nil { setting = Setting{Name: SettingInactiveName} } @@ -456,7 +510,7 @@ func (s *SqlStorage) SaveInactiveSettings(inactiveSettings InactiveSettings) err func (s *SqlStorage) SaveRefreshIntervalSettings(refreshInterval RefreshIntervalSettings) error { var setting Setting - err := s.DB.First(&setting, "name = ?", SettingRefreshInterval).Error + err := s.DB.First(&setting, EqualName, SettingRefreshInterval).Error if err != nil { setting = Setting{Name: SettingRefreshInterval} } diff --git a/internal/storage/sql_storage_test.go b/internal/storage/sql_storage_test.go index 44784ec0..82d2bb5c 100644 --- a/internal/storage/sql_storage_test.go +++ b/internal/storage/sql_storage_test.go @@ -31,6 +31,7 @@ func (s *SqlStorageTestSuite) SetupTest() { &TickerMastodon{}, &TickerBluesky{}, &TickerSignalGroup{}, + &TickerWebsite{}, &User{}, &Message{}, &Upload{}, @@ -49,6 +50,7 @@ func (s *SqlStorageTestSuite) BeforeTest(suiteName, testName string) { 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 ticker_websites").Error) s.NoError(s.db.Exec("DELETE FROM settings").Error) s.NoError(s.db.Exec("DELETE FROM uploads").Error) } @@ -471,7 +473,7 @@ func (s *SqlStorageTestSuite) TestFindTickerByDomain() { s.Error(err) }) - ticker := Ticker{Domain: "systemli.org"} + ticker := Ticker{Websites: []TickerWebsite{{Domain: "systemli.org"}}} err := s.db.Create(&ticker).Error s.NoError(err) @@ -493,6 +495,7 @@ func (s *SqlStorageTestSuite) TestFindTickerByDomain() { s.NotNil(ticker) s.True(ticker.Mastodon.Active) s.True(ticker.Telegram.Active) + s.Equal("systemli.org", ticker.Websites[0].Domain) }) } @@ -990,6 +993,78 @@ func (s *SqlStorageTestSuite) TestDeleteTicker() { }) } +func (s *SqlStorageTestSuite) TestSaveTickerWebsite() { + ticker := Ticker{} + err := s.db.Create(&ticker).Error + s.NoError(err) + + s.Run("when ticker website is new", func() { + err = s.store.SaveTickerWebsite(&ticker, "example.org") + s.NoError(err) + + var count int64 + err = s.db.Model(&TickerWebsite{}).Count(&count).Error + s.NoError(err) + s.Equal(int64(1), count) + }) + + s.Run("when ticker website is existing", func() { + ticker.Websites = []TickerWebsite{{Domain: "example.com"}} + err := s.db.Save(&ticker).Error + s.NoError(err) + + err = s.store.SaveTickerWebsite(&ticker, "example.com") + s.Error(err) + }) +} + +func (s *SqlStorageTestSuite) TestDeleteTickerWebsite() { + ticker := Ticker{} + err := s.db.Create(&ticker).Error + s.NoError(err) + + s.Run("when ticker website does not exist", func() { + err := s.store.DeleteTickerWebsite(&ticker, "example.org") + s.NoError(err) + }) + + s.Run("when ticker website exists", func() { + ticker.Websites = []TickerWebsite{{Domain: "example.org"}} + err := s.db.Save(&ticker).Error + s.NoError(err) + + err = s.store.DeleteTickerWebsite(&ticker, "example.org") + s.NoError(err) + + var count int64 + err = s.db.Model(&TickerWebsite{}).Count(&count).Error + s.NoError(err) + s.Equal(int64(0), count) + }) +} + +func (s *SqlStorageTestSuite) TestFindTickerWebsites() { + s.Run("when no websites exist", func() { + ticker := Ticker{ID: 1} + err := s.store.findTickerWebsites(&ticker) + s.NoError(err) + }) + + s.Run("when websites exist", func() { + err := s.db.Create(&Ticker{Websites: []TickerWebsite{{Domain: "example.org"}}}).Error + s.NoError(err) + + var ticker Ticker + s.db.Model(&Ticker{}).Find(&ticker) + + s.Nil(ticker.Websites) + + err = s.store.findTickerWebsites(&ticker) + s.NoError(err) + s.Len(ticker.Websites, 1) + }) +} + func (s *SqlStorageTestSuite) TestFindUploadByUUID() { s.Run("when upload does not exist", func() { _, err := s.store.FindUploadByUUID("uuid") diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 52431e71..28ae09d3 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -26,6 +26,9 @@ type Storage interface { FindTickerByID(id int, opts ...func(*gorm.DB) *gorm.DB) (Ticker, error) SaveTicker(ticker *Ticker) error DeleteTicker(ticker *Ticker) error + SaveTickerWebsite(ticker *Ticker, domain string) error + DeleteTickerWebsite(ticker *Ticker, domain string) error + DeleteTickerWebsites(ticker *Ticker) error ResetTicker(ticker *Ticker) error DeleteIntegrations(ticker *Ticker) error DeleteMastodon(ticker *Ticker) error diff --git a/internal/storage/ticker.go b/internal/storage/ticker.go index d5d73878..1dd5455c 100644 --- a/internal/storage/ticker.go +++ b/internal/storage/ticker.go @@ -10,7 +10,7 @@ type Ticker struct { ID int `gorm:"primaryKey"` CreatedAt time.Time UpdatedAt time.Time - Domain string `gorm:"unique,index"` + Domain string Title string Description string Active bool @@ -20,7 +20,8 @@ type Ticker struct { Mastodon TickerMastodon Bluesky TickerBluesky SignalGroup TickerSignalGroup - Users []User `gorm:"many2many:ticker_users;"` + Websites []TickerWebsite `gorm:"foreignKey:TickerID;"` + Users []User `gorm:"many2many:ticker_users;"` } func NewTicker() Ticker { @@ -60,6 +61,14 @@ type TickerInformation struct { Bluesky string } +type TickerWebsite struct { + ID int `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + TickerID int `gorm:"index"` + Domain string `gorm:"unique;not null"` +} + type TickerTelegram struct { ID int `gorm:"primaryKey"` CreatedAt time.Time