From bfe8813466ed99f40633860ebed633caaa07f7f1 Mon Sep 17 00:00:00 2001 From: Luke Chui Date: Wed, 30 May 2018 20:25:23 -0700 Subject: [PATCH] init commit --- adapters/http/rating.go | 122 +++++++++++++++++++++++ app.go | 5 + mocks/RatingStore.go | 107 ++++++++++++++++++++ models/rating.go | 36 +++++++ services/rating.go | 143 +++++++++++++++++++++++++++ services/rating_test.go | 178 ++++++++++++++++++++++++++++++++++ stores/postgres/rating.go | 103 ++++++++++++++++++++ stores/postgres/rating_sql.go | 36 +++++++ 8 files changed, 730 insertions(+) create mode 100644 adapters/http/rating.go create mode 100644 mocks/RatingStore.go create mode 100644 models/rating.go create mode 100644 services/rating.go create mode 100644 services/rating_test.go create mode 100644 stores/postgres/rating.go create mode 100644 stores/postgres/rating_sql.go diff --git a/adapters/http/rating.go b/adapters/http/rating.go new file mode 100644 index 0000000..bcf252a --- /dev/null +++ b/adapters/http/rating.go @@ -0,0 +1,122 @@ +package http + +import ( + "net/http" + "strings" + + "github.com/labstack/echo" + "github.com/ucladevx/BPool/interfaces" + "github.com/ucladevx/BPool/models" + "github.com/ucladevx/BPool/services" + "github.com/ucladevx/BPool/utils/auth" +) + +type ( + // RatingService is used to provide and check ratings + RatingService interface { + Create(models.Rating, *auth.UserClaims) (*models.Rating, error) + GetByID(string, *auth.UserClaims) (*models.Rating, error) + GetRatingByUserID(string) (float32, error) + Delete(string, *auth.UserClaims) error + } + + // RatingController http adapter + RatingController struct { + service RatingService + passengerService PassengerService + logger interfaces.Logger + } +) + +// NewRatingController creates a new rating controller +func NewRatingController(ratingService RatingService, p PassengerService, l interfaces.Logger) *RatingController { + return &RatingController{ + service: ratingService, + passengerService: p, + logger: l, + } +} + +// MountRoutes mounts the rating routes +func (ratingController *RatingController) MountRoutes(c *echo.Group) { + c.DELETE( + "/ratings/:id", + ratingController.delete, + auth.NewAuthMiddleware(services.AdminLevel, ratingController.logger), + ) + + c.Use(auth.NewAuthMiddleware(services.UserLevel, ratingController.logger)) + + c.GET("/ratings/:id", ratingController.show) + c.GET("/ratings/user/:id", ratingController.getUserRating) + c.POST("/ratings", ratingController.create) +} + +func (ratingController *RatingController) show(c echo.Context) error { + id := c.Param("id") + user := userClaimsFromContext(c) + + rating, err := ratingController.service.GetByID(id, user) + + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + return c.JSON(http.StatusOK, echo.Map{ + "data": rating, + }) +} + +func (ratingController *RatingController) getUserRating(c echo.Context) error { + userID := c.Param("id") + + rating, err := ratingController.service.GetRatingByUserID(userID) + + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + return c.JSON(http.StatusOK, echo.Map{ + "data": rating, + }) +} + +func (ratingController *RatingController) create(c echo.Context) error { + data := models.Rating{} + + if err := c.Bind(&data); err != nil { + message := err.Error() + if strings.HasPrefix(message, "code=400, message=Syntax error") { + message = "Invalid JSON" + } + + return echo.NewHTTPError(http.StatusBadRequest, message) + } + + userClaims := userClaimsFromContext(c) + rating, err := ratingController.service.Create(data, userClaims) + + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + return c.JSON(http.StatusOK, echo.Map{ + "data": rating, + }) +} + +func (ratingController *RatingController) delete(c echo.Context) error { + id := c.Param("id") + userClaims := userClaimsFromContext(c) + + err := ratingController.service.Delete(id, userClaims) + + if err != nil { + status := 400 + // fill in other errors + + return echo.NewHTTPError(status, err.Error()) + } + + return c.NoContent(http.StatusNoContent) +} diff --git a/app.go b/app.go index 7050dcb..7095430 100644 --- a/app.go +++ b/app.go @@ -70,12 +70,14 @@ func Start() { carStore := postgres.NewCarStore(db) rideStore := postgres.NewRideStore(db) passengerStore := postgres.NewPassengerStore(db) + ratingStore := postgres.NewRatingStore(db) postgres.CreateTables( userStore, carStore, rideStore, passengerStore, + ratingStore, ) userService := services.NewUserService(userStore, tokenizer, logger) @@ -83,6 +85,7 @@ func Start() { rideService := services.NewRideService(rideStore, carService, logger) passengerService := services.NewPassengerService(passengerStore, rideService, logger) feedService := services.NewFeedService(rideStore, logger) + ratingService := services.NewRatingService(ratingStore, passengerService, logger) userController := http.NewUserController( userService, @@ -95,6 +98,7 @@ func Start() { carController := http.NewCarController(carService, logger) passengersController := http.NewPassengerController(passengerService, logger) feedController := http.NewFeedController(feedService, logger) + ratingController := http.NewRatingController(ratingService, passengerService, logger) app := echo.New() app.HTTPErrorHandler = handleError(logger) @@ -126,6 +130,7 @@ func Start() { carController.MountRoutes(app.Group("/api/v1")) passengersController.MountRoutes(app.Group("/api/v1")) feedController.MountRoutes(app.Group("/api/v1")) + ratingController.MountRoutes(app.Group("/api/v1")) logger.Info("CONFIG", "env", env) port := ":" + conf.Get("port") diff --git a/mocks/RatingStore.go b/mocks/RatingStore.go new file mode 100644 index 0000000..19ca894 --- /dev/null +++ b/mocks/RatingStore.go @@ -0,0 +1,107 @@ +// Code generated by mockery v1.0.0 +package mocks + +import mock "github.com/stretchr/testify/mock" +import models "github.com/ucladevx/BPool/models" + +import stores "github.com/ucladevx/BPool/stores" + +// RatingStore is an autogenerated mock type for the RatingStore type +type RatingStore struct { + mock.Mock +} + +// Average provides a mock function with given fields: _a0 +func (_m *RatingStore) Average(_a0 string) (float32, error) { + ret := _m.Called(_a0) + + var r0 float32 + if rf, ok := ret.Get(0).(func(string) float32); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(float32) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: _a0 +func (_m *RatingStore) Delete(_a0 string) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetByID provides a mock function with given fields: _a0 +func (_m *RatingStore) GetByID(_a0 string) (*models.Rating, error) { + ret := _m.Called(_a0) + + var r0 *models.Rating + if rf, ok := ret.Get(0).(func(string) *models.Rating); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Rating) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Insert provides a mock function with given fields: rating +func (_m *RatingStore) Insert(rating *models.Rating) error { + ret := _m.Called(rating) + + var r0 error + if rf, ok := ret.Get(0).(func(*models.Rating) error); ok { + r0 = rf(rating) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// WhereMany provides a mock function with given fields: _a0 +func (_m *RatingStore) WhereMany(_a0 []stores.QueryModifier) ([]*models.Rating, error) { + ret := _m.Called(_a0) + + var r0 []*models.Rating + if rf, ok := ret.Get(0).(func([]stores.QueryModifier) []*models.Rating); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Rating) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]stores.QueryModifier) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/models/rating.go b/models/rating.go new file mode 100644 index 0000000..426bcb0 --- /dev/null +++ b/models/rating.go @@ -0,0 +1,36 @@ +package models + +import ( + "fmt" + "time" + + validation "github.com/go-ozzo/ozzo-validation" +) + +// Rating model instance +type Rating struct { + ID string `json:"id"` + Rating int `json:"rating"` + RideID string `json:"ride_id" db:"ride_id"` + RaterID string `json:"rater_id"` + RateeID string `json:"ratee_id"` + Comment string `json:"comment"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Validate validates rating before insertion +func (r *Rating) Validate() error { + return validation.ValidateStruct(r, + validation.Field(&r.Rating, validation.Required, validation.Min(1)), + validation.Field(&r.RideID, validation.Required), + validation.Field(&r.RaterID, validation.Required), + validation.Field(&r.RateeID, validation.Required), + ) +} + +func (r *Rating) String() string { + return fmt.Sprintf(" 0 { + ratingService.logger.Error("RatingService.Create - cannot add duplicate ratings", "error", err) + return nil, ErrDuplciateRating + } + + rating := &models.Rating{ + Rating: rat, + RideID: rideID, + RateeID: rateeID, + RaterID: raterID, + Comment: comment, + } + + // Model level validation + if err := rating.Validate(); err != nil { + ratingService.logger.Error("RatingService.Create - validate", "error", err) + return nil, err + } + + // DB level err handling + if err := ratingService.store.Insert(rating); err != nil { + ratingService.logger.Error("RatingService.Create - unable to leave rating", "error", err.Error()) + return nil, err + } + + return rating, nil +} + +// GetByID returns average rating for a certain ride +func (ratingService *RatingService) GetByID(id string, user *auth.UserClaims) (*models.Rating, error) { + if user.AuthLevel != AdminLevel { + return nil, ErrForbidden + } + + return ratingService.store.GetByID(id) +} + +// GetRatingByUserID returns average rating for a certain ride +func (ratingService *RatingService) GetRatingByUserID(userID string) (float32, error) { + rating, err := ratingService.store.Average(userID) + + if err != nil { + ratingService.logger.Error("RatingService.GetRatingByRideID - Average", "error", err.Error()) + return -1, err + } + + return rating, nil +} + +// Delete removes rating from store if user is allowed to +func (ratingService *RatingService) Delete(ratingID string, user *auth.UserClaims) error { + rating, err := ratingService.store.GetByID(ratingID) + + if err != nil { + ratingService.logger.Error("RatingService.Delete - Delete", "error", err.Error()) + return err + } + + if user.AuthLevel != AdminLevel { + return ErrForbidden + } + + return ratingService.store.Delete(rating.ID) +} diff --git a/services/rating_test.go b/services/rating_test.go new file mode 100644 index 0000000..ba5ea8b --- /dev/null +++ b/services/rating_test.go @@ -0,0 +1,178 @@ +package services_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ucladevx/BPool/mocks" + "github.com/ucladevx/BPool/models" + "github.com/ucladevx/BPool/services" + "github.com/ucladevx/BPool/stores" + "github.com/ucladevx/BPool/stores/postgres" + "github.com/ucladevx/BPool/utils/auth" +) + +var ( + rating1 = models.Rating{ + ID: "goodRating", + Rating: 5, + RideID: ride1.ID, + RaterID: johnDoe.ID, + RateeID: janeSmith.ID, + Comment: "good ride", + } + + rating2 = models.Rating{ + ID: "poorRating", + Rating: 2, + RideID: ride1.ID, + RaterID: "dawg", + RateeID: janeSmith.ID, + Comment: "", + } +) + +func newRatingService(store *mocks.RatingStore, p *services.PassengerService) *services.RatingService { + logger := mocks.Logger{} + return services.NewRatingService(store, p, logger) +} + +func TestRatingGetByID(t *testing.T) { + store := new(mocks.RatingStore) + p := newMockedPassengerService() + service := newRatingService(store, p.passengerService) + assert := assert.New(t) + + // Return error if user does not have proper auth level + store.On("GetByID", "forbidden").Return(nil, services.ErrForbidden) + + user := auth.UserClaims{ + ID: "testUser", + Email: "test@gmail.com", + AuthLevel: services.UserLevel, + } + + noRating, err := service.GetByID("forbidden", &user) + + assert.Nil(noRating, "for unauthorized user, there should be no rating") + assert.Equal(services.ErrForbidden, err, "if user unauthorized, should return error") + + // Return error if rating does not exist + store.On("GetByID", "badID").Return(nil, postgres.ErrNoRatingFound) + + user = auth.UserClaims{ + ID: "testUser", + Email: "test@gmail.com", + AuthLevel: services.AdminLevel, + } + + noRating, err = service.GetByID("badID", &user) + + assert.Nil(noRating, "for a bad id there should be no rating") + assert.Equal(postgres.ErrNoRatingFound, err, "if no rating found should return no rating found error") + + // Return reating if rating does exist and user is authorized + store.On("GetByID", rating1.ID).Return(&rating1, nil) + + user = auth.UserClaims{ + ID: "testUser", + Email: "test@gmail.com", + AuthLevel: services.AdminLevel, + } + + rating, noErr := service.GetByID(rating1.ID, &user) + + assert.Nil(noErr, "no error if rating exists") + assert.Equal(rating.ID, rating1.ID, "should return rating if no problem with query") +} + +func TestRatingCreate(t *testing.T) { + store := new(mocks.RatingStore) + p := newMockedPassengerService() + service := newRatingService(store, p.passengerService) + assert := assert.New(t) + + user := auth.UserClaims{ + ID: "testUser", + Email: "test@gmail.com", + AuthLevel: services.AdminLevel, + } + + passengers := []*models.Passenger{ + &validPassenger, + } + + // Return err on duplicate ratings + queryModifiers := []stores.QueryModifier{ + stores.QueryMod("ride_id", stores.EQ, rating1.RideID), + } + + duplicates := []*models.Rating{ + &rating1, &rating2, + } + + p.rideStore.On("GetByID", rating1.RideID).Return(&ride1, nil) + p.passengerStore.On("WhereMany", queryModifiers).Return(passengers) + store.On("WhereMany", queryModifiers).Return(duplicates) + + toAdd := rating1 + + noRating, err := service.Create(toAdd, &user) + + assert.Nil(noRating, "fail rating creation if duplicate") + assert.Equal(services.ErrDuplciateRating, err, "return error on duplicate rating") +} + +func TestRatingGetRatingByUserID(t *testing.T) { + store := new(mocks.RatingStore) + p := newMockedPassengerService() + service := newRatingService(store, p.passengerService) + assert := assert.New(t) + + // Returns average of all ratings for ride + store.On("Average", janeSmith.ID).Return(float32(3.5), nil) + + rating, err := service.GetRatingByUserID(janeSmith.ID) + + assert.Nil(err, "should not return error if ratee has any ratings") + assert.Equal(rating, float32(3.5), "successfully returns average rating") +} + +func TestRatingDelete(t *testing.T) { + store := new(mocks.RatingStore) + p := newMockedPassengerService() + service := newRatingService(store, p.passengerService) + assert := assert.New(t) + + // Successfully delete is ride exists and user is admin level + store.On("GetByID", rating1.ID).Return(&rating1, nil) + store.On("Delete", rating1.ID).Return(nil) + user := auth.UserClaims{ + ID: "testUser", + Email: "test@gmail.com", + AuthLevel: services.AdminLevel, + } + + err := service.Delete(rating1.ID, &user) + assert.Nil(err, "successful delete should not return an error") + + // Return err if rating does not exist + store.On("GetByID", "badID").Return(nil, postgres.ErrNoRatingFound) + err = service.Delete("badID", &user) + + assert.NotNil(err, "for a bad id there should be no rating") + assert.Equal(postgres.ErrNoRatingFound, err, "if no rating found should return no rating found error") + + // Return error if user is not admin level + user = auth.UserClaims{ + ID: "testUser", + Email: "test@gmail.com", + AuthLevel: services.UserLevel, + } + + err = service.Delete(rating1.ID, &user) + + assert.NotNil(err, "for unauthorized user, user cannot delete rating") + assert.Equal(services.ErrForbidden, err, "if user unauthorized, should return error") +} diff --git a/stores/postgres/rating.go b/stores/postgres/rating.go new file mode 100644 index 0000000..272e603 --- /dev/null +++ b/stores/postgres/rating.go @@ -0,0 +1,103 @@ +package postgres + +import ( + "database/sql" + "errors" + + "github.com/jmoiron/sqlx" + "github.com/ucladevx/BPool/models" + "github.com/ucladevx/BPool/stores" + "github.com/ucladevx/BPool/utils/id" +) + +// RatingStore persists ratings in DB +type RatingStore struct { + db *sqlx.DB + idGen IDgen +} + +var ( + // ErrNoRatingFound error when no rating in db + ErrNoRatingFound = errors.New("no rating found") +) + +// NewRatingStore creates a new pg rating store +func NewRatingStore(db *sqlx.DB) *RatingStore { + return &RatingStore{ + db: db, + idGen: id.New, + } +} + +// Insert persists a new rating into the DB +func (ratingStore *RatingStore) Insert(rating *models.Rating) error { + rating.ID = ratingStore.idGen() + + row := ratingStore.db.QueryRow( + ratingInsertSQL, + rating.ID, + rating.Rating, + rating.RideID, + rating.RaterID, + rating.RateeID, + rating.Comment, + ) + + return row.Scan(&rating.CreatedAt, &rating.UpdatedAt) +} + +// GetByID finds a rating by ID if exits in the DB +func (ratingStore *RatingStore) GetByID(id string) (*models.Rating, error) { + return ratingStore.getBy(ratingGetByIDSQL, id) +} + +// Average determines average rating for provided rideID +func (ratingStore *RatingStore) Average(userID string) (float32, error) { + row := ratingStore.db.QueryRow(ratingGetAverageRatingSQL, userID) + average := float32(0.0) + + if err := row.Scan(&row); err != nil { + return 0.0, err + } + + return average, nil +} + +// Delete deleted rating +func (ratingStore *RatingStore) Delete(ratingID string) error { + _, err := ratingStore.db.Exec(ratingDeleteSQL, ratingID) + return err +} + +func (ratingStore *RatingStore) getBy(query string, arg interface{}) (*models.Rating, error) { + rating := models.Rating{} + + if err := ratingStore.db.Get(&rating, query, arg); err != nil { + if err == sql.ErrNoRows { + err = ErrNoRatingFound + } + + return nil, err + } + + return &rating, nil +} + +// WhereMany provides a generic query interface to get many ratings +func (ratingStore *RatingStore) WhereMany(clauses []stores.QueryModifier) ([]*models.Rating, error) { + where, vals := generateWhereStatement(&clauses) + query := "SELECT * FROM ratings " + where + + ratings := []*models.Rating{} + + if err := ratingStore.db.Select(&ratings, query, vals...); err != nil { + return nil, err + } + + return ratings, nil +} + +// Migrate create ratings table in DB +func (ratingStore *RatingStore) migrate() { + ratingStore.db.MustExec(ratingCreateTable) +} diff --git a/stores/postgres/rating_sql.go b/stores/postgres/rating_sql.go new file mode 100644 index 0000000..2bec9a8 --- /dev/null +++ b/stores/postgres/rating_sql.go @@ -0,0 +1,36 @@ +package postgres + +const ( + ratingCreateTable = ` + CREATE TABLE IF NOT EXISTS ratings ( + id varchar(20) primary key, + rating int NOT NULL, + ride_id varchar(20) NOT NULL, + rater_id varchar(20) NOT NULL, + ratee_id varchar(20) NOT NULL, + comment varchar(500), + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW(), + FOREIGN KEY (ride_id) REFERENCES rides (id) ON DELETE CASCADE, + FOREIGN KEY (rater_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (ratee_id) REFERENCES users (id) ON DELETE CASCADE + ) + ` + + ratingInsertSQL = ` + INSERT INTO ratings (id, rating, ride_id, rater_id, ratee_id, comment) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at + ` + + ratingGetByIDSQL = ` + SELECT * FROM ratings WHERE id=$1 + ` + ratingGetAverageRatingSQL = ` + SELECT AVG(rating) FROM ratings WHERE ratee_id=$1 + ` + + ratingDeleteSQL = ` + DELETE FROM ratings WHERE id=$1 + ` +)