From edfc34c9384f5c64bd0c6e4956464cdf8b176de7 Mon Sep 17 00:00:00 2001 From: ecrupper Date: Mon, 10 Nov 2025 10:45:03 -0600 Subject: [PATCH 1/4] init commit --- api/types/favorite.go | 78 ++++++++++++ api/user/add_favorite.go | 0 api/user/list_favorites.go | 54 +++++++++ api/user/save_favorites.go | 61 ++++++++++ constants/table.go | 3 + database/database.go | 2 + database/favorite/create.go | 28 +++++ database/favorite/create_test.go | 88 ++++++++++++++ database/favorite/delete.go | 27 +++++ database/favorite/favorite.go | 80 ++++++++++++ database/favorite/favorite_test.go | 182 ++++++++++++++++++++++++++++ database/favorite/index.go | 38 ++++++ database/favorite/interface.go | 32 +++++ database/favorite/list_user.go | 38 ++++++ database/favorite/list_user_test.go | 137 +++++++++++++++++++++ database/favorite/opts.go | 62 ++++++++++ database/favorite/table.go | 60 +++++++++ database/favorite/update.go | 59 +++++++++ database/favorite/update_test.go | 147 ++++++++++++++++++++++ database/interface.go | 4 + database/resource.go | 13 ++ database/testutils/api_resources.go | 7 ++ database/types/favorite.go | 47 +++++++ 23 files changed, 1247 insertions(+) create mode 100644 api/types/favorite.go create mode 100644 api/user/add_favorite.go create mode 100644 api/user/list_favorites.go create mode 100644 api/user/save_favorites.go create mode 100644 database/favorite/create.go create mode 100644 database/favorite/create_test.go create mode 100644 database/favorite/delete.go create mode 100644 database/favorite/favorite.go create mode 100644 database/favorite/favorite_test.go create mode 100644 database/favorite/index.go create mode 100644 database/favorite/interface.go create mode 100644 database/favorite/list_user.go create mode 100644 database/favorite/list_user_test.go create mode 100644 database/favorite/opts.go create mode 100644 database/favorite/table.go create mode 100644 database/favorite/update.go create mode 100644 database/favorite/update_test.go create mode 100644 database/types/favorite.go diff --git a/api/types/favorite.go b/api/types/favorite.go new file mode 100644 index 000000000..6d10c176e --- /dev/null +++ b/api/types/favorite.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "fmt" +) + +// Favorite is the API representation of a user's favorite. +// +// swagger:model Favorite +type Favorite struct { + Position *int64 `json:"position,omitempty"` + Repo *string `json:"repo,omitempty"` +} + +// GetPosition returns the Position field. +// +// When the provided Favorite type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (f *Favorite) GetPosition() int64 { + // return zero value if Favorite type or Position field is nil + if f == nil || f.Position == nil { + return 0 + } + + return *f.Position +} + +// GetRepo returns the Repo field. +// +// When the provided Favorite type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (f *Favorite) GetRepo() string { + // return zero value if Favorite type or Repo field is nil + if f == nil || f.Repo == nil { + return "" + } + + return *f.Repo +} + +// SetPosition sets the Position field. +// +// When the provided Favorite type is nil, it +// will set nothing and immediately return. +func (f *Favorite) SetPosition(v int64) { + // return if Favorite type is nil + if f == nil { + return + } + + f.Position = &v +} + +// SetRepo sets the Repo field. +// +// When the provided Favorite type is nil, it +// will set nothing and immediately return. +func (f *Favorite) SetRepo(v string) { + // return if Favorite type is nil + if f == nil { + return + } + + f.Repo = &v +} + +// String implements the Stringer interface for the Favorite type. +func (f *Favorite) String() string { + return fmt.Sprintf(`{ + Position: %d, + Repo: %s, +}`, + f.GetPosition(), + f.GetRepo(), + ) +} diff --git a/api/user/add_favorite.go b/api/user/add_favorite.go new file mode 100644 index 000000000..e69de29bb diff --git a/api/user/list_favorites.go b/api/user/list_favorites.go new file mode 100644 index 000000000..31daae459 --- /dev/null +++ b/api/user/list_favorites.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package user + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// swagger:operation GET /api/v1/user/favorites users GetUserFavorites +// +// Get the current authenticated user's favorites +// +// --- +// produces: +// - application/json +// security: +// - ApiKeyAuth: [] +// responses: +// '200': +// description: Successfully retrieved the current user's favorites +// schema: +// type: array +// items: +// "$ref": "#/definitions/Favorite" +// '401': +// description: Unauthorized +// schema: +// "$ref": "#/definitions/Error" + +// GetUserFavorites represents the API handler to capture the +// currently authenticated user's favorites. +func GetUserFavorites(c *gin.Context) { + // capture middleware values + u := user.Retrieve(c) + ctx := c.Request.Context() + + favorites, err := database.FromContext(c).ListUserFavorites(ctx, u) + if err != nil { + retErr := fmt.Errorf("unable to get favorites for user %s: %w", u.GetName(), err) + + util.HandleError(ctx, http.StatusInternalServerError, retErr) + + return + } + + c.JSON(http.StatusOK, favorites) +} diff --git a/api/user/save_favorites.go b/api/user/save_favorites.go new file mode 100644 index 000000000..1fbb57f0f --- /dev/null +++ b/api/user/save_favorites.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +package user + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/go-vela/server/api/types" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// swagger:operation PUT /api/v1/user/favorites users SaveUserFavorites +// +// Save the current authenticated user's favorites +// +// --- +// produces: +// - application/json +// security: +// - ApiKeyAuth: [] +// responses: +// '204': +// description: Successfully saved the current user's favorites +// '401': +// description: Unauthorized +// schema: +// "$ref": "#/definitions/Error" + +// SaveUserFavorites represents the API handler to save the +// currently authenticated user's favorites. +func SaveUserFavorites(c *gin.Context) { + // capture middleware values + u := user.Retrieve(c) + ctx := c.Request.Context() + + favorites := new([]*types.Favorite) + + err := c.Bind(favorites) + if err != nil { + retErr := err + + util.HandleError(ctx, http.StatusBadRequest, retErr) + + return + } + + err = database.FromContext(c).UpdateFavorites(ctx, u, *favorites) + if err != nil { + retErr := err + + util.HandleError(ctx, http.StatusInternalServerError, retErr) + + return + } + + c.JSON(http.StatusOK, favorites) +} diff --git a/constants/table.go b/constants/table.go index cd1000469..677dc8919 100644 --- a/constants/table.go +++ b/constants/table.go @@ -16,6 +16,9 @@ const ( // TableDeployment defines the table type for the database deployments table. TableDeployment = "deployments" + // TableFavorite defines the table type for the database favorites table. + TableFavorite = "favorites" + // TableHook defines the table type for the database hooks table. TableHook = "hooks" diff --git a/database/database.go b/database/database.go index 63c7e9cb5..e49e55b43 100644 --- a/database/database.go +++ b/database/database.go @@ -18,6 +18,7 @@ import ( "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" + "github.com/go-vela/server/database/favorite" "github.com/go-vela/server/database/hook" "github.com/go-vela/server/database/jwk" "github.com/go-vela/server/database/log" @@ -86,6 +87,7 @@ type ( dashboard.DashboardInterface executable.BuildExecutableInterface deployment.DeploymentInterface + favorite.FavoriteInterface hook.HookInterface jwk.JWKInterface log.LogInterface diff --git a/database/favorite/create.go b/database/favorite/create.go new file mode 100644 index 000000000..f6ce75282 --- /dev/null +++ b/database/favorite/create.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + + "github.com/sirupsen/logrus" + + api "github.com/go-vela/server/api/types" +) + +// CreateFavorite creates a new favorite in the database. +func (e *Engine) CreateFavorite(ctx context.Context, u *api.User, f *api.Favorite) error { + e.logger.WithFields(logrus.Fields{ + "repo": f.GetRepo(), + }).Tracef("creating favorite for user %s", u.GetName()) + + return e.client. + WithContext(ctx). + Exec( + `INSERT INTO favorites (user_id, repo_id, position) + SELECT ?, id, ? FROM repos WHERE full_name = ?;`, + u.GetID(), + f.GetPosition(), + f.GetRepo(), + ).Error +} diff --git a/database/favorite/create_test.go b/database/favorite/create_test.go new file mode 100644 index 000000000..ab073dd9d --- /dev/null +++ b/database/favorite/create_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-vela/server/constants" + "github.com/go-vela/server/database/testutils" + "github.com/go-vela/server/database/types" +) + +func TestFavorite_Engine_CreateFavorite(t *testing.T) { + // setup types + _user := testutils.APIUser() + _user.SetID(1) + + _repo := testutils.APIRepo() + _repo.SetID(1) + _repo.SetFullName("foo/bar") + + _favorite := testutils.APIFavorite() + _favorite.SetRepo("foo/bar") + _favorite.SetPosition(1) + + _postgres, _mock := testPostgres(t) + + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // ensure the mock expects the query + _mock.ExpectExec(`INSERT INTO favorites +(user_id, repo_id, position) +SELECT $1, id, $2 FROM repos WHERE full_name = $3;`). + WithArgs(1, 1, "foo/bar").WillReturnResult(sqlmock.NewResult(1, 1)) + + _sqlite := testSqlite(t) + + err := _sqlite.client.AutoMigrate(&types.Repo{}) + if err != nil { + t.Errorf("unable to create build table for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repo)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + // setup tests + tests := []struct { + failure bool + name string + database *Engine + }{ + { + failure: false, + name: "postgres", + database: _postgres, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.database.CreateFavorite(context.TODO(), _user, _favorite) + + if test.failure { + if err == nil { + t.Errorf("CreateUser for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("CreateUser for %s returned err: %v", test.name, err) + } + }) + } +} diff --git a/database/favorite/delete.go b/database/favorite/delete.go new file mode 100644 index 000000000..893bea52e --- /dev/null +++ b/database/favorite/delete.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + + "github.com/sirupsen/logrus" + + api "github.com/go-vela/server/api/types" +) + +// DeleteFavorite deletes a user favorite in the database. +func (e *Engine) DeleteFavorite(ctx context.Context, u *api.User, f *api.Favorite) error { + e.logger.WithFields(logrus.Fields{ + "repo": f.GetRepo(), + }).Tracef("deleting favorite for user %s", u.GetName()) + + return e.client. + WithContext(ctx). + Exec( + `DELETE FROM favorites + WHERE user_id = ? AND repo_id = (SELECT id FROM repos WHERE full_name = ?)`, + u.GetID(), + f.GetRepo(), + ).Error +} diff --git a/database/favorite/favorite.go b/database/favorite/favorite.go new file mode 100644 index 000000000..f6915093a --- /dev/null +++ b/database/favorite/favorite.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" + + "github.com/go-vela/server/constants" +) + +type ( + // config represents the settings required to create the engine that implements the UserInterface interface. + config struct { + // specifies to skip creating tables and indexes for the User engine + SkipCreation bool + Driver string + } + + // Engine represents the user functionality that implements the UserInterface interface. + Engine struct { + // engine configuration settings used in user functions + config *config + + ctx context.Context + + // gorm.io/gorm database client used in user functions + // + // https://pkg.go.dev/gorm.io/gorm#DB + client *gorm.DB + + // sirupsen/logrus logger used in user functions + // + // https://pkg.go.dev/github.com/sirupsen/logrus#Entry + logger *logrus.Entry + } +) + +// New creates and returns a Vela service for integrating with users in the database. +func New(opts ...EngineOpt) (*Engine, error) { + // create new User engine + e := new(Engine) + + // create new fields + e.client = new(gorm.DB) + e.config = new(config) + e.logger = new(logrus.Entry) + + // apply all provided configuration options + for _, opt := range opts { + err := opt(e) + if err != nil { + return nil, err + } + } + + // check if we should skip creating user database objects + if e.config.SkipCreation { + e.logger.Warning("skipping creation of users table and indexes") + + return e, nil + } + + // create the users table + err := e.CreateFavoritesTable(e.ctx, e.client.Config.Dialector.Name()) + if err != nil { + return nil, fmt.Errorf("unable to create %s table: %w", constants.TableUser, err) + } + + // create the indexes for the users table + err = e.CreateFavoritesIndexes(e.ctx) + if err != nil { + return nil, fmt.Errorf("unable to create indexes for %s table: %w", constants.TableUser, err) + } + + return e, nil +} diff --git a/database/favorite/favorite_test.go b/database/favorite/favorite_test.go new file mode 100644 index 000000000..846a607b4 --- /dev/null +++ b/database/favorite/favorite_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "database/sql/driver" + "reflect" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/sirupsen/logrus" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/go-vela/server/constants" + "github.com/go-vela/server/database/testutils" +) + +func TestFavorite_New(t *testing.T) { + // setup types + logger := logrus.NewEntry(logrus.StandardLogger()) + + _sql, _mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + if err != nil { + t.Errorf("unable to create new SQL mock: %v", err) + } + defer _sql.Close() + + _mock.ExpectExec(CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(CreateRepoIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(CreateUserRepoIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + + _config := &gorm.Config{SkipDefaultTransaction: true} + + _postgres, err := testutils.TestPostgresGormInit(_sql) + if err != nil { + t.Errorf("unable to create new postgres database: %v", err) + } + + _sqlite, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), _config) + if err != nil { + t.Errorf("unable to create new sqlite database: %v", err) + } + + defer func() { _sql, _ := _sqlite.DB(); _sql.Close() }() + + // setup tests + tests := []struct { + failure bool + name string + client *gorm.DB + key string + logger *logrus.Entry + skipCreation bool + want *Engine + }{ + { + failure: false, + name: "postgres", + client: _postgres, + key: "A1B2C3D4E5G6H7I8J9K0LMNOPQRSTUVW", + logger: logger, + skipCreation: false, + want: &Engine{ + client: _postgres, + config: &config{SkipCreation: false, Driver: constants.DriverPostgres}, + logger: logger, + }, + }, + { + failure: false, + name: "sqlite3", + client: _sqlite, + key: "A1B2C3D4E5G6H7I8J9K0LMNOPQRSTUVW", + logger: logger, + skipCreation: false, + want: &Engine{ + client: _sqlite, + config: &config{SkipCreation: false, Driver: constants.DriverSqlite}, + logger: logger, + }, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := New( + WithClient(test.client), + WithLogger(test.logger), + WithSkipCreation(test.skipCreation), + WithDriver(test.name), + ) + + if test.failure { + if err == nil { + t.Errorf("New for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("New for %s returned err: %v", test.name, err) + } + + if !reflect.DeepEqual(got, test.want) { + t.Errorf("New for %s is %v, want %v", test.name, got, test.want) + } + }) + } +} + +// testPostgres is a helper function to create a Postgres engine for testing. +func testPostgres(t *testing.T) (*Engine, sqlmock.Sqlmock) { + // create the new mock sql database + // + // https://pkg.go.dev/github.com/DATA-DOG/go-sqlmock#New + _sql, _mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + if err != nil { + t.Errorf("unable to create new SQL mock: %v", err) + } + + _mock.ExpectExec(CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(CreateRepoIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(CreateUserRepoIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + + // create the new mock Postgres database client + // + // https://pkg.go.dev/gorm.io/gorm#Open + _postgres, err := testutils.TestPostgresGormInit(_sql) + if err != nil { + t.Errorf("unable to create new postgres database: %v", err) + } + + _engine, err := New( + WithClient(_postgres), + WithLogger(logrus.NewEntry(logrus.StandardLogger())), + WithSkipCreation(false), + WithDriver(constants.DriverPostgres), + ) + if err != nil { + t.Errorf("unable to create new postgres user engine: %v", err) + } + + return _engine, _mock +} + +// testSqlite is a helper function to create a Sqlite engine for testing. +func testSqlite(t *testing.T) *Engine { + _sqlite, err := gorm.Open( + sqlite.Open("file::memory:?cache=shared"), + &gorm.Config{SkipDefaultTransaction: true}, + ) + if err != nil { + t.Errorf("unable to create new sqlite database: %v", err) + } + + _engine, err := New( + WithClient(_sqlite), + WithLogger(logrus.NewEntry(logrus.StandardLogger())), + WithSkipCreation(false), + WithDriver(constants.DriverSqlite), + ) + if err != nil { + t.Errorf("unable to create new sqlite user engine: %v", err) + } + + return _engine +} + +// This will be used with the github.com/DATA-DOG/go-sqlmock library to compare values +// that are otherwise not easily compared. These typically would be values generated +// before adding or updating them in the database. +// +// https://github.com/DATA-DOG/go-sqlmock#matching-arguments-like-timetime +type AnyArgument struct{} + +// Match satisfies sqlmock.Argument interface. +func (a AnyArgument) Match(_ driver.Value) bool { + return true +} diff --git a/database/favorite/index.go b/database/favorite/index.go new file mode 100644 index 000000000..2478333ff --- /dev/null +++ b/database/favorite/index.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import "context" + +const ( + // CreateOrgNameIndex represents a query to create an + // index on the repos table for the org and name columns. + CreateRepoIndex = ` + CREATE INDEX idx_favorites_repo ON favorites (repo_id); +` + + CreateUserRepoIndex = ` + CREATE INDEX idx_favorites_user_position ON favorites (user_id, position); +` +) + +// CreateFavoritesIndexes creates the indexes for the favorites table in the database. +func (e *Engine) CreateFavoritesIndexes(ctx context.Context) error { + e.logger.Tracef("creating indexes for favorites table") + + // create the repo columns index for the favorites table + if err := e.client. + WithContext(ctx). + Exec(CreateRepoIndex).Error; err != nil { + return err + } + + // create the user and position columns index for the favorites table + if err := e.client. + WithContext(ctx). + Exec(CreateUserRepoIndex).Error; err != nil { + return err + } + + return nil +} diff --git a/database/favorite/interface.go b/database/favorite/interface.go new file mode 100644 index 000000000..61ec98ba9 --- /dev/null +++ b/database/favorite/interface.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + + api "github.com/go-vela/server/api/types" +) + +// FavoriteInterface represents the Vela interface for user favorite +// functions with the supported Database backends. +type FavoriteInterface interface { + // Favorite Data Definition Language Functions + // + // https://en.wikipedia.org/wiki/Data_definition_language + CreateFavoritesTable(context.Context, string) error + + CreateFavoritesIndexes(context.Context) error + + // Favorite Data Manipulation Language Functions + // + // https://en.wikipedia.org/wiki/Data_manipulation_language + + CreateFavorite(context.Context, *api.User, *api.Favorite) error + + DeleteFavorite(context.Context, *api.User, *api.Favorite) error + + ListUserFavorites(context.Context, *api.User) ([]*api.Favorite, error) + + UpdateFavorites(context.Context, *api.User, []*api.Favorite) error +} diff --git a/database/favorite/list_user.go b/database/favorite/list_user.go new file mode 100644 index 000000000..f500fa846 --- /dev/null +++ b/database/favorite/list_user.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" + "github.com/go-vela/server/database/types" +) + +// ListUserFavorites gets a list of all user favorites from the database. +func (e *Engine) ListUserFavorites(ctx context.Context, u *api.User) ([]*api.Favorite, error) { + e.logger.Trace("listing all user favorites") + + result := []types.Favorite{} + + err := e.client. + WithContext(ctx). + Table(constants.TableFavorite+" f"). + Select("r.full_name as repo_name, f.position"). + Joins("JOIN "+constants.TableRepo+" r ON f.repo_id = r.id"). + Where("f.user_id = ?", u.GetID()). + Order("f.position ASC"). + Find(&result). + Error + if err != nil { + return nil, err + } + + favorites := make([]*api.Favorite, 0, len(result)) + for _, res := range result { + favorites = append(favorites, res.ToAPI()) + } + + return favorites, nil +} diff --git a/database/favorite/list_user_test.go b/database/favorite/list_user_test.go new file mode 100644 index 000000000..5f046dc9c --- /dev/null +++ b/database/favorite/list_user_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + "reflect" + "testing" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" + "github.com/go-vela/server/database/testutils" + "github.com/go-vela/server/database/types" +) + +func TestFavorite_Engine_ListUserFavorites(t *testing.T) { + // setup types + _owner := testutils.APIUser().Crop() + _owner.SetID(1) + _owner.SetName("foo") + _owner.SetToken("bar") + + _repoOne := testutils.APIRepo() + _repoOne.SetID(1) + _repoOne.SetOwner(_owner) + _repoOne.SetHash("baz") + _repoOne.SetOrg("foo") + _repoOne.SetName("bar") + _repoOne.SetFullName("foo/bar") + _repoOne.SetVisibility("public") + _repoOne.SetPipelineType("yaml") + _repoOne.SetTopics([]string{}) + _repoOne.SetAllowEvents(api.NewEventsFromMask(1)) + + _repoTwo := testutils.APIRepo() + _repoTwo.SetID(2) + _repoTwo.SetOwner(_owner) + _repoTwo.SetHash("baz") + _repoTwo.SetOrg("bar") + _repoTwo.SetName("foo") + _repoTwo.SetFullName("bar/foo") + _repoTwo.SetVisibility("public") + _repoTwo.SetPipelineType("yaml") + _repoTwo.SetTopics([]string{}) + _repoTwo.SetAllowEvents(api.NewEventsFromMask(1)) + + _favoriteOne := testutils.APIFavorite() + _favoriteOne.SetRepo("foo/bar") + _favoriteOne.SetPosition(1) + + _favoriteTwo := testutils.APIFavorite() + _favoriteTwo.SetRepo("bar/foo") + _favoriteTwo.SetPosition(2) + + _postgres, _mock := testPostgres(t) + + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // create expected result in mock + _rows := testutils.CreateMockRows([]any{*types.FavoriteFromAPI(_favoriteOne), *types.FavoriteFromAPI(_favoriteTwo)}) + + // ensure the mock expects the query + _mock.ExpectQuery(`SELECT r.full_name as repo_name, f.position FROM favorites f JOIN repos r ON f.repo_id = r.id WHERE f.user_id = $1 ORDER BY f.position ASC`).WillReturnRows(_rows) + + _sqlite := testSqlite(t) + + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + err := _sqlite.client.AutoMigrate(&types.Repo{}) + if err != nil { + t.Errorf("unable to create build table for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoOne)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoTwo)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.CreateFavorite(context.TODO(), _owner, _favoriteOne) + if err != nil { + t.Errorf("unable to create test favorite for sqlite: %v", err) + } + + err = _sqlite.CreateFavorite(context.TODO(), _owner, _favoriteTwo) + if err != nil { + t.Errorf("unable to create test favorite for sqlite: %v", err) + } + + // setup tests + tests := []struct { + failure bool + name string + database *Engine + want []*api.Favorite + }{ + { + failure: false, + name: "postgres", + database: _postgres, + want: []*api.Favorite{_favoriteOne, _favoriteTwo}, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + want: []*api.Favorite{_favoriteOne, _favoriteTwo}, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.database.ListUserFavorites(context.TODO(), _owner) + + if test.failure { + if err == nil { + t.Errorf("ListRepos for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("ListRepos for %s returned err: %v", test.name, err) + } + + if !reflect.DeepEqual(got, test.want) { + t.Errorf("ListRepos for %s is %v, want %v", test.name, got, test.want) + } + }) + } +} diff --git a/database/favorite/opts.go b/database/favorite/opts.go new file mode 100644 index 000000000..ef6bdf67c --- /dev/null +++ b/database/favorite/opts.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// EngineOpt represents a configuration option to initialize the database engine for Users. +type EngineOpt func(*Engine) error + +// WithClient sets the gorm.io/gorm client in the database engine for Users. +func WithClient(client *gorm.DB) EngineOpt { + return func(e *Engine) error { + // set the gorm.io/gorm client in the user engine + e.client = client + + return nil + } +} + +// WithLogger sets the github.com/sirupsen/logrus logger in the database engine for Users. +func WithLogger(logger *logrus.Entry) EngineOpt { + return func(e *Engine) error { + // set the github.com/sirupsen/logrus logger in the user engine + e.logger = logger + + return nil + } +} + +// WithDriver sets the driver type in the database engine for dashboards. +func WithDriver(driver string) EngineOpt { + return func(e *Engine) error { + // set the driver type in the dashboard engine + e.config.Driver = driver + + return nil + } +} + +// WithSkipCreation sets the skip creation logic in the database engine for Users. +func WithSkipCreation(skipCreation bool) EngineOpt { + return func(e *Engine) error { + // set to skip creating tables and indexes in the user engine + e.config.SkipCreation = skipCreation + + return nil + } +} + +// WithContext sets the context in the database engine for Users. +func WithContext(ctx context.Context) EngineOpt { + return func(e *Engine) error { + e.ctx = ctx + + return nil + } +} diff --git a/database/favorite/table.go b/database/favorite/table.go new file mode 100644 index 000000000..dfb7e651a --- /dev/null +++ b/database/favorite/table.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + + "github.com/go-vela/server/constants" +) + +const ( + // CreatePostgresTable represents a query to create the Postgres users table. + CreatePostgresTable = ` +CREATE TABLE +IF NOT EXISTS +favorites ( + user_id BIGINT NOT NULL, + repo_id BIGINT NOT NULL, + position INTEGER, + PRIMARY KEY (user_id, repo_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (repo_id) REFERENCES repos(id) +); +` + + // CreateSqliteTable represents a query to create the Sqlite users table. + CreateSqliteTable = ` +CREATE TABLE +IF NOT EXISTS +favorites ( + user_id INTEGER NOT NULL, + repo_id INTEGER NOT NULL, + position INTEGER, + PRIMARY KEY (user_id, repo_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (repo_id) REFERENCES repositories(id) +); +` +) + +// CreateFavoritesTable creates the favorites table in the database. +func (e *Engine) CreateFavoritesTable(ctx context.Context, driver string) error { + e.logger.Tracef("creating favorites table") + + // handle the driver provided to create the table + switch driver { + case constants.DriverPostgres: + // create the favorites table for Postgres + return e.client. + WithContext(ctx). + Exec(CreatePostgresTable).Error + case constants.DriverSqlite: + fallthrough + default: + // create the favorites table for Sqlite + return e.client. + WithContext(ctx). + Exec(CreateSqliteTable).Error + } +} diff --git a/database/favorite/update.go b/database/favorite/update.go new file mode 100644 index 000000000..ecc33f9f8 --- /dev/null +++ b/database/favorite/update.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + "fmt" + "strings" + + "github.com/sirupsen/logrus" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" +) + +// UpdateFavorites updates a user's favorites in the database. +func (e *Engine) UpdateFavorites(ctx context.Context, u *api.User, favs []*api.Favorite) error { + e.logger.WithFields(logrus.Fields{ + "user": u.GetName(), + }).Tracef("updating user %s favorites", u.GetName()) + + if len(favs) == 0 { + return nil + } + + switch e.config.Driver { + case constants.DriverPostgres: + valueStrings := make([]string, 0, len(favs)) + valueArgs := make([]any, 0, len(favs)*2) + + for _, fav := range favs { + valueStrings = append(valueStrings, "(?, ?)") + valueArgs = append(valueArgs, fav.GetRepo(), fav.GetPosition()) + } + + args := []any{u.GetID()} + args = append(args, valueArgs...) + + query := fmt.Sprintf(` +WITH input(repo_name, position) AS ( + VALUES %s +) +INSERT INTO favorites (user_id, repo_id, position) +SELECT ?, r.id, input.position +FROM input +JOIN repos r ON r.full_name = input.repo_name +ON CONFLICT (user_id, repo_id) DO UPDATE + SET position = excluded.position; +`, strings.Join(valueStrings, ", ")) + + return e.client. + WithContext(ctx). + Exec(query, args...). + Error + + default: + return fmt.Errorf("unsupported database driver: %s", e.config.Driver) + } +} diff --git a/database/favorite/update_test.go b/database/favorite/update_test.go new file mode 100644 index 000000000..843ea1326 --- /dev/null +++ b/database/favorite/update_test.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + "reflect" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" + "github.com/go-vela/server/database/testutils" + "github.com/go-vela/server/database/types" +) + +func TestFavorite_Engine_UpdateFavorites(t *testing.T) { + // setup types + _user := testutils.APIUser() + _user.SetID(1) + + _repoOne := testutils.APIRepo() + _repoOne.SetID(1) + _repoOne.SetFullName("foo/bar") + + _repoTwo := testutils.APIRepo() + _repoTwo.SetID(2) + _repoTwo.SetFullName("bar/foo") + + _favoriteOne := testutils.APIFavorite() + _favoriteOne.SetRepo("foo/bar") + _favoriteOne.SetPosition(1) + + _favoriteTwo := testutils.APIFavorite() + _favoriteTwo.SetRepo("bar/foo") + _favoriteTwo.SetPosition(2) + + _updatedFavoriteOne := testutils.APIFavorite() + _updatedFavoriteOne.SetRepo("foo/bar") + _updatedFavoriteOne.SetPosition(2) + + _updatedFavoriteTwo := testutils.APIFavorite() + _updatedFavoriteTwo.SetRepo("bar/foo") + _updatedFavoriteTwo.SetPosition(1) + + _postgres, _mock := testPostgres(t) + + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // ensure the mock expects the query + _mock.ExpectExec(`WITH input(repo_name, position) AS ( VALUES ($1, $2), ($3, $4) ) INSERT INTO favorites (user_id, repo_id, position) SELECT $5, r.id, input.position FROM input JOIN repos r ON r.full_name = input.repo_name ON CONFLICT (user_id, repo_id) DO UPDATE SET position = excluded.position;`). + WithArgs(1, "foo/bar", 2, "bar/foo", 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + + _rows := testutils.CreateMockRows([]any{*types.FavoriteFromAPI(_updatedFavoriteTwo), *types.FavoriteFromAPI(_updatedFavoriteOne)}) + + _mock.ExpectQuery(`SELECT r.full_name as repo_name, f.position FROM favorites f JOIN repos r ON f.repo_id = r.id WHERE f.user_id = $1 ORDER BY f.position ASC`).WillReturnRows(_rows) + + _sqlite := testSqlite(t) + + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + err := _sqlite.client.AutoMigrate(&types.User{}) + if err != nil { + t.Errorf("unable to create user table for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableUser).Create(types.UserFromAPI(_user)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.client.AutoMigrate(&types.Repo{}) + if err != nil { + t.Errorf("unable to create build table for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoOne)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoTwo)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.CreateFavorite(context.TODO(), _user, _favoriteOne) + if err != nil { + t.Errorf("unable to create test favorite for sqlite: %v", err) + } + + err = _sqlite.CreateFavorite(context.TODO(), _user, _favoriteTwo) + if err != nil { + t.Errorf("unable to create test favorite for sqlite: %v", err) + } + + // setup tests + tests := []struct { + failure bool + name string + database *Engine + want []*api.Favorite + }{ + { + failure: false, + name: "postgres", + database: _postgres, + want: []*api.Favorite{_updatedFavoriteTwo, _updatedFavoriteOne}, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + want: []*api.Favorite{_updatedFavoriteTwo, _updatedFavoriteOne}, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.database.UpdateFavorites(context.TODO(), _user, []*api.Favorite{_updatedFavoriteOne, _updatedFavoriteTwo}) + + if test.failure { + if err == nil { + t.Errorf("UpdateFavorites for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("UpdateFavorites for %s returned err: %v", test.name, err) + } + + list, err := test.database.ListUserFavorites(context.TODO(), _user) + if err != nil { + t.Errorf("ListUserFavorites for %s returned err: %v", test.name, err) + } + + if !reflect.DeepEqual(list, test.want) { + t.Errorf("ListUserFavorites for %s returned %v, want %v", test.name, list, test.want) + } + }) + } +} diff --git a/database/interface.go b/database/interface.go index e2a6343c9..1d68efcce 100644 --- a/database/interface.go +++ b/database/interface.go @@ -7,6 +7,7 @@ import ( "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" + "github.com/go-vela/server/database/favorite" "github.com/go-vela/server/database/hook" "github.com/go-vela/server/database/jwk" "github.com/go-vela/server/database/log" @@ -54,6 +55,9 @@ type Interface interface { // DeploymentInterface defines the interface for deployments stored in the database. deployment.DeploymentInterface + // FavoriteInterface defines the interface for user favorites stored in the database. + favorite.FavoriteInterface + // HookInterface defines the interface for hooks stored in the database. hook.HookInterface diff --git a/database/resource.go b/database/resource.go index c52444017..0b6641ae2 100644 --- a/database/resource.go +++ b/database/resource.go @@ -9,6 +9,7 @@ import ( "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" + "github.com/go-vela/server/database/favorite" "github.com/go-vela/server/database/hook" "github.com/go-vela/server/database/jwk" "github.com/go-vela/server/database/log" @@ -87,6 +88,18 @@ func (e *engine) NewResources(ctx context.Context) error { return err } + // create the database agnostic engine for favorites + e.FavoriteInterface, err = favorite.New( + favorite.WithContext(ctx), + favorite.WithClient(e.client), + favorite.WithLogger(e.logger), + favorite.WithSkipCreation(e.config.SkipCreation), + favorite.WithDriver(e.config.Driver), + ) + if err != nil { + return err + } + // create the database agnostic engine for hooks e.HookInterface, err = hook.New( hook.WithContext(ctx), diff --git a/database/testutils/api_resources.go b/database/testutils/api_resources.go index 6f56ebfd9..ce37f1c04 100644 --- a/database/testutils/api_resources.go +++ b/database/testutils/api_resources.go @@ -299,6 +299,13 @@ func APIDashboardRepo() *api.DashboardRepo { } } +func APIFavorite() *api.Favorite { + return &api.Favorite{ + Position: new(int64), + Repo: new(string), + } +} + func JWK() jwk.RSAPublicKey { privateRSAKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { diff --git a/database/types/favorite.go b/database/types/favorite.go new file mode 100644 index 000000000..d790709bf --- /dev/null +++ b/database/types/favorite.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "database/sql" + "fmt" + + api "github.com/go-vela/server/api/types" +) + +type ( + // Favorite represents a user's favorite repository. + Favorite struct { + Position sql.NullInt64 `sql:"position"` + RepoName sql.NullString `sql:"repo_name"` + } +) + +// ToAPI converts the Favorites type +// to a slice of repos. +func (f *Favorite) ToAPI() *api.Favorite { + favorite := new(api.Favorite) + + favorite.SetPosition(f.Position.Int64) + favorite.SetRepo(f.RepoName.String) + + return favorite +} + +// Validate verifies the necessary fields for +// the Favorite type are populated correctly. +func (f *Favorite) Validate() error { + // verify the Repo field is populated + if len(f.RepoName.String) == 0 { + return fmt.Errorf("empty favorite repo provided") + } + + return nil +} + +func FavoriteFromAPI(f *api.Favorite) *Favorite { + return &Favorite{ + Position: sql.NullInt64{Int64: f.GetPosition(), Valid: true}, + RepoName: sql.NullString{String: f.GetRepo(), Valid: true}, + } +} From 489509dcc2b8637dfc462444fcdfe74084bc4225 Mon Sep 17 00:00:00 2001 From: ecrupper Date: Fri, 14 Nov 2025 15:18:54 -0600 Subject: [PATCH 2/4] better design --- .../save_favorites.go => favorite/create.go} | 27 +- api/favorite/delete.go | 51 +++ .../list_favorites.go => favorite/list.go} | 8 +- api/favorite/update.go | 64 ++++ api/types/favorite_test.go | 102 ++++++ api/user/add_favorite.go | 0 database/favorite/create.go | 30 +- database/favorite/create_test.go | 21 +- database/favorite/delete.go | 50 ++- database/favorite/delete_test.go | 118 +++++++ database/favorite/favorite.go | 1 - database/favorite/favorite_test.go | 8 +- database/favorite/index.go | 4 +- database/favorite/interface.go | 4 +- database/favorite/opts.go | 10 - database/favorite/opts_test.go | 208 +++++++++++++ database/favorite/table.go | 2 +- database/favorite/update.go | 59 ---- database/favorite/update_position.go | 93 ++++++ database/favorite/update_position_test.go | 196 ++++++++++++ database/favorite/update_test.go | 147 --------- database/integration_test.go | 290 ++++++++++++++++-- database/resource.go | 23 +- database/resource_test.go | 5 + database/types/favorite.go | 19 +- database/types/favorite_test.go | 57 ++++ router/favorite.go | 34 ++ router/user.go | 7 +- 28 files changed, 1316 insertions(+), 322 deletions(-) rename api/{user/save_favorites.go => favorite/create.go} (54%) create mode 100644 api/favorite/delete.go rename api/{user/list_favorites.go => favorite/list.go} (85%) create mode 100644 api/favorite/update.go create mode 100644 api/types/favorite_test.go delete mode 100644 api/user/add_favorite.go create mode 100644 database/favorite/delete_test.go create mode 100644 database/favorite/opts_test.go delete mode 100644 database/favorite/update.go create mode 100644 database/favorite/update_position.go create mode 100644 database/favorite/update_position_test.go delete mode 100644 database/favorite/update_test.go create mode 100644 database/types/favorite_test.go create mode 100644 router/favorite.go diff --git a/api/user/save_favorites.go b/api/favorite/create.go similarity index 54% rename from api/user/save_favorites.go rename to api/favorite/create.go index 1fbb57f0f..5180b5a32 100644 --- a/api/user/save_favorites.go +++ b/api/favorite/create.go @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -package user +package favorite import ( + "fmt" "net/http" "github.com/gin-gonic/gin" @@ -13,7 +14,7 @@ import ( "github.com/go-vela/server/util" ) -// swagger:operation PUT /api/v1/user/favorites users SaveUserFavorites +// swagger:operation POST /api/v1/user/favorites favorites CreateFavorite // // Save the current authenticated user's favorites // @@ -23,23 +24,23 @@ import ( // security: // - ApiKeyAuth: [] // responses: -// '204': -// description: Successfully saved the current user's favorites +// '201': +// description: Successfully added user favorite // '401': // description: Unauthorized // schema: // "$ref": "#/definitions/Error" -// SaveUserFavorites represents the API handler to save the -// currently authenticated user's favorites. -func SaveUserFavorites(c *gin.Context) { +// CreateFavorite represents the API handler to add a +// favorite for the currently authenticated user. +func CreateFavorite(c *gin.Context) { // capture middleware values u := user.Retrieve(c) ctx := c.Request.Context() - favorites := new([]*types.Favorite) + favorite := new(types.Favorite) - err := c.Bind(favorites) + err := c.Bind(favorite) if err != nil { retErr := err @@ -48,14 +49,14 @@ func SaveUserFavorites(c *gin.Context) { return } - err = database.FromContext(c).UpdateFavorites(ctx, u, *favorites) + err = database.FromContext(c).CreateFavorite(ctx, u, favorite) if err != nil { - retErr := err + retErr := fmt.Errorf("unable to create favorite for user %s: %w", u.GetName(), err) - util.HandleError(ctx, http.StatusInternalServerError, retErr) + util.HandleError(c, http.StatusInternalServerError, retErr) return } - c.JSON(http.StatusOK, favorites) + c.Status(http.StatusCreated) } diff --git a/api/favorite/delete.go b/api/favorite/delete.go new file mode 100644 index 000000000..58fe8db73 --- /dev/null +++ b/api/favorite/delete.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/repo" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// swagger:operation DELETE /api/v1/user/favorites/{org}/{repo} favorites DeleteFavorite +// +// Remove the current authenticated user's favorite +// +// --- +// produces: +// - application/json +// security: +// - ApiKeyAuth: [] +// responses: +// '204': +// description: Successfully removed user favorite +// '401': +// description: Unauthorized +// schema: +// "$ref": "#/definitions/Error" + +// DeleteFavorite represents the API handler to delete a +// favorite for the currently authenticated user. +func DeleteFavorite(c *gin.Context) { + // capture middleware values + u := user.Retrieve(c) + r := repo.Retrieve(c) + ctx := c.Request.Context() + + err := database.FromContext(c).DeleteFavorite(ctx, u, r) + if err != nil { + retErr := err + + util.HandleError(ctx, http.StatusInternalServerError, retErr) + + return + } + + c.Status(http.StatusNoContent) +} diff --git a/api/user/list_favorites.go b/api/favorite/list.go similarity index 85% rename from api/user/list_favorites.go rename to api/favorite/list.go index 31daae459..bdb10d31e 100644 --- a/api/user/list_favorites.go +++ b/api/favorite/list.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -package user +package favorite import ( "fmt" @@ -13,7 +13,7 @@ import ( "github.com/go-vela/server/util" ) -// swagger:operation GET /api/v1/user/favorites users GetUserFavorites +// swagger:operation GET /api/v1/user/favorites favorites ListFavorites // // Get the current authenticated user's favorites // @@ -34,9 +34,9 @@ import ( // schema: // "$ref": "#/definitions/Error" -// GetUserFavorites represents the API handler to capture the +// ListFavorites represents the API handler to capture the // currently authenticated user's favorites. -func GetUserFavorites(c *gin.Context) { +func ListFavorites(c *gin.Context) { // capture middleware values u := user.Retrieve(c) ctx := c.Request.Context() diff --git a/api/favorite/update.go b/api/favorite/update.go new file mode 100644 index 000000000..35cda6486 --- /dev/null +++ b/api/favorite/update.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/go-vela/server/api/types" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/repo" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// swagger:operation PUT /api/v1/user/favorites/{org}/{repo} favorites UpdateFavorite +// +// Update the current authenticated user's favorite +// +// --- +// produces: +// - application/json +// security: +// - ApiKeyAuth: [] +// responses: +// '204': +// description: Successfully updated favorite position +// '401': +// description: Unauthorized +// schema: +// "$ref": "#/definitions/Error" + +// UpdateFavorite represents the API handler to update the +// currently authenticated user's favorite position. +func UpdateFavorite(c *gin.Context) { + // capture middleware values + u := user.Retrieve(c) + r := repo.Retrieve(c) + ctx := c.Request.Context() + + favorite := new(types.Favorite) + + err := c.Bind(favorite) + if err != nil { + retErr := err + + util.HandleError(ctx, http.StatusBadRequest, retErr) + + return + } + + err = database.FromContext(c).UpdateFavoritePosition(ctx, u, r, favorite) + if err != nil { + retErr := fmt.Errorf("unable to update favorite position for user %s: %w", u.GetName(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + c.Status(http.StatusNoContent) +} diff --git a/api/types/favorite_test.go b/api/types/favorite_test.go new file mode 100644 index 000000000..78a11dd18 --- /dev/null +++ b/api/types/favorite_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "fmt" + "reflect" + "testing" +) + +func TestTypes_Favorite_Getters(t *testing.T) { + // setup tests + tests := []struct { + favorite *Favorite + want *Favorite + }{ + { + favorite: testFavorite(), + want: testFavorite(), + }, + { + favorite: new(Favorite), + want: new(Favorite), + }, + } + + // run tests + for _, test := range tests { + if test.favorite.GetPosition() != test.want.GetPosition() { + t.Errorf("GetPosition is %v, want %v", test.favorite.GetPosition(), test.want.GetPosition()) + } + + if test.favorite.GetRepo() != test.want.GetRepo() { + t.Errorf("GetRepo is %v, want %v", test.favorite.GetRepo(), test.want.GetRepo()) + } + } +} + +func TestTypes_Favorite_Setters(t *testing.T) { + // setup types + var f *Favorite + + // setup tests + tests := []struct { + favorite *Favorite + want *Favorite + }{ + { + favorite: testFavorite(), + want: testFavorite(), + }, + { + favorite: f, + want: new(Favorite), + }, + } + + // run tests + for _, test := range tests { + test.favorite.SetPosition(test.want.GetPosition()) + test.favorite.SetRepo(test.want.GetRepo()) + + if test.favorite.GetPosition() != test.want.GetPosition() { + t.Errorf("SetPosition is %v, want %v", test.favorite.GetPosition(), test.want.GetPosition()) + } + + if test.favorite.GetRepo() != test.want.GetRepo() { + t.Errorf("SetRepo is %v, want %v", test.favorite.GetRepo(), test.want.GetRepo()) + } + } +} + +func TestTypes_Favorite_String(t *testing.T) { + // setup types + f := testFavorite() + + want := fmt.Sprintf(`{ + Position: %d, + Repo: %s, +}`, + f.GetPosition(), + f.GetRepo(), + ) + + // run test + got := f.String() + + if !reflect.DeepEqual(got, want) { + t.Errorf("String is %v, want %v", got, want) + } +} + +// testFavorite is a test helper function to create a Favorite +// type with all fields set to a fake value. +func testFavorite() *Favorite { + f := new(Favorite) + + f.SetPosition(1) + f.SetRepo("octocat/Hello-World") + + return f +} diff --git a/api/user/add_favorite.go b/api/user/add_favorite.go deleted file mode 100644 index e69de29bb..000000000 diff --git a/database/favorite/create.go b/database/favorite/create.go index f6ce75282..bf833c728 100644 --- a/database/favorite/create.go +++ b/database/favorite/create.go @@ -4,6 +4,7 @@ package favorite import ( "context" + "fmt" "github.com/sirupsen/logrus" @@ -16,13 +17,26 @@ func (e *Engine) CreateFavorite(ctx context.Context, u *api.User, f *api.Favorit "repo": f.GetRepo(), }).Tracef("creating favorite for user %s", u.GetName()) - return e.client. + res := e.client. WithContext(ctx). - Exec( - `INSERT INTO favorites (user_id, repo_id, position) - SELECT ?, id, ? FROM repos WHERE full_name = ?;`, - u.GetID(), - f.GetPosition(), - f.GetRepo(), - ).Error + Exec(` + INSERT INTO favorites (user_id, repo_id, position) + SELECT ?, r.id, + (SELECT COALESCE(MAX(position), 0) + 1 FROM favorites WHERE user_id = ?) + FROM repos r + WHERE r.full_name = ?; + `, u.GetID(), u.GetID(), f.GetRepo()) + + e.logger.Infof("result err: %s, rows: %d", res.Error, res.RowsAffected) + + if res.Error != nil { + return res.Error + } + + // no repo found + if res.RowsAffected == 0 { + return fmt.Errorf("repo not found: %s", f.GetRepo()) + } + + return nil } diff --git a/database/favorite/create_test.go b/database/favorite/create_test.go index ab073dd9d..9439db468 100644 --- a/database/favorite/create_test.go +++ b/database/favorite/create_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + + api "github.com/go-vela/server/api/types" "github.com/go-vela/server/constants" "github.com/go-vela/server/database/testutils" "github.com/go-vela/server/database/types" @@ -23,16 +25,16 @@ func TestFavorite_Engine_CreateFavorite(t *testing.T) { _favorite := testutils.APIFavorite() _favorite.SetRepo("foo/bar") - _favorite.SetPosition(1) + + _noRepoFavorite := testutils.APIFavorite() + _noRepoFavorite.SetRepo("does/not-exist") _postgres, _mock := testPostgres(t) defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() // ensure the mock expects the query - _mock.ExpectExec(`INSERT INTO favorites -(user_id, repo_id, position) -SELECT $1, id, $2 FROM repos WHERE full_name = $3;`). + _mock.ExpectExec(`INSERT INTO favorites (user_id, repo_id, position) SELECT $1, r.id, (SELECT COALESCE(MAX(position), 0) + 1 FROM favorites WHERE user_id = $2) FROM repos r WHERE r.full_name = $3;`). WithArgs(1, 1, "foo/bar").WillReturnResult(sqlmock.NewResult(1, 1)) _sqlite := testSqlite(t) @@ -54,23 +56,32 @@ SELECT $1, id, $2 FROM repos WHERE full_name = $3;`). failure bool name string database *Engine + favorite *api.Favorite }{ { failure: false, name: "postgres", database: _postgres, + favorite: _favorite, }, { failure: false, name: "sqlite3", database: _sqlite, + favorite: _favorite, + }, + { + failure: true, + name: "no repo found", + database: _sqlite, + favorite: _noRepoFavorite, }, } // run tests for _, test := range tests { t.Run(test.name, func(t *testing.T) { - err := test.database.CreateFavorite(context.TODO(), _user, _favorite) + err := test.database.CreateFavorite(context.TODO(), _user, test.favorite) if test.failure { if err == nil { diff --git a/database/favorite/delete.go b/database/favorite/delete.go index 893bea52e..49c81fa0a 100644 --- a/database/favorite/delete.go +++ b/database/favorite/delete.go @@ -4,24 +4,58 @@ package favorite import ( "context" + "database/sql" + "fmt" "github.com/sirupsen/logrus" + "gorm.io/gorm" api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" ) // DeleteFavorite deletes a user favorite in the database. -func (e *Engine) DeleteFavorite(ctx context.Context, u *api.User, f *api.Favorite) error { +func (e *Engine) DeleteFavorite(ctx context.Context, u *api.User, r *api.Repo) error { e.logger.WithFields(logrus.Fields{ - "repo": f.GetRepo(), + "repo": r.GetFullName(), }).Tracef("deleting favorite for user %s", u.GetName()) - return e.client. - WithContext(ctx). - Exec( - `DELETE FROM favorites - WHERE user_id = ? AND repo_id = (SELECT id FROM repos WHERE full_name = ?)`, + return e.client.Transaction(func(tx *gorm.DB) error { + currentPos := sql.NullInt64{} + + err := tx.Table(constants.TableFavorite). + Select("position"). + Where("user_id = ? AND repo_id = ?", u.GetID(), r.GetID()). + Scan(¤tPos).Error + if err != nil { + return fmt.Errorf("error getting current favorite position: %w", err) + } + + if !currentPos.Valid { + return fmt.Errorf("favorite not found for repo: %s", r.GetFullName()) + } + + err = tx.Exec(` + DELETE FROM favorites + WHERE user_id = ? AND repo_id = ?`, u.GetID(), - f.GetRepo(), + r.GetID(), ).Error + if err != nil { + return fmt.Errorf("error deleting favorite: %w", err) + } + + // shift favorites up to fill gap + err = tx.Exec(` + UPDATE favorites + SET position = position - 1 + WHERE user_id = ? + AND position > ? + `, u.GetID(), currentPos.Int64).Error + if err != nil { + return fmt.Errorf("error shifting favorites: %w", err) + } + + return nil + }) } diff --git a/database/favorite/delete_test.go b/database/favorite/delete_test.go new file mode 100644 index 000000000..b476921a2 --- /dev/null +++ b/database/favorite/delete_test.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-vela/server/constants" + "github.com/go-vela/server/database/testutils" + "github.com/go-vela/server/database/types" +) + +func TestFavorite_Engine_DeleteFavorite(t *testing.T) { + // setup types + _favoriteOne := testutils.APIFavorite() + _favoriteOne.SetRepo("foo/bar") + _favoriteOne.SetPosition(1) + + _favoriteTwo := testutils.APIFavorite() + _favoriteTwo.SetRepo("baz/qux") + _favoriteTwo.SetPosition(2) + + _user := testutils.APIUser() + _user.SetID(1) + + _repoOne := testutils.APIRepo() + _repoOne.SetID(1) + _repoOne.SetFullName("foo/bar") + + _repoTwo := testutils.APIRepo() + _repoTwo.SetID(2) + _repoTwo.SetFullName("baz/qux") + + _postgres, _mock := testPostgres(t) + + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + _mock.ExpectBegin() + + _mock.ExpectQuery(`SELECT position FROM "favorites" WHERE user_id = $1 AND repo_id = $2`). + WithArgs(1, 1). + WillReturnRows(sqlmock.NewRows([]string{"position"}).AddRow(1)) + + _mock.ExpectExec(`DELETE FROM favorites WHERE user_id = $1 AND repo_id = $2`).WithArgs(1, 1).WillReturnResult(sqlmock.NewResult(1, 1)) + + _mock.ExpectExec(`UPDATE favorites SET position = position - 1 WHERE user_id = $1 AND position > $2`). + WithArgs(1, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + + _mock.ExpectCommit() + + _sqlite := testSqlite(t) + + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + err := _sqlite.client.AutoMigrate(&types.Repo{}) + if err != nil { + t.Errorf("unable to create build table for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoOne)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoTwo)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.CreateFavorite(t.Context(), _user, _favoriteOne) + if err != nil { + t.Errorf("unable to create test favorite for sqlite: %v", err) + } + + err = _sqlite.CreateFavorite(t.Context(), _user, _favoriteTwo) + if err != nil { + t.Errorf("unable to create test favorite for sqlite: %v", err) + } + + // setup tests + tests := []struct { + failure bool + name string + database *Engine + }{ + { + failure: false, + name: "postgres", + database: _postgres, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.database.DeleteFavorite(t.Context(), _user, _repoOne) + + if test.failure { + if err == nil { + t.Errorf("DeleteFavorite for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("DeleteFavorite for %s returned err: %v", test.name, err) + } + }) + } +} diff --git a/database/favorite/favorite.go b/database/favorite/favorite.go index f6915093a..435847d4f 100644 --- a/database/favorite/favorite.go +++ b/database/favorite/favorite.go @@ -17,7 +17,6 @@ type ( config struct { // specifies to skip creating tables and indexes for the User engine SkipCreation bool - Driver string } // Engine represents the user functionality that implements the UserInterface interface. diff --git a/database/favorite/favorite_test.go b/database/favorite/favorite_test.go index 846a607b4..feaa9e9a6 100644 --- a/database/favorite/favorite_test.go +++ b/database/favorite/favorite_test.go @@ -12,7 +12,6 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" - "github.com/go-vela/server/constants" "github.com/go-vela/server/database/testutils" ) @@ -63,7 +62,7 @@ func TestFavorite_New(t *testing.T) { skipCreation: false, want: &Engine{ client: _postgres, - config: &config{SkipCreation: false, Driver: constants.DriverPostgres}, + config: &config{SkipCreation: false}, logger: logger, }, }, @@ -76,7 +75,7 @@ func TestFavorite_New(t *testing.T) { skipCreation: false, want: &Engine{ client: _sqlite, - config: &config{SkipCreation: false, Driver: constants.DriverSqlite}, + config: &config{SkipCreation: false}, logger: logger, }, }, @@ -89,7 +88,6 @@ func TestFavorite_New(t *testing.T) { WithClient(test.client), WithLogger(test.logger), WithSkipCreation(test.skipCreation), - WithDriver(test.name), ) if test.failure { @@ -137,7 +135,6 @@ func testPostgres(t *testing.T) (*Engine, sqlmock.Sqlmock) { WithClient(_postgres), WithLogger(logrus.NewEntry(logrus.StandardLogger())), WithSkipCreation(false), - WithDriver(constants.DriverPostgres), ) if err != nil { t.Errorf("unable to create new postgres user engine: %v", err) @@ -160,7 +157,6 @@ func testSqlite(t *testing.T) *Engine { WithClient(_sqlite), WithLogger(logrus.NewEntry(logrus.StandardLogger())), WithSkipCreation(false), - WithDriver(constants.DriverSqlite), ) if err != nil { t.Errorf("unable to create new sqlite user engine: %v", err) diff --git a/database/favorite/index.go b/database/favorite/index.go index 2478333ff..1e35c2770 100644 --- a/database/favorite/index.go +++ b/database/favorite/index.go @@ -8,11 +8,11 @@ const ( // CreateOrgNameIndex represents a query to create an // index on the repos table for the org and name columns. CreateRepoIndex = ` - CREATE INDEX idx_favorites_repo ON favorites (repo_id); + CREATE INDEX IF NOT EXISTS idx_favorites_repo ON favorites (repo_id); ` CreateUserRepoIndex = ` - CREATE INDEX idx_favorites_user_position ON favorites (user_id, position); + CREATE INDEX IF NOT EXISTS idx_favorites_user_position ON favorites (user_id, position); ` ) diff --git a/database/favorite/interface.go b/database/favorite/interface.go index 61ec98ba9..9de67399b 100644 --- a/database/favorite/interface.go +++ b/database/favorite/interface.go @@ -24,9 +24,9 @@ type FavoriteInterface interface { CreateFavorite(context.Context, *api.User, *api.Favorite) error - DeleteFavorite(context.Context, *api.User, *api.Favorite) error + DeleteFavorite(context.Context, *api.User, *api.Repo) error ListUserFavorites(context.Context, *api.User) ([]*api.Favorite, error) - UpdateFavorites(context.Context, *api.User, []*api.Favorite) error + UpdateFavoritePosition(context.Context, *api.User, *api.Repo, *api.Favorite) error } diff --git a/database/favorite/opts.go b/database/favorite/opts.go index ef6bdf67c..bbe7aec85 100644 --- a/database/favorite/opts.go +++ b/database/favorite/opts.go @@ -32,16 +32,6 @@ func WithLogger(logger *logrus.Entry) EngineOpt { } } -// WithDriver sets the driver type in the database engine for dashboards. -func WithDriver(driver string) EngineOpt { - return func(e *Engine) error { - // set the driver type in the dashboard engine - e.config.Driver = driver - - return nil - } -} - // WithSkipCreation sets the skip creation logic in the database engine for Users. func WithSkipCreation(skipCreation bool) EngineOpt { return func(e *Engine) error { diff --git a/database/favorite/opts_test.go b/database/favorite/opts_test.go new file mode 100644 index 000000000..64bb6433c --- /dev/null +++ b/database/favorite/opts_test.go @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + "reflect" + "testing" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func TestFavorite_EngineOpt_WithClient(t *testing.T) { + // setup types + e := &Engine{client: new(gorm.DB)} + + // setup tests + tests := []struct { + failure bool + name string + client *gorm.DB + want *gorm.DB + }{ + { + failure: false, + name: "client set to new database", + client: new(gorm.DB), + want: new(gorm.DB), + }, + { + failure: false, + name: "client set to nil", + client: nil, + want: nil, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := WithClient(test.client)(e) + + if test.failure { + if err == nil { + t.Errorf("WithClient for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("WithClient returned err: %v", err) + } + + if !reflect.DeepEqual(e.client, test.want) { + t.Errorf("WithClient is %v, want %v", e.client, test.want) + } + }) + } +} + +func TestFavorite_EngineOpt_WithLogger(t *testing.T) { + // setup types + e := &Engine{logger: new(logrus.Entry)} + + // setup tests + tests := []struct { + failure bool + name string + logger *logrus.Entry + want *logrus.Entry + }{ + { + failure: false, + name: "logger set to new entry", + logger: new(logrus.Entry), + want: new(logrus.Entry), + }, + { + failure: false, + name: "logger set to nil", + logger: nil, + want: nil, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := WithLogger(test.logger)(e) + + if test.failure { + if err == nil { + t.Errorf("WithLogger for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("WithLogger returned err: %v", err) + } + + if !reflect.DeepEqual(e.logger, test.want) { + t.Errorf("WithLogger is %v, want %v", e.logger, test.want) + } + }) + } +} + +func TestFavorite_EngineOpt_WithSkipCreation(t *testing.T) { + // setup types + e := &Engine{config: new(config)} + + // setup tests + tests := []struct { + failure bool + name string + skipCreation bool + want bool + }{ + { + failure: false, + name: "skip creation set to true", + skipCreation: true, + want: true, + }, + { + failure: false, + name: "skip creation set to false", + skipCreation: false, + want: false, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := WithSkipCreation(test.skipCreation)(e) + + if test.failure { + if err == nil { + t.Errorf("WithSkipCreation for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("WithSkipCreation returned err: %v", err) + } + + if !reflect.DeepEqual(e.config.SkipCreation, test.want) { + t.Errorf("WithSkipCreation is %v, want %v", e.config.SkipCreation, test.want) + } + }) + } +} + +func TestFavorite_EngineOpt_WithContext(t *testing.T) { + // setup types + e := &Engine{config: new(config)} + + // setup tests + tests := []struct { + failure bool + name string + ctx context.Context + want context.Context + }{ + { + failure: false, + name: "context set to TODO", + ctx: context.TODO(), + want: context.TODO(), + }, + { + failure: false, + name: "context set to nil", + ctx: nil, + want: nil, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := WithContext(test.ctx)(e) + + if test.failure { + if err == nil { + t.Errorf("WithContext for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("WithContext returned err: %v", err) + } + + if !reflect.DeepEqual(e.ctx, test.want) { + t.Errorf("WithContext is %v, want %v", e.ctx, test.want) + } + }) + } +} diff --git a/database/favorite/table.go b/database/favorite/table.go index dfb7e651a..8335a4e53 100644 --- a/database/favorite/table.go +++ b/database/favorite/table.go @@ -16,7 +16,7 @@ IF NOT EXISTS favorites ( user_id BIGINT NOT NULL, repo_id BIGINT NOT NULL, - position INTEGER, + position BIGINT NOT NULL, PRIMARY KEY (user_id, repo_id), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (repo_id) REFERENCES repos(id) diff --git a/database/favorite/update.go b/database/favorite/update.go deleted file mode 100644 index ecc33f9f8..000000000 --- a/database/favorite/update.go +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package favorite - -import ( - "context" - "fmt" - "strings" - - "github.com/sirupsen/logrus" - - api "github.com/go-vela/server/api/types" - "github.com/go-vela/server/constants" -) - -// UpdateFavorites updates a user's favorites in the database. -func (e *Engine) UpdateFavorites(ctx context.Context, u *api.User, favs []*api.Favorite) error { - e.logger.WithFields(logrus.Fields{ - "user": u.GetName(), - }).Tracef("updating user %s favorites", u.GetName()) - - if len(favs) == 0 { - return nil - } - - switch e.config.Driver { - case constants.DriverPostgres: - valueStrings := make([]string, 0, len(favs)) - valueArgs := make([]any, 0, len(favs)*2) - - for _, fav := range favs { - valueStrings = append(valueStrings, "(?, ?)") - valueArgs = append(valueArgs, fav.GetRepo(), fav.GetPosition()) - } - - args := []any{u.GetID()} - args = append(args, valueArgs...) - - query := fmt.Sprintf(` -WITH input(repo_name, position) AS ( - VALUES %s -) -INSERT INTO favorites (user_id, repo_id, position) -SELECT ?, r.id, input.position -FROM input -JOIN repos r ON r.full_name = input.repo_name -ON CONFLICT (user_id, repo_id) DO UPDATE - SET position = excluded.position; -`, strings.Join(valueStrings, ", ")) - - return e.client. - WithContext(ctx). - Exec(query, args...). - Error - - default: - return fmt.Errorf("unsupported database driver: %s", e.config.Driver) - } -} diff --git a/database/favorite/update_position.go b/database/favorite/update_position.go new file mode 100644 index 000000000..2a035d1a0 --- /dev/null +++ b/database/favorite/update_position.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "context" + "database/sql" + "fmt" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" +) + +// UpdateFavoritePosition updates a single favorite position and applies the shift to other favorites. +func (e *Engine) UpdateFavoritePosition(ctx context.Context, u *api.User, r *api.Repo, f *api.Favorite) error { + e.logger.WithFields(logrus.Fields{ + "user": u.GetName(), + }).Tracef("updating user %s favorites", u.GetName()) + + return e.client.Transaction(func(tx *gorm.DB) error { + currentPos := sql.NullInt64{} + + err := tx.Table(constants.TableFavorite). + Select("position"). + Where("user_id = ? AND repo_id = ?", u.GetID(), r.GetID()). + Scan(¤tPos).Error + if err != nil { + return fmt.Errorf("error getting current favorite position: %w", err) + } + + if !currentPos.Valid { + return fmt.Errorf("favorite not found for repo: %s", r.GetFullName()) + } + + if currentPos.Int64 == f.GetPosition() { + return nil + } + + var count int64 + if err := tx.Table(constants.TableFavorite). + Where("user_id = ?", u.GetID()). + Count(&count).Error; err != nil { + return fmt.Errorf("error counting favorites: %w", err) + } + + // clamp position + if f.GetPosition() <= 0 { + f.SetPosition(1) + } + + if f.GetPosition() > count { + f.SetPosition(count) + } + + if currentPos.Int64 > f.GetPosition() { + // moving up - smaller position + err := tx.Exec(` + UPDATE favorites + SET position = position + 1 + WHERE user_id = ? + AND position >= ? + AND position < ? + `, u.GetID(), f.GetPosition(), currentPos.Int64).Error + if err != nil { + return fmt.Errorf("error shifting favorites: %w", err) + } + } else { + // moving down - larger position + err := tx.Exec(` + UPDATE favorites + SET position = position - 1 + WHERE user_id = ? + AND position <= ? + AND position > ? + `, u.GetID(), f.GetPosition(), currentPos.Int64).Error + if err != nil { + return fmt.Errorf("error shifting favorites: %w", err) + } + } + + err = tx.Table(constants.TableFavorite). + Where("user_id = ? AND repo_id = ?", u.GetID(), r.GetID()). + Update("position", f.GetPosition()).Error + if err != nil { + return fmt.Errorf("error updating favorite position: %w", err) + } + + return nil + }) +} diff --git a/database/favorite/update_position_test.go b/database/favorite/update_position_test.go new file mode 100644 index 000000000..31a856b61 --- /dev/null +++ b/database/favorite/update_position_test.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 + +package favorite + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" + "github.com/go-vela/server/database/testutils" + "github.com/go-vela/server/database/types" +) + +func TestFavorite_Engine_UpdatePosition(t *testing.T) { + // setup types + _favoriteOne := testutils.APIFavorite() + _favoriteOne.SetRepo("foo/bar") + _favoriteOne.SetPosition(1) + + _favoriteTwo := testutils.APIFavorite() + _favoriteTwo.SetRepo("baz/qux") + _favoriteTwo.SetPosition(2) + + _favoriteThree := testutils.APIFavorite() + _favoriteThree.SetRepo("quux/corge") + _favoriteThree.SetPosition(3) + + _user := testutils.APIUser() + _user.SetID(1) + + _repoOne := testutils.APIRepo() + _repoOne.SetID(1) + _repoOne.SetFullName("foo/bar") + + _repoTwo := testutils.APIRepo() + _repoTwo.SetID(2) + _repoTwo.SetFullName("baz/qux") + + _repoThree := testutils.APIRepo() + _repoThree.SetID(3) + _repoThree.SetFullName("quux/corge") + + _postgres, _mock := testPostgres(t) + + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // move down mocks + _mock.ExpectBegin() + + _mock.ExpectQuery(`SELECT position FROM "favorites" WHERE user_id = $1 AND repo_id = $2`). + WithArgs(1, 1). + WillReturnRows(sqlmock.NewRows([]string{"position"}).AddRow(1)) + + _mock.ExpectQuery(`SELECT count(*) FROM "favorites" WHERE user_id = $1`). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3)) + + _mock.ExpectExec(`UPDATE favorites SET position = position - 1 WHERE user_id = $1 AND position <= $2 AND position > $3`). + WithArgs(1, 2, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + + _mock.ExpectExec(`UPDATE "favorites" SET "position"=$1 WHERE user_id = $2 AND repo_id = $3`). + WithArgs(2, 1, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + + _mock.ExpectCommit() + + // move up mocks + _mock.ExpectBegin() + + _mock.ExpectQuery(`SELECT position FROM "favorites" WHERE user_id = $1 AND repo_id = $2`). + WithArgs(1, 3). + WillReturnRows(sqlmock.NewRows([]string{"position"}).AddRow(3)) + + _mock.ExpectQuery(`SELECT count(*) FROM "favorites" WHERE user_id = $1`). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3)) + + _mock.ExpectExec(`UPDATE favorites SET position = position + 1 WHERE user_id = $1 AND position >= $2 AND position < $3`). + WithArgs(1, 1, 3). + WillReturnResult(sqlmock.NewResult(1, 1)) + + _mock.ExpectExec(`UPDATE "favorites" SET "position"=$1 WHERE user_id = $2 AND repo_id = $3`). + WithArgs(1, 1, 3). + WillReturnResult(sqlmock.NewResult(1, 1)) + + _mock.ExpectCommit() + + _sqlite := testSqlite(t) + + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + err := _sqlite.client.AutoMigrate(&types.Repo{}) + if err != nil { + t.Errorf("unable to create build table for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoOne)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoTwo)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoThree)).Error + if err != nil { + t.Errorf("unable to create test user for sqlite: %v", err) + } + + err = _sqlite.CreateFavorite(t.Context(), _user, _favoriteOne) + if err != nil { + t.Errorf("unable to create test favorite for sqlite: %v", err) + } + + err = _sqlite.CreateFavorite(t.Context(), _user, _favoriteTwo) + if err != nil { + t.Errorf("unable to create test favorite for sqlite: %v", err) + } + + err = _sqlite.CreateFavorite(t.Context(), _user, _favoriteThree) + if err != nil { + t.Errorf("unable to create test favorite for sqlite: %v", err) + } + + int2 := int64(2) + int1 := int64(1) + + // setup tests + tests := []struct { + failure bool + name string + database *Engine + updateRepo *api.Repo + updateFav *api.Favorite + }{ + { + failure: false, + name: "postgres move down", + database: _postgres, + updateRepo: _repoOne, + updateFav: &api.Favorite{ + Position: &int2, + }, + }, + { + failure: false, + name: "postgres move up", + database: _postgres, + updateRepo: _repoThree, + updateFav: &api.Favorite{ + Position: &int1, + }, + }, + { + failure: false, + name: "sqlite3 move down", + database: _sqlite, + updateRepo: _repoOne, + updateFav: &api.Favorite{ + Position: &int2, + }, + }, + { + failure: false, + name: "sqlite3 move up", + database: _sqlite, + updateRepo: _repoThree, + updateFav: &api.Favorite{ + Position: &int1, + }, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.database.UpdateFavoritePosition(t.Context(), _user, test.updateRepo, test.updateFav) + + if test.failure { + if err == nil { + t.Errorf("UpdateFavoritePosition for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("UpdateFavoritePosition for %s returned err: %v", test.name, err) + } + }) + } +} diff --git a/database/favorite/update_test.go b/database/favorite/update_test.go deleted file mode 100644 index 843ea1326..000000000 --- a/database/favorite/update_test.go +++ /dev/null @@ -1,147 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package favorite - -import ( - "context" - "reflect" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - - api "github.com/go-vela/server/api/types" - "github.com/go-vela/server/constants" - "github.com/go-vela/server/database/testutils" - "github.com/go-vela/server/database/types" -) - -func TestFavorite_Engine_UpdateFavorites(t *testing.T) { - // setup types - _user := testutils.APIUser() - _user.SetID(1) - - _repoOne := testutils.APIRepo() - _repoOne.SetID(1) - _repoOne.SetFullName("foo/bar") - - _repoTwo := testutils.APIRepo() - _repoTwo.SetID(2) - _repoTwo.SetFullName("bar/foo") - - _favoriteOne := testutils.APIFavorite() - _favoriteOne.SetRepo("foo/bar") - _favoriteOne.SetPosition(1) - - _favoriteTwo := testutils.APIFavorite() - _favoriteTwo.SetRepo("bar/foo") - _favoriteTwo.SetPosition(2) - - _updatedFavoriteOne := testutils.APIFavorite() - _updatedFavoriteOne.SetRepo("foo/bar") - _updatedFavoriteOne.SetPosition(2) - - _updatedFavoriteTwo := testutils.APIFavorite() - _updatedFavoriteTwo.SetRepo("bar/foo") - _updatedFavoriteTwo.SetPosition(1) - - _postgres, _mock := testPostgres(t) - - defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() - - // ensure the mock expects the query - _mock.ExpectExec(`WITH input(repo_name, position) AS ( VALUES ($1, $2), ($3, $4) ) INSERT INTO favorites (user_id, repo_id, position) SELECT $5, r.id, input.position FROM input JOIN repos r ON r.full_name = input.repo_name ON CONFLICT (user_id, repo_id) DO UPDATE SET position = excluded.position;`). - WithArgs(1, "foo/bar", 2, "bar/foo", 1). - WillReturnResult(sqlmock.NewResult(1, 1)) - - _rows := testutils.CreateMockRows([]any{*types.FavoriteFromAPI(_updatedFavoriteTwo), *types.FavoriteFromAPI(_updatedFavoriteOne)}) - - _mock.ExpectQuery(`SELECT r.full_name as repo_name, f.position FROM favorites f JOIN repos r ON f.repo_id = r.id WHERE f.user_id = $1 ORDER BY f.position ASC`).WillReturnRows(_rows) - - _sqlite := testSqlite(t) - - defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() - - err := _sqlite.client.AutoMigrate(&types.User{}) - if err != nil { - t.Errorf("unable to create user table for sqlite: %v", err) - } - - err = _sqlite.client.Table(constants.TableUser).Create(types.UserFromAPI(_user)).Error - if err != nil { - t.Errorf("unable to create test user for sqlite: %v", err) - } - - err = _sqlite.client.AutoMigrate(&types.Repo{}) - if err != nil { - t.Errorf("unable to create build table for sqlite: %v", err) - } - - err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoOne)).Error - if err != nil { - t.Errorf("unable to create test user for sqlite: %v", err) - } - - err = _sqlite.client.Table(constants.TableRepo).Create(types.RepoFromAPI(_repoTwo)).Error - if err != nil { - t.Errorf("unable to create test user for sqlite: %v", err) - } - - err = _sqlite.CreateFavorite(context.TODO(), _user, _favoriteOne) - if err != nil { - t.Errorf("unable to create test favorite for sqlite: %v", err) - } - - err = _sqlite.CreateFavorite(context.TODO(), _user, _favoriteTwo) - if err != nil { - t.Errorf("unable to create test favorite for sqlite: %v", err) - } - - // setup tests - tests := []struct { - failure bool - name string - database *Engine - want []*api.Favorite - }{ - { - failure: false, - name: "postgres", - database: _postgres, - want: []*api.Favorite{_updatedFavoriteTwo, _updatedFavoriteOne}, - }, - { - failure: false, - name: "sqlite3", - database: _sqlite, - want: []*api.Favorite{_updatedFavoriteTwo, _updatedFavoriteOne}, - }, - } - - // run tests - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := test.database.UpdateFavorites(context.TODO(), _user, []*api.Favorite{_updatedFavoriteOne, _updatedFavoriteTwo}) - - if test.failure { - if err == nil { - t.Errorf("UpdateFavorites for %s should have returned err", test.name) - } - - return - } - - if err != nil { - t.Errorf("UpdateFavorites for %s returned err: %v", test.name, err) - } - - list, err := test.database.ListUserFavorites(context.TODO(), _user) - if err != nil { - t.Errorf("ListUserFavorites for %s returned err: %v", test.name, err) - } - - if !reflect.DeepEqual(list, test.want) { - t.Errorf("ListUserFavorites for %s returned %v, want %v", test.name, list, test.want) - } - }) - } -} diff --git a/database/integration_test.go b/database/integration_test.go index a08d49ea1..323241c47 100644 --- a/database/integration_test.go +++ b/database/integration_test.go @@ -23,6 +23,7 @@ import ( "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" + "github.com/go-vela/server/database/favorite" "github.com/go-vela/server/database/hook" dbJWK "github.com/go-vela/server/database/jwk" "github.com/go-vela/server/database/log" @@ -39,25 +40,35 @@ import ( "github.com/go-vela/server/tracing" ) -// Resources represents the object containing test resources. -type Resources struct { - Builds []*api.Build - Dashboards []*api.Dashboard - Deployments []*api.Deployment - Executables []*api.BuildExecutable - Hooks []*api.Hook - JWKs jwk.Set - Logs []*api.Log - Pipelines []*api.Pipeline - Repos []*api.Repo - Schedules []*api.Schedule - Secrets []*api.Secret - Services []*api.Service - Steps []*api.Step - Users []*api.User - Workers []*api.Worker - Platform []*settings.Platform -} +type ( + FavoriteMatrix struct { + Repos []*api.Repo + Before []*api.Favorite + AfterMoveUp []*api.Favorite + AfterMoveDown []*api.Favorite + AfterDelete []*api.Favorite + } + + Resources struct { + Builds []*api.Build + Dashboards []*api.Dashboard + Deployments []*api.Deployment + Executables []*api.BuildExecutable + Favorites FavoriteMatrix + Hooks []*api.Hook + JWKs jwk.Set + Logs []*api.Log + Pipelines []*api.Pipeline + Repos []*api.Repo + Schedules []*api.Schedule + Secrets []*api.Secret + Services []*api.Service + Steps []*api.Step + Users []*api.User + Workers []*api.Worker + Platform []*settings.Platform + } +) func TestDatabase_Integration(t *testing.T) { // check if we should skip the integration test @@ -139,6 +150,8 @@ func TestDatabase_Integration(t *testing.T) { t.Run("test_executables", func(t *testing.T) { testExecutables(t, db, resources) }) + t.Run("test_favorites", func(t *testing.T) { testFavorites(t, db, resources) }) + t.Run("test_hooks", func(t *testing.T) { testHooks(t, db, resources) }) t.Run("test_jwks", func(t *testing.T) { testJWKs(t, db, resources) }) @@ -845,6 +858,146 @@ func testDeployments(t *testing.T, db Interface, resources *Resources) { } } +func testFavorites(t *testing.T, db Interface, resources *Resources) { + // create a variable to track the number of methods called for favorites + methods := make(map[string]bool) + // capture the element type of the favorite interface + element := reflect.TypeOf(new(favorite.FavoriteInterface)).Elem() + // iterate through all methods found in the favorite interface + for i := 0; i < element.NumMethod(); i++ { + // skip tracking the methods to create indexes and tables for favorites + // since those are already called when the database engine starts + if strings.Contains(element.Method(i).Name, "Index") || + strings.Contains(element.Method(i).Name, "Table") { + continue + } + + // add the method name to the list of functions + methods[element.Method(i).Name] = false + } + + user := resources.Favorites.Repos[0].GetOwner() + + for _, user := range resources.Users { + _, err := db.CreateUser(context.TODO(), user) + if err != nil { + t.Errorf("unable to create user %d: %v", user.GetID(), err) + } + } + + // create the repos for favorite related functions + for _, repo := range resources.Favorites.Repos { + _, err := db.CreateRepo(context.TODO(), repo) + if err != nil { + t.Errorf("unable to create repo %d: %v", repo.GetID(), err) + } + } + + // create the favorites + for _, favorite := range resources.Favorites.Before { + err := db.CreateFavorite(context.TODO(), user, favorite) + if err != nil { + t.Errorf("unable to create favorite %s: %v", favorite.GetRepo(), err) + } + } + + methods["CreateFavorite"] = true + + // list the user favorites + list, err := db.ListUserFavorites(context.TODO(), user) + if err != nil { + t.Errorf("unable to list user favorites: %v", err) + } + + if diff := cmp.Diff(resources.Favorites.Before, list); diff != "" { + t.Errorf("ListUserFavorites() mismatch (-want +got):\n%s", diff) + } + + methods["ListUserFavorites"] = true + + // move favorite up + err = db.UpdateFavoritePosition(context.TODO(), user, resources.Favorites.Repos[1], resources.Favorites.AfterMoveUp[0]) + if err != nil { + t.Errorf("unable to move favorite up: %v", err) + } + + list, err = db.ListUserFavorites(context.TODO(), user) + if err != nil { + t.Errorf("unable to list user favorites: %v", err) + } + + if diff := cmp.Diff(resources.Favorites.AfterMoveUp, list); diff != "" { + t.Errorf("ListUserFavorites() mismatch (-want +got):\n%s", diff) + } + + err = db.UpdateFavoritePosition(context.TODO(), user, resources.Favorites.Repos[0], resources.Favorites.AfterMoveDown[2]) + if err != nil { + t.Errorf("unable to move favorite up: %v", err) + } + + list, err = db.ListUserFavorites(context.TODO(), user) + if err != nil { + t.Errorf("unable to list user favorites: %v", err) + } + + if diff := cmp.Diff(resources.Favorites.AfterMoveDown, list); diff != "" { + t.Errorf("ListUserFavorites() mismatch (-want +got):\n%s", diff) + } + + methods["UpdateFavoritePosition"] = true + + // delete a favorite + err = db.DeleteFavorite(context.TODO(), user, resources.Favorites.Repos[1]) + if err != nil { + t.Errorf("unable to delete favorite %d: %v", resources.Favorites.Repos[1].GetID(), err) + } + + list, err = db.ListUserFavorites(context.TODO(), user) + if err != nil { + t.Errorf("unable to list user favorites: %v", err) + } + + if diff := cmp.Diff(resources.Favorites.AfterDelete, list); diff != "" { + t.Errorf("ListUserFavorites() mismatch (-want +got):\n%s", diff) + } + + // delete rest + err = db.DeleteFavorite(context.TODO(), user, resources.Favorites.Repos[0]) + if err != nil { + t.Errorf("unable to delete favorite %d: %v", resources.Favorites.Repos[0].GetID(), err) + } + + err = db.DeleteFavorite(context.TODO(), user, resources.Favorites.Repos[2]) + if err != nil { + t.Errorf("unable to delete favorite %d: %v", resources.Favorites.Repos[2].GetID(), err) + } + + methods["DeleteFavorite"] = true + + // delete the repos for hook related functions + for _, repo := range resources.Favorites.Repos { + err = db.DeleteRepo(context.TODO(), repo) + if err != nil { + t.Errorf("unable to delete repo: %v", err) + } + } + + // delete the users for the hook related functions + for _, user := range resources.Users { + err = db.DeleteUser(context.TODO(), user) + if err != nil { + t.Errorf("unable to delete user: %v", err) + } + } + + // ensure we called all the methods we expected to + for method, called := range methods { + if !called { + t.Errorf("method %s was not called for deployments", method) + } + } +} + func testHooks(t *testing.T, db Interface, resources *Resources) { // create a variable to track the number of methods called for hooks methods := make(map[string]bool) @@ -2806,6 +2959,32 @@ func newResources() *Resources { repoTwo.SetInstallID(0) repoTwo.SetCustomProps(map[string]any{"foo": "bar"}) + repoThree := new(api.Repo) + repoThree.SetID(3) + repoThree.SetOwner(userOne.Crop()) + repoThree.SetHash("YzE0ZDI3ZTAtZTI0Zi00Y2E3LTkzZTAtY2E3N2JlMjI3NjBi") + repoThree.SetOrg("github") + repoThree.SetName("hello-world") + repoThree.SetFullName("github/hello-world") + repoThree.SetLink("https://github.com/github/hello-world") + repoThree.SetClone("https://github.com/github/hello-world.git") + repoThree.SetBranch("main") + repoThree.SetTopics([]string{"cloud", "security"}) + repoThree.SetBuildLimit(10) + repoThree.SetTimeout(30) + repoThree.SetCounter(0) + repoThree.SetVisibility("public") + repoThree.SetPrivate(false) + repoThree.SetTrusted(false) + repoThree.SetActive(true) + repoThree.SetPipelineType("") + repoThree.SetPreviousName("") + repoThree.SetApproveBuild(constants.ApproveForkAlways) + repoThree.SetAllowEvents(api.NewEventsFromMask(1)) + repoThree.SetApprovalTimeout(7) + repoThree.SetInstallID(0) + repoThree.SetCustomProps(map[string]any{"foo": "bar"}) + buildOne := new(api.Build) buildOne.SetID(1) buildOne.SetRepo(repoOne) @@ -2962,6 +3141,50 @@ func newResources() *Resources { deploymentTwo.SetCreatedAt(1) deploymentTwo.SetCreatedBy("octocat") + beforeFavoriteOne := new(api.Favorite) + beforeFavoriteOne.SetPosition(1) + beforeFavoriteOne.SetRepo("github/octocat") + + beforeFavoriteTwo := new(api.Favorite) + beforeFavoriteTwo.SetPosition(2) + beforeFavoriteTwo.SetRepo("github/octokitty") + + beforeFavoriteThree := new(api.Favorite) + beforeFavoriteThree.SetPosition(3) + beforeFavoriteThree.SetRepo("github/hello-world") + + moveUpFavoriteOne := new(api.Favorite) + moveUpFavoriteOne.SetPosition(1) + moveUpFavoriteOne.SetRepo("github/octokitty") + + moveUpFavoriteTwo := new(api.Favorite) + moveUpFavoriteTwo.SetPosition(2) + moveUpFavoriteTwo.SetRepo("github/octocat") + + moveUpFavoriteThree := new(api.Favorite) + moveUpFavoriteThree.SetPosition(3) + moveUpFavoriteThree.SetRepo("github/hello-world") + + moveDownFavoriteOne := new(api.Favorite) + moveDownFavoriteOne.SetPosition(1) + moveDownFavoriteOne.SetRepo("github/octokitty") + + moveDownFavoriteTwo := new(api.Favorite) + moveDownFavoriteTwo.SetPosition(2) + moveDownFavoriteTwo.SetRepo("github/hello-world") + + moveDownFavoriteThree := new(api.Favorite) + moveDownFavoriteThree.SetPosition(3) + moveDownFavoriteThree.SetRepo("github/octocat") + + afterDeleteFavoriteOne := new(api.Favorite) + afterDeleteFavoriteOne.SetPosition(1) + afterDeleteFavoriteOne.SetRepo("github/hello-world") + + afterDeleteFavoriteTwo := new(api.Favorite) + afterDeleteFavoriteTwo.SetPosition(2) + afterDeleteFavoriteTwo.SetRepo("github/octocat") + hookOne := new(api.Hook) hookOne.SetID(1) hookOne.SetRepo(repoOne) @@ -3291,17 +3514,24 @@ func newResources() *Resources { Dashboards: []*api.Dashboard{dashboardOne, dashboardTwo}, Deployments: []*api.Deployment{deploymentOne, deploymentTwo}, Executables: []*api.BuildExecutable{executableOne, executableTwo}, - Hooks: []*api.Hook{hookOne, hookTwo, hookThree}, - JWKs: jwkSet, - Logs: []*api.Log{logServiceOne, logServiceTwo, logStepOne, logStepTwo}, - Pipelines: []*api.Pipeline{pipelineOne, pipelineTwo}, - Repos: []*api.Repo{repoOne, repoTwo}, - Schedules: []*api.Schedule{scheduleOne, scheduleTwo}, - Secrets: []*api.Secret{secretOrg, secretRepo, secretShared}, - Services: []*api.Service{serviceOne, serviceTwo}, - Steps: []*api.Step{stepOne, stepTwo}, - Users: []*api.User{userOne, userTwo}, - Workers: []*api.Worker{workerOne, workerTwo}, + Favorites: FavoriteMatrix{ + Repos: []*api.Repo{repoOne, repoTwo, repoThree}, + Before: []*api.Favorite{beforeFavoriteOne, beforeFavoriteTwo, beforeFavoriteThree}, + AfterMoveUp: []*api.Favorite{moveUpFavoriteOne, moveUpFavoriteTwo, moveUpFavoriteThree}, + AfterMoveDown: []*api.Favorite{moveDownFavoriteOne, moveDownFavoriteTwo, moveDownFavoriteThree}, + AfterDelete: []*api.Favorite{afterDeleteFavoriteOne, afterDeleteFavoriteTwo}, + }, + Hooks: []*api.Hook{hookOne, hookTwo, hookThree}, + JWKs: jwkSet, + Logs: []*api.Log{logServiceOne, logServiceTwo, logStepOne, logStepTwo}, + Pipelines: []*api.Pipeline{pipelineOne, pipelineTwo}, + Repos: []*api.Repo{repoOne, repoTwo}, + Schedules: []*api.Schedule{scheduleOne, scheduleTwo}, + Secrets: []*api.Secret{secretOrg, secretRepo, secretShared}, + Services: []*api.Service{serviceOne, serviceTwo}, + Steps: []*api.Step{stepOne, stepTwo}, + Users: []*api.User{userOne, userTwo}, + Workers: []*api.Worker{workerOne, workerTwo}, } } diff --git a/database/resource.go b/database/resource.go index 0b6641ae2..8ec5c82d1 100644 --- a/database/resource.go +++ b/database/resource.go @@ -88,18 +88,6 @@ func (e *engine) NewResources(ctx context.Context) error { return err } - // create the database agnostic engine for favorites - e.FavoriteInterface, err = favorite.New( - favorite.WithContext(ctx), - favorite.WithClient(e.client), - favorite.WithLogger(e.logger), - favorite.WithSkipCreation(e.config.SkipCreation), - favorite.WithDriver(e.config.Driver), - ) - if err != nil { - return err - } - // create the database agnostic engine for hooks e.HookInterface, err = hook.New( hook.WithContext(ctx), @@ -222,6 +210,17 @@ func (e *engine) NewResources(ctx context.Context) error { return err } + // create the database agnostic engine for favorites + e.FavoriteInterface, err = favorite.New( + favorite.WithContext(ctx), + favorite.WithClient(e.client), + favorite.WithLogger(e.logger), + favorite.WithSkipCreation(e.config.SkipCreation), + ) + if err != nil { + return err + } + // create the database agnostic engine for workers e.WorkerInterface, err = worker.New( worker.WithContext(ctx), diff --git a/database/resource_test.go b/database/resource_test.go index c852c8bf0..95090372c 100644 --- a/database/resource_test.go +++ b/database/resource_test.go @@ -12,6 +12,7 @@ import ( "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" + "github.com/go-vela/server/database/favorite" "github.com/go-vela/server/database/hook" "github.com/go-vela/server/database/jwk" "github.com/go-vela/server/database/log" @@ -77,6 +78,10 @@ func TestDatabase_Engine_NewResources(t *testing.T) { // ensure the mock expects the user queries _mock.ExpectExec(user.CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(user.CreateUserRefreshIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + // ensure the mock expects the favorite queuries + _mock.ExpectExec(favorite.CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(favorite.CreateRepoIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(favorite.CreateUserRepoIndex).WillReturnResult(sqlmock.NewResult(1, 1)) // ensure the mock expects the worker queries _mock.ExpectExec(worker.CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(worker.CreateHostnameAddressIndex).WillReturnResult(sqlmock.NewResult(1, 1)) diff --git a/database/types/favorite.go b/database/types/favorite.go index d790709bf..08f04d4ff 100644 --- a/database/types/favorite.go +++ b/database/types/favorite.go @@ -4,7 +4,6 @@ package types import ( "database/sql" - "fmt" api "github.com/go-vela/server/api/types" ) @@ -14,6 +13,8 @@ type ( Favorite struct { Position sql.NullInt64 `sql:"position"` RepoName sql.NullString `sql:"repo_name"` + UserID sql.NullInt64 `sql:"user_id"` + RepoID sql.NullInt64 `sql:"repo_id"` } ) @@ -22,23 +23,15 @@ type ( func (f *Favorite) ToAPI() *api.Favorite { favorite := new(api.Favorite) - favorite.SetPosition(f.Position.Int64) + if f.Position.Valid { + favorite.SetPosition(f.Position.Int64) + } + favorite.SetRepo(f.RepoName.String) return favorite } -// Validate verifies the necessary fields for -// the Favorite type are populated correctly. -func (f *Favorite) Validate() error { - // verify the Repo field is populated - if len(f.RepoName.String) == 0 { - return fmt.Errorf("empty favorite repo provided") - } - - return nil -} - func FavoriteFromAPI(f *api.Favorite) *Favorite { return &Favorite{ Position: sql.NullInt64{Int64: f.GetPosition(), Valid: true}, diff --git a/database/types/favorite_test.go b/database/types/favorite_test.go new file mode 100644 index 000000000..bb95c81e5 --- /dev/null +++ b/database/types/favorite_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "database/sql" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + + api "github.com/go-vela/server/api/types" +) + +func TestTypes_Favorite_ToAPI(t *testing.T) { + // setup types + want := new(api.Favorite) + want.SetPosition(1) + want.SetRepo("octocat/Hello-World") + + // run test + got := testFavorite().ToAPI() + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ToAPI() mismatch (-want +got):\n%s", diff) + } +} + +func TestTypes_FavoriteFromAPI(t *testing.T) { + // setup types + want := &Favorite{ + Position: sql.NullInt64{Int64: 1, Valid: true}, + RepoName: sql.NullString{String: "octocat/Hello-World", Valid: true}, + } + + f := new(api.Favorite) + f.SetPosition(1) + f.SetRepo("octocat/Hello-World") + + // run test + got := FavoriteFromAPI(f) + + if !reflect.DeepEqual(got, want) { + t.Errorf("FavoriteFromAPI is %v, want %v", got, want) + } +} + +// testFavorite is a test helper function to create a Favorite +// type with all fields set to a fake value. +func testFavorite() *Favorite { + return &Favorite{ + Position: sql.NullInt64{Int64: 1, Valid: true}, + RepoName: sql.NullString{String: "octocat/Hello-World", Valid: true}, + UserID: sql.NullInt64{Int64: 1, Valid: true}, + RepoID: sql.NullInt64{Int64: 1, Valid: true}, + } +} diff --git a/router/favorite.go b/router/favorite.go new file mode 100644 index 000000000..fb6d6c08b --- /dev/null +++ b/router/favorite.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 + +package router + +import ( + "github.com/gin-gonic/gin" + + "github.com/go-vela/server/api/favorite" + "github.com/go-vela/server/router/middleware/org" + "github.com/go-vela/server/router/middleware/repo" +) + +// BuildHandlers is a function that extends the provided base router group +// with the API handlers for build functionality. +// +// POST /api/v1/repos/:org/:repo/builds +// GET /api/v1/repos/:org/:repo/builds +// POST /api/v1/repos/:org/:repo/builds/:build +// GET /api/v1/repos/:org/:repo/builds/:build . +func FavoriteHandlers(base *gin.RouterGroup) { + // Favorites endpoints + favorites := base.Group("/favorites") + { + favorites.POST("", favorite.CreateFavorite) + favorites.GET("", favorite.ListFavorites) + + // Favorite endpoints + f := favorites.Group("/:org/:repo", org.Establish(), repo.Establish()) + { + f.DELETE("", favorite.DeleteFavorite) + f.PUT("", favorite.UpdateFavorite) + } // end of favorite endpoints + } // end of favorites endpoints +} diff --git a/router/user.go b/router/user.go index 149fe3146..77aef96c4 100644 --- a/router/user.go +++ b/router/user.go @@ -23,7 +23,10 @@ import ( // GET /api/v1/user/source/repos // POST /api/v1/user/token // DELETE /api/v1/user/token -// GET /api/v1/user/dashboards . +// GET /api/v1/user/dashboards +// POST /api/v1/user/favorites +// DELETE /api/v1/user/favorites/:org/:repo +// PUT /api/v1/user/favorites/shuffle . func UserHandlers(base *gin.RouterGroup) { // Users endpoints _users := base.Group("/users") @@ -44,5 +47,7 @@ func UserHandlers(base *gin.RouterGroup) { _user.POST("/token", user.CreateToken) _user.DELETE("/token", user.DeleteToken) _user.GET("/dashboards", dashboard.ListUserDashboards) + + FavoriteHandlers(_user) } // end of user endpoints } From d31aa5de427e57d5d53d52b33d407fc59b0b134f Mon Sep 17 00:00:00 2001 From: ecrupper Date: Fri, 14 Nov 2025 18:38:02 -0600 Subject: [PATCH 3/4] lintfix --- database/favorite/delete_test.go | 1 + database/favorite/update_position_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/database/favorite/delete_test.go b/database/favorite/delete_test.go index b476921a2..fb0e6e3c9 100644 --- a/database/favorite/delete_test.go +++ b/database/favorite/delete_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + "github.com/go-vela/server/constants" "github.com/go-vela/server/database/testutils" "github.com/go-vela/server/database/types" diff --git a/database/favorite/update_position_test.go b/database/favorite/update_position_test.go index 31a856b61..8b10f5248 100644 --- a/database/favorite/update_position_test.go +++ b/database/favorite/update_position_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + api "github.com/go-vela/server/api/types" "github.com/go-vela/server/constants" "github.com/go-vela/server/database/testutils" From 798e151625eb62efd3c995b1917e907e9433acac Mon Sep 17 00:00:00 2001 From: ecrupper Date: Fri, 14 Nov 2025 19:27:02 -0600 Subject: [PATCH 4/4] fix api spec --- api/favorite/delete.go | 11 +++++++++++ api/favorite/update.go | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/api/favorite/delete.go b/api/favorite/delete.go index 58fe8db73..97579ab50 100644 --- a/api/favorite/delete.go +++ b/api/favorite/delete.go @@ -20,6 +20,17 @@ import ( // --- // produces: // - application/json +// parameters: +// - in: path +// name: org +// description: Name of the organization +// required: true +// type: string +// - in: path +// name: repo +// description: Name of the repository +// required: true +// type: string // security: // - ApiKeyAuth: [] // responses: diff --git a/api/favorite/update.go b/api/favorite/update.go index 35cda6486..8f70fea67 100644 --- a/api/favorite/update.go +++ b/api/favorite/update.go @@ -22,6 +22,17 @@ import ( // --- // produces: // - application/json +// parameters: +// - in: path +// name: org +// description: Name of the organization +// required: true +// type: string +// - in: path +// name: repo +// description: Name of the repository +// required: true +// type: string // security: // - ApiKeyAuth: [] // responses: