diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a0296ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.14.10 AS builder +RUN mkdir /app +ADD . /app +WORKDIR /app +RUN CGO_ENABLED=0 GOOS=linux go build -o go-e-commerce_product-api + +FROM alpine:latest AS production +COPY --from=builder /app . +ENTRYPOINT ["./go-e-commerce_product-api"] +CMD ["start"] diff --git a/README.md b/README.md index 6875cd9..6d73984 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,93 @@ -## Golang Boilerplate -Golang boilerplate to kickstart any go api project. This supports 2 database configurations currently: +Assumption 1: You are simran. +Assumption 2: Words in CAPITAL_CASE are meant to be set by simran. +Assumption 3: Port 33001 must be free, else specify other port in application.yml . +Assumption 4: Postgresql server is running on port 5432, if on other port then makes appropriate changes in application.yml. +Assumption 5: All the source code is pulled from git and you are currently on product_api branch -* MongoDB -* Postgres (default) +## Setting Up Database +Use PostgresQL database with version greater than 12. -### Installing and configuring the boilerplate! -Get the install.sh script into your microservice development directory. +Create a database in PostgresQL via commandline +sudo -u postgres createdb --owner=USERNAME DATABASE_NAME +example: +``` +sudo -u postgres createdb --owner=simran Commerce +``` + +``` +[Just For Knowledge] +Drop a database in PostgresQL via commandline +dropdb -h localhost -p PORT_NUMBER -U USERNAME DATABASE_NAME +example: +dropdb -h localhost -p 5432 -U simran Commerce +``` + +Copy your DB_URI to application.yml file +DB_URI: "postgresql://USERNAME:PASSWORD@localhost:PORT_NUMBER/DATABASE_NAME?sslmode=disable" +example: +``` +"postgresql://simran:root@localhost:5432/Commerce?sslmode=disable" +``` +### golang version +golang version 1.14.10 +### Build the Product_api ``` -wget https://raw.githubusercontent.com/joshsoftware/golang-boilerplate/master/install.sh +go build ``` -Run the script with the options: +You should see some executable getting created with name go-e-commerce +### Migrate ``` -. ./install.sh -p package_name [-d {mongo|pg}] [-h] +./go-e-commerce migrate ``` --p: [mandatory] Usually your github handle and service name. Eg. gautamrege/testly or github.com/corp/pkg/service +This will create tables in the DATABASE_NAME you specified in application.yml --d: [optional] Default: pg. Specify 'mongo' for mongoDB setup +### Copying Dump to Database +Copy the migration.sql dump to this db with following command. +psql DATABASE_NAME < PATH_TO_migration.sql +example : +``` +psql Commerce < /home/simran/Desktop/Josh/InternProject/go-e-commerce/migration.sql +``` --h: [optional] Display help +You should see that all records have been inserted successfully, if not either you messed up or there is version problem or our code is broke. -### Testing -Run test locally +### Run directly on host ``` -$ make test +./go-e-commerce start ``` + +### Run on Docker + +Dependency 1: You must have docker installed with sudo rights. +Dependency 2: You must have all migrations already created(See Migrate). + +## First Way +Build Locally and run +``` +docker build -t joshsoftware/go-e-commerce_product-api:v1 . + + +docker run -it -p 33001:33001 \ + --network=host \ + joshsoftware/go-e-commerce_product-api:v1 +``` + +## Second Way +Directly Pull the docker image from https://hub.docker.com/repository/docker/skavhar1998/go-e-commerce_product-api and then run. + +``` +docker pull skavhar1998/go-e-commerce_product-api:v1 + +docker run -it -p 33001:33001 \ + --network=host \ + skavhar1998/go-e-commerce_product-api:v1 +``` + +### For documentation, refer the docs folder +docs only include Product_api documentation \ No newline at end of file diff --git a/assets/productImages/AndroidLEDTV1.jpeg b/assets/productImages/AndroidLEDTV1.jpeg new file mode 100644 index 0000000..dfacbe3 Binary files /dev/null and b/assets/productImages/AndroidLEDTV1.jpeg differ diff --git a/assets/productImages/AndroidLEDTV2.jpeg b/assets/productImages/AndroidLEDTV2.jpeg new file mode 100644 index 0000000..967bff0 Binary files /dev/null and b/assets/productImages/AndroidLEDTV2.jpeg differ diff --git a/assets/productImages/AndroidLEDTV3.jpeg b/assets/productImages/AndroidLEDTV3.jpeg new file mode 100644 index 0000000..4e75f7d Binary files /dev/null and b/assets/productImages/AndroidLEDTV3.jpeg differ diff --git a/assets/productImages/Charger1.jpeg b/assets/productImages/Charger1.jpeg new file mode 100644 index 0000000..6364b96 Binary files /dev/null and b/assets/productImages/Charger1.jpeg differ diff --git a/assets/productImages/Charger2.jpeg b/assets/productImages/Charger2.jpeg new file mode 100644 index 0000000..0ef8224 Binary files /dev/null and b/assets/productImages/Charger2.jpeg differ diff --git a/assets/productImages/Charger3.jpeg b/assets/productImages/Charger3.jpeg new file mode 100644 index 0000000..265ae1e Binary files /dev/null and b/assets/productImages/Charger3.jpeg differ diff --git a/assets/productImages/DragonJacket1.jpeg b/assets/productImages/DragonJacket1.jpeg new file mode 100644 index 0000000..767bb21 Binary files /dev/null and b/assets/productImages/DragonJacket1.jpeg differ diff --git a/assets/productImages/DragonJacket2.jpeg b/assets/productImages/DragonJacket2.jpeg new file mode 100644 index 0000000..25cbe66 Binary files /dev/null and b/assets/productImages/DragonJacket2.jpeg differ diff --git a/assets/productImages/DragonJacket3.jpeg b/assets/productImages/DragonJacket3.jpeg new file mode 100644 index 0000000..3bd15e6 Binary files /dev/null and b/assets/productImages/DragonJacket3.jpeg differ diff --git a/assets/productImages/Football1.jpeg b/assets/productImages/Football1.jpeg new file mode 100644 index 0000000..a6bced7 Binary files /dev/null and b/assets/productImages/Football1.jpeg differ diff --git a/assets/productImages/Football2.jpeg b/assets/productImages/Football2.jpeg new file mode 100644 index 0000000..5a93aff Binary files /dev/null and b/assets/productImages/Football2.jpeg differ diff --git a/assets/productImages/Football3.jpeg b/assets/productImages/Football3.jpeg new file mode 100644 index 0000000..e8622a1 Binary files /dev/null and b/assets/productImages/Football3.jpeg differ diff --git a/assets/productImages/Football4.jpeg b/assets/productImages/Football4.jpeg new file mode 100644 index 0000000..fb6651f Binary files /dev/null and b/assets/productImages/Football4.jpeg differ diff --git a/assets/productImages/Football5.jpeg b/assets/productImages/Football5.jpeg new file mode 100644 index 0000000..241e65f Binary files /dev/null and b/assets/productImages/Football5.jpeg differ diff --git a/assets/productImages/Football6.jpeg b/assets/productImages/Football6.jpeg new file mode 100644 index 0000000..49a8125 Binary files /dev/null and b/assets/productImages/Football6.jpeg differ diff --git a/assets/productImages/MensRunningShoes1.jpeg b/assets/productImages/MensRunningShoes1.jpeg new file mode 100644 index 0000000..0588fec Binary files /dev/null and b/assets/productImages/MensRunningShoes1.jpeg differ diff --git a/assets/productImages/MensRunningShoes2.jpeg b/assets/productImages/MensRunningShoes2.jpeg new file mode 100644 index 0000000..c4530ff Binary files /dev/null and b/assets/productImages/MensRunningShoes2.jpeg differ diff --git a/assets/productImages/MensRunningShoes3.jpeg b/assets/productImages/MensRunningShoes3.jpeg new file mode 100644 index 0000000..3ed42cf Binary files /dev/null and b/assets/productImages/MensRunningShoes3.jpeg differ diff --git a/assets/productImages/RelaxWatch1.jpeg b/assets/productImages/RelaxWatch1.jpeg new file mode 100644 index 0000000..c983dc5 Binary files /dev/null and b/assets/productImages/RelaxWatch1.jpeg differ diff --git a/assets/productImages/RelaxWatch2.jpeg b/assets/productImages/RelaxWatch2.jpeg new file mode 100644 index 0000000..9b69f0d Binary files /dev/null and b/assets/productImages/RelaxWatch2.jpeg differ diff --git a/assets/productImages/RelaxWatch3.jpeg b/assets/productImages/RelaxWatch3.jpeg new file mode 100644 index 0000000..825294c Binary files /dev/null and b/assets/productImages/RelaxWatch3.jpeg differ diff --git a/assets/productImages/SonataWatch1.jpeg b/assets/productImages/SonataWatch1.jpeg new file mode 100644 index 0000000..69f56bc Binary files /dev/null and b/assets/productImages/SonataWatch1.jpeg differ diff --git a/assets/productImages/SonataWatch2.jpeg b/assets/productImages/SonataWatch2.jpeg new file mode 100644 index 0000000..e945c58 Binary files /dev/null and b/assets/productImages/SonataWatch2.jpeg differ diff --git a/assets/productImages/SonataWatch3.jpeg b/assets/productImages/SonataWatch3.jpeg new file mode 100644 index 0000000..4f5d824 Binary files /dev/null and b/assets/productImages/SonataWatch3.jpeg differ diff --git a/assets/productImages/SonyDSC1.jpeg b/assets/productImages/SonyDSC1.jpeg new file mode 100644 index 0000000..640cc8a Binary files /dev/null and b/assets/productImages/SonyDSC1.jpeg differ diff --git a/assets/productImages/SonyDSC2.jpeg b/assets/productImages/SonyDSC2.jpeg new file mode 100644 index 0000000..cf6edf5 Binary files /dev/null and b/assets/productImages/SonyDSC2.jpeg differ diff --git a/assets/productImages/SonyDSC3.jpeg b/assets/productImages/SonyDSC3.jpeg new file mode 100644 index 0000000..17398c3 Binary files /dev/null and b/assets/productImages/SonyDSC3.jpeg differ diff --git a/assets/productImages/Titan Watch-151948565.png b/assets/productImages/Titan Watch-151948565.png new file mode 100644 index 0000000..e8edfd9 Binary files /dev/null and b/assets/productImages/Titan Watch-151948565.png differ diff --git a/assets/productImages/TitanWatch1.jpeg b/assets/productImages/TitanWatch1.jpeg new file mode 100644 index 0000000..58814f9 Binary files /dev/null and b/assets/productImages/TitanWatch1.jpeg differ diff --git a/assets/productImages/TitanWatch2.jpeg b/assets/productImages/TitanWatch2.jpeg new file mode 100644 index 0000000..0daf845 Binary files /dev/null and b/assets/productImages/TitanWatch2.jpeg differ diff --git a/assets/productImages/TitanWatch3.jpeg b/assets/productImages/TitanWatch3.jpeg new file mode 100644 index 0000000..c2a158a Binary files /dev/null and b/assets/productImages/TitanWatch3.jpeg differ diff --git a/assets/productImages/WingsofFire1.jpeg b/assets/productImages/WingsofFire1.jpeg new file mode 100644 index 0000000..c386c12 Binary files /dev/null and b/assets/productImages/WingsofFire1.jpeg differ diff --git a/assets/productImages/WingsofFire2.jpeg b/assets/productImages/WingsofFire2.jpeg new file mode 100644 index 0000000..d67f0c6 Binary files /dev/null and b/assets/productImages/WingsofFire2.jpeg differ diff --git a/assets/productImages/Wrangler1.jpeg b/assets/productImages/Wrangler1.jpeg new file mode 100644 index 0000000..a2cdf5e Binary files /dev/null and b/assets/productImages/Wrangler1.jpeg differ diff --git a/assets/productImages/Wrangler2.jpeg b/assets/productImages/Wrangler2.jpeg new file mode 100644 index 0000000..04afd0d Binary files /dev/null and b/assets/productImages/Wrangler2.jpeg differ diff --git a/assets/productImages/poloshirt1.jpeg b/assets/productImages/poloshirt1.jpeg new file mode 100644 index 0000000..00dbd67 Binary files /dev/null and b/assets/productImages/poloshirt1.jpeg differ diff --git a/assets/productImages/poloshirt2.jpeg b/assets/productImages/poloshirt2.jpeg new file mode 100644 index 0000000..b6d4320 Binary files /dev/null and b/assets/productImages/poloshirt2.jpeg differ diff --git a/assets/productImages/poloshirt3.jpeg b/assets/productImages/poloshirt3.jpeg new file mode 100644 index 0000000..d004914 Binary files /dev/null and b/assets/productImages/poloshirt3.jpeg differ diff --git a/assets/productImages/vivkananadbook1.jpeg b/assets/productImages/vivkananadbook1.jpeg new file mode 100644 index 0000000..c334df8 Binary files /dev/null and b/assets/productImages/vivkananadbook1.jpeg differ diff --git a/assets/productImages/vivkananadbook2.jpeg b/assets/productImages/vivkananadbook2.jpeg new file mode 100644 index 0000000..dd96071 Binary files /dev/null and b/assets/productImages/vivkananadbook2.jpeg differ diff --git a/config/config.go b/config/config.go index af428d1..fc49530 100644 --- a/config/config.go +++ b/config/config.go @@ -63,5 +63,6 @@ func checkIfSet(key string) { if !viper.IsSet(key) { err := errors.New(fmt.Sprintf("Key %s is not set", key)) panic(err) + //logger.WithField("err", err.Error()).Error("Error Couldn't find db!") } } diff --git a/db/common.go b/db/common.go new file mode 100644 index 0000000..29de8fe --- /dev/null +++ b/db/common.go @@ -0,0 +1,7 @@ +package db + +type ErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Fields map[string]string `json:"fields"` +} diff --git a/db/common_test.go b/db/common_test.go new file mode 100644 index 0000000..14f574a --- /dev/null +++ b/db/common_test.go @@ -0,0 +1,37 @@ +package db + +import ( + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + logger "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" +) + +var ( + now time.Time + mockedRows *sqlmock.Rows +) + +func InitMockDB() (s Storer, sqlConn *sqlx.DB, sqlmockInstance sqlmock.Sqlmock) { + mockDB, sqlmock, err := sqlmock.New() + if err != nil { + logger.WithField("err:", err).Error("error initializing mock db") + return + } + + sqlmockInstance = sqlmock + sqlxDB := sqlx.NewDb(mockDB, "sqlmock") + + var pgStoreConn pgStore + pgStoreConn.db = sqlxDB + + return &pgStoreConn, sqlxDB, sqlmockInstance +} + +func TestExampleTestSuite(t *testing.T) { + suite.Run(t, new(ProductsTestSuite)) + suite.Run(t, new(FilterTestSuite)) +} diff --git a/db/db.go b/db/db.go index 2d1556a..d1ea20d 100644 --- a/db/db.go +++ b/db/db.go @@ -2,11 +2,16 @@ package db import ( "context" + "mime/multipart" ) type Storer interface { - ListUsers(context.Context) ([]User, error) - //Create(context.Context, User) error - //GetUser(context.Context) (User, error) - //Delete(context.Context, string) error + ListProducts(context.Context, int, int) (int, []Product, error) + FilteredProducts(context.Context, Filter, string, string) (int, []Product, error) + SearchProductsByText(context.Context, string, string, string) (int, []Product, error) + CreateProduct(context.Context, Product, []*multipart.FileHeader) (Product, error, int) + DeleteProductById(context.Context, int) error + UpdateProductById(context.Context, Product, int, []*multipart.FileHeader) (Product, error, int) + UpdateProductStockById(context.Context, int, int) (Product, error, int) + GetProductByID(context.Context, int) (Product, error) } diff --git a/db/filters.go b/db/filters.go new file mode 100644 index 0000000..3e22e37 --- /dev/null +++ b/db/filters.go @@ -0,0 +1,164 @@ +package db + +import ( + "context" + "fmt" + "regexp" + "strings" + + logger "github.com/sirupsen/logrus" +) + +var ( + filterSearchProduct = `SELECT count(*) OVER() AS total,* + FROM products p + INNER JOIN category c + ON p.cid = c.cid` +) + +type Filter struct { + // Below fields are what we may receive as Parameters in request body + CategoryId string + Price string + Brand string + Size string + Color string + // These Flags will help us format our query, true means that field exists in Request Parameters + CategoryFlag bool + PriceFlag bool + BrandFlag bool + SizeFlag bool + ColorFlag bool +} + +// TODO Add condition and sort capabilities for both these APIs +// These capabilities are suppossed to make filter and search APIs +// really really dynamic and much robust. +// eg for condition -> WHERE cid >= 5 AND tax <= 4 +// eg for sort -> category_id = desc, price asc + +// @Title FilteredProducts +// @Description Get the products that are filtered as per request Parameters +// @Accept request.Context, Filter struct's object +// @Success total= (count of filtered products), error=nil +// @Failure total=0, error= "Some Error" +func (s *pgStore) FilteredProducts(ctx context.Context, filter Filter, limitStr string, offsetStr string) (int, []Product, error) { + + var found bool + var records Records + var err error + + // helper will be used in making query dynamic. + // See how it's getting concatanation added in case a flag was Filter Flag is true + sqlRegexp := `` + filterQuery := ` ` + if filter.CategoryFlag == true { + filterQuery += ` c.cid = ` + filter.CategoryId + ` AND` + sqlRegexp += filter.CategoryId + found = true + } + if filter.BrandFlag { + filterQuery += ` LOWER(p.brand) = LOWER('` + filter.Brand + `') AND` + sqlRegexp += filter.Brand + found = true + } + if filter.SizeFlag { + filterQuery += ` LOWER(p.size) = LOWER('` + filter.Size + `') AND` + sqlRegexp += filter.Size + found = true + } + if filter.ColorFlag { + filterQuery += ` LOWER(p.color) =LOWER('` + filter.Color + `') AND` + sqlRegexp += filter.Color + found = true + } + if found { + var validParameters = regexp.MustCompile(`^[\w ]+$`) + if validParameters.MatchString(sqlRegexp) == false { + err = fmt.Errorf("Possible SQL Injection Attack.") + logger.WithField("err", err.Error()).Error("Error In Parameters, special Characters are present.") + return 0, []Product{}, err + } + filterQuery = ` WHERE ` + filterQuery[:len(filterQuery)-3] + } + + filterQuery = filterSearchProduct + filterQuery + + if filter.PriceFlag { + filterQuery += ` ORDER BY p.price ` + filter.Price + `, p.id LIMIT ` + limitStr + ` OFFSET ` + offsetStr + ` ;` + } else { + filterQuery += ` ORDER BY p.id LIMIT ` + limitStr + ` OFFSET ` + offsetStr + ` ;` + } + + err = s.db.Select(&records, filterQuery) + if err != nil { + logger.WithField("err", err.Error()).Error("Error fetching Products from database") + return 0, []Product{}, err + } else if len(records) == 0 { + err = fmt.Errorf("Desired page not found, Offset was big") + logger.WithField("err", err.Error()).Error("Products don't exist by such filters!") + return 0, []Product{}, err + } + + return records[0].TotalRecords, records.Products(), nil +} + +// @Title SearchRecords +// @Description Get records that are searched as per request Parameter "text" along with count +// @Accept request.Context, text as string, limitStr, pageStr +// @Success total= (count of search qualifying records), error=nil +// @Failure total=0, error= "Some Error" +func (s *pgStore) SearchProductsByText(ctx context.Context, text string, limitStr string, offsetStr string) (int, []Product, error) { + + var records Records + var validParameters = regexp.MustCompile(`^[\w ]+$`) + + // if There are other chracters than word and space + if validParameters.MatchString(text) == false { + err := fmt.Errorf("Possible SQL Injection Attack.") + logger.WithField("err", err.Error()).Error("Error In Parameters, special Characters are present.") + return 0, []Product{}, err + } + + // Split the text into slice of strings, max 10 first words will be considered + textSlice := strings.SplitN(text, " ", 11) + + // If there are more than 10 words in search, ask user to be less verbose + if len(textSlice) > 10 { + err := fmt.Errorf("Unnecessary detailed text given.") + logger.WithField("err", err.Error()).Error("Error In Parameters, very detailed!.") + return 0, []Product{}, err + } + + // Removing Duplicate words from textSlice + textMap := make(map[string]bool, 10) + for i := 0; i < len(textSlice); i++ { + textMap[textSlice[i]] = true + } + + searchQuery := ` WHERE ` + + // iterate over all the textMap + for key := range textMap { + searchQuery += ` + LOWER(p.name) LIKE LOWER('%` + key + `%') OR + LOWER(p.brand) LIKE LOWER('%` + key + `%') OR + LOWER(c.cname) LIKE LOWER('%` + key + `%') OR` + } + + // remove that last OR from searchQuery + searchQuery = filterSearchProduct + searchQuery[:len(searchQuery)-2] + + ` LIMIT ` + limitStr + ` OFFSET ` + offsetStr + ` ;` + + err := s.db.Select(&records, searchQuery) + if err != nil { + logger.WithField("err", err.Error()).Error("Error fetching Products from database") + return 0, []Product{}, err + } else if len(records) == 0 { + err = fmt.Errorf("Either Offset was big or No Records Present in database!") + logger.WithField("err", err.Error()).Error("database Returned total record count as 0") + return 0, []Product{}, err + } + + return records[0].TotalRecords, records.Products(), nil +} diff --git a/db/filters_test.go b/db/filters_test.go new file mode 100644 index 0000000..60d9345 --- /dev/null +++ b/db/filters_test.go @@ -0,0 +1,148 @@ +package db + +import ( + "context" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type FilterTestSuite struct { + suite.Suite + dbStore Storer + db *sqlx.DB + sqlmock sqlmock.Sqlmock +} + +func (suite *FilterTestSuite) SetupTest() { + dbStore, dbConn, sqlmock := InitMockDB() + suite.dbStore = dbStore + suite.db = dbConn + suite.sqlmock = sqlmock +} + +func (suite *FilterTestSuite) TearDownTest() { + suite.db.Close() +} + +var testFilter = Filter{ + CategoryId: "1", + Price: "asc", + Brand: "", + Size: "", + Color: "", + CategoryFlag: true, + PriceFlag: true, + BrandFlag: false, + SizeFlag: false, + ColorFlag: false, +} + +func (suite *FilterTestSuite) TestFilteredProductsSuccess() { + + products := []Product{ + Product{ + Id: 2, + Name: "Wrangler", + Description: "Men Slim Fit Jeans", + Price: 600, + Discount: 20, + Tax: 12, + Quantity: 7, + CategoryId: 1, + CategoryName: "Clothes", + Brand: "Armani", + Color: "Charcoal Black", + Size: "Large", + URLs: []string{ + "url1", + "url2", + }, + }, + Product{ + Id: 3, + Name: "Dragon Jacket", + Description: "Made from the skin of one of the dragons", + Price: 700, + Discount: 40, + Tax: 9, + Quantity: 5, + CategoryId: 1, + CategoryName: "Clothes", + Brand: "Veteran", + Color: "Black", + Size: "Extra Large", + URLs: []string{ + "url1", + "url2", + }, + }, + } + + suite.sqlmock.ExpectBegin() + count, filteredProducts, err := suite.dbStore.FilteredProducts(context.Background(), testFilter, "1", "1") + + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), 2, count) + assert.Equal(suite.T(), products, filteredProducts) +} + +func (suite *FilterTestSuite) TestFilteredProductsFailure() { + suite.sqlmock.ExpectBegin() + suite.sqlmock.ExpectRollback() + + count, filteredProducts, err := suite.dbStore.FilteredProducts(context.Background(), testFilter, "1", "1") + + assert.NotNil(suite.T(), err) + assert.Equal(suite.T(), 0, count) + assert.Equal(suite.T(), "", filteredProducts) +} + +var text = "xr" + +func (suite *FilterTestSuite) TestSearchProductsCountsSuccess() { + + products := []Product{ + Product{ + Id: 9, + Name: "Apple iPhone XR (64GB)", + Description: "6.1-inch (15.5 cm) Liquid Retina HD LCD display", + Price: 50000, + Discount: 6, + Tax: 15, + Quantity: 20, + CategoryId: 3, + CategoryName: "Mobile", + Brand: "Apple", + Color: "Grey", + Size: "", + URLs: []string{ + "url1", + "url2", + }, + }, + } + + suite.sqlmock.ExpectBegin() + suite.sqlmock.ExpectExec(` SELECT COUNT(p.id) from products p INNER JOIN category c ON p.category_id = c.id WHERE LOWER(p.name) LIKE LOWER('%xr%') OR LOWER(p.brand) LIKE LOWER('%xr%') OR LOWER(c.name) LIKE LOWER('%xr%') ;`). + WillReturnResult(sqlmock.NewResult(1, 2)) + + count, searchProducts, err := suite.dbStore.SearchProductsByText(context.Background(), text, "1", "1") + + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), 1, count) + assert.Equal(suite.T(), products, searchProducts) +} + +func (suite *FilterTestSuite) TestSearchProductsFailure() { + suite.sqlmock.ExpectBegin() + suite.sqlmock.ExpectRollback() + + count, searchProducts, err := suite.dbStore.SearchProductsByText(context.Background(), text, "1", "1") + + assert.NotNil(suite.T(), err) + assert.Equal(suite.T(), 0, count) + assert.Equal(suite.T(), "", searchProducts) +} diff --git a/db/mock.go b/db/mock.go index a8047e7..9c91066 100644 --- a/db/mock.go +++ b/db/mock.go @@ -2,6 +2,7 @@ package db import ( "context" + "mime/multipart" "github.com/stretchr/testify/mock" ) @@ -10,7 +11,43 @@ type DBMockStore struct { mock.Mock } -func (m *DBMockStore) ListUsers(ctx context.Context) (users []User, err error) { - args := m.Called(ctx) - return args.Get(0).([]User), args.Error(1) +// ListUsers - test mock +func (m *DBMockStore) ListProducts(ctx context.Context, limitStr int, pageStr int) (count int, product []Product, err error) { + args := m.Called(ctx, limitStr, pageStr) + return args.Get(0).(int), args.Get(1).([]Product), args.Error(2) +} + +func (m *DBMockStore) CreateProduct(ctx context.Context, product Product, images []*multipart.FileHeader) (createdProduct Product, err error, errCode int) { + args := m.Called(ctx, product, images) + return args.Get(0).(Product), args.Error(1), args.Get(2).(int) +} + +func (m *DBMockStore) FilteredProducts(ctx context.Context, filter Filter, limitStr string, pageStr string) (count int, product []Product, err error) { + args := m.Called(ctx, filter, limitStr, pageStr) + return args.Get(0).(int), args.Get(1).([]Product), args.Error(2) +} + +func (m *DBMockStore) SearchProductsByText(ctx context.Context, text string, limitStr string, pageStr string) (count int, product []Product, err error) { + args := m.Called(ctx, text, limitStr, pageStr) + return args.Get(0).(int), args.Get(1).([]Product), args.Error(2) +} + +func (m *DBMockStore) DeleteProductById(ctx context.Context, id int) (err error) { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *DBMockStore) UpdateProductStockById(ctx context.Context, count, id int) (updatedProduct Product, err error, errCode int) { + args := m.Called(ctx, count, id) + return args.Get(0).(Product), args.Error(1), args.Int(2) +} + +func (m *DBMockStore) GetProductByID(ctx context.Context, id int) (product Product, err error) { + args := m.Called(ctx, id) + return args.Get(0).(Product), args.Error(1) +} + +func (m *DBMockStore) UpdateProductById(ctx context.Context, product Product, id int, images []*multipart.FileHeader) (updatedProduct Product, err error, errCode int) { + args := m.Called(ctx, product, id, images) + return args.Get(0).(Product), args.Error(1), args.Int(2) } diff --git a/db/pg.go b/db/pg.go index bd23fc1..fcc8997 100644 --- a/db/pg.go +++ b/db/pg.go @@ -34,6 +34,7 @@ func Init() (s Storer, err error) { conn, err := sqlx.Connect(dbDriver, uri) if err != nil { logger.WithField("err", err.Error()).Error("Cannot initialize database") + //err = nil return } diff --git a/db/product.go b/db/product.go new file mode 100644 index 0000000..a311ede --- /dev/null +++ b/db/product.go @@ -0,0 +1,374 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "io/ioutil" + "mime/multipart" + "net/http" + "os" + "regexp" + "strings" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + + logger "github.com/sirupsen/logrus" +) + +const ( + getProductCount = `SELECT count(id) from Products ;` + getProductQuery = `SELECT count(*) OVER() AS total,* FROM products p + INNER JOIN category c ON p.cid = c.cid ORDER BY p.id LIMIT $1 OFFSET $2 ;` + getProductByIDQuery = `SELECT * FROM products p INNER JOIN category c ON p.cid = c.cid WHERE p.id=$1` + insertProductQuery = `INSERT INTO products ( name, description, + price, discount, tax, quantity, cid, brand, color, size, image_urls) VALUES ( + :name, :description, :price, :discount, :tax, :quantity, :cid, :brand, :color, :size, :image_urls) + RETURNING id, (SELECT cname from category where cid=:cid);` + deleteProductIdQuery = `DELETE FROM products WHERE id = $1 RETURNING image_urls` + updateProductStockQuery = `UPDATE products SET quantity= (quantity - $1) where id = $2 + RETURNING *,(SELECT cname from category where cid= + (SELECT cid FROM products where id = $2))` + updateProductQuery = `UPDATE products SET name= :name, description=:description, price=:price, + discount=:discount, tax=:tax, quantity=:quantity, cid=:cid, brand=:brand, + color=:color, size=:size, image_urls=:image_urls WHERE id = :id + RETURNING (SELECT cname from category where cid=:cid);` +) + +type Product struct { + Id int `db:"id" json:"id" schema:"-"` + Name string `db:"name" json:"product_title" schema:"product_title"` + Description string `db:"description" json:"description" schema:"description"` + Price float32 `db:"price" json:"product_price" schema:"product_price"` + Discount float32 `db:"discount" json:"discount" schema:"discount"` + Tax float32 `db:"tax" json:"tax" schema:"tax"` + Quantity int `db:"quantity" json:"stock" schema:"stock"` + CategoryId int `db:"cid" json:"category_id" schema:"category_id"` + CategoryName string `db:"cname" json:"category" schema:"category"` + Brand string `db:"brand" json:"brand" schema:"brand"` + Color string `db:"color" json:"color,*" schema:"color,*"` + Size string `db:"size" json:"size,*" schema:"size,*"` + URLs pq.StringArray `db:"image_urls" json:"image_urls,*" schema:"images"` +} + +// Pagination helps to return UI side with number of pages given a limitStr and pageStr number from Query Parameters +type Pagination struct { + Products []Product `json:"products"` + TotalPages int `json:"total_pages"` +} + +type Record struct { + TotalRecords int `db:"total"` + Product +} + +type Records []Record + +type GetProductsFromRecords interface { + Products() []Product +} + +func (r Records) Products() (products []Product) { + for _, record := range r { + products = append(products, record.Product) + } + return +} + +func (product *Product) Validate() (map[string]ErrorResponse, bool) { + var errorResponse map[string]ErrorResponse + var valid bool + + fieldErrors := make(map[string]string) + + if product.Name == "" { + fieldErrors["product_name"] = "Can't be blank" + } + if product.Description == "" { + fieldErrors["product_description"] = "Can't be blank " + } + // complicated compliment conditions are used to handle NaN's + // Example : product.Price > NaN will return false and so will product.Price < NaN! + if (product.Price > 0) == false { + fieldErrors["price"] = "Can't be blank or less than zero" + } + if (product.Discount >= 0 && product.Discount <= 100) == false { + fieldErrors["discount"] = "Can't be less than zero or more than 100 %" + } + if (product.Tax >= 0 && product.Tax <= 100) == false { + fieldErrors["tax"] = "Can't be less than zero or more than 100 %" + } + // If Quantity gets's < 0 by UpdateProductStockById Method, this is what saves us + if (product.Quantity >= 0 && product.Quantity <= 1000) == false { + fieldErrors["available_quantity"] = "Can't be blank or less than zero or greater than 1000" + } + if product.CategoryId == 0 { + fieldErrors["category_id"] = "Can't be blank" + } + + if len(fieldErrors) == 0 { + valid = true + return nil, valid + } + + errorResponse = map[string]ErrorResponse{ + "error": ErrorResponse{ + Code: "Invalid_data", + Message: "Please Provide valid Product data", + Fields: fieldErrors, + }, + } + + return errorResponse, false +} + +func deleteImages(files pq.StringArray) error { + + root := "./" + for _, file := range files { + file = root + file + //fmt.Println(file) + err := os.Remove(file) + if err != nil { + logger.WithField("err", err.Error()).Error("Error Couldn't remove the file!") + return err + } + } + return nil +} + +func imagesStore(images []*multipart.FileHeader, product *Product) error { + + for i := range images { + image, err := images[i].Open() + defer image.Close() + if err != nil { + logger.WithField("err", err.Error()).Error("Error while decoding image Data, probably invalid image") + return err + } + + extensionRegex := regexp.MustCompile(`[.]+.*`) + extension := extensionRegex.Find([]byte(images[i].Filename)) + // normally our extensions be like .jpg, .jpeg, .png etc + if len(extension) < 2 || len(extension) > 5 { + err = fmt.Errorf("Couldn't get extension of file!") + logger.WithField("err", err.Error()).Error("Error while getting image Extension. Re-check the image file extension!") + + return err + } + + directoryPath := "assets/productImages" + fileName := strings.ReplaceAll((*product).Name, " ", "") + tempFile, err := ioutil.TempFile(directoryPath, fileName+"-*"+string(extension)) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while Creating a Temporary File") + return err + } + defer tempFile.Close() + + imageBytes, err := ioutil.ReadAll(image) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while reading image File") + return err + } + tempFile.Write(imageBytes) + (*product).URLs = append(product.URLs, tempFile.Name()) + } + return nil +} + +// @Title GetProductByID +// @Description Get a Product Object by its Id +// @Params req.Context, product's Id +// @Returns Product Object, error if any +func (s *pgStore) GetProductByID(ctx context.Context, id int) (Product, error) { + + var product Product + err := s.db.Get(&product, getProductByIDQuery, id) + if err != nil { + logger.WithField("err", err.Error()).Error("Error selecting product from database by id: " + string(id)) + return Product{}, err + } + return product, nil +} + +// @Title ListProducts +// @Description Get limited number of Products of particular pageStr +// @Params req.Context , limitStr, pageStr +// @Returns Count of Records, error if any +func (s *pgStore) ListProducts(ctx context.Context, limit int, offset int) (int, []Product, error) { + + var records Records + + err := s.db.Select(&records, getProductQuery, limit, offset) + if err != nil { + logger.WithField("err", err.Error()).Error("Error fetching Products from database") + return 0, []Product{}, err + } else if len(records) == 0 { + err = fmt.Errorf("Either Offset was big or No Records Present in database!") + logger.WithField("err", err.Error()).Error("database Returned total record count as 0") + return 0, []Product{}, err + } + + return records[0].TotalRecords, records.Products(), nil +} + +func (s *pgStore) CreateProduct(ctx context.Context, product Product, images []*multipart.FileHeader) (Product, error, int) { + + if images != nil { + err := imagesStore(images, &product) + if err != nil { + logger.WithField("err", err.Error()).Error("Error inserting images in assets: " + product.Name) + return Product{}, err, http.StatusInternalServerError + } + } + + var row *sqlx.Rows + row, err := s.db.NamedQuery(insertProductQuery, product) + if err != nil { + logger.WithField("err", err.Error()).Error("Error inserting product to database: " + product.Name) + return Product{}, err, http.StatusConflict + } + if row.Next() { + err = row.Scan(&product.Id, &product.CategoryName) + if err != nil { + logger.WithField("err", err.Error()).Error("Error scanning product id from database: " + product.Name) + return Product{}, err, http.StatusInternalServerError + } + } + + row.Close() + return product, nil, http.StatusOK +} + +func (s *pgStore) UpdateProductStockById(ctx context.Context, count, id int) (Product, error, int) { + var product Product + + err := s.db.QueryRowx(updateProductStockQuery, + count, + id, + ).StructScan(&product) + if err != nil { + if err == sql.ErrNoRows { + logger.WithField("err", err.Error()).Error("Error updating product Stock to database. Record not Found :" + string(id)) + } else { + logger.WithField("err", err.Error()).Error("Error updating product Stock to database. Violation of Stock :" + string(id)) + } + return Product{}, err, http.StatusBadRequest + } + return product, nil, http.StatusOK +} + +func (s *pgStore) DeleteProductById(ctx context.Context, id int) error { + + var files pq.StringArray + err := s.db.QueryRowx(deleteProductIdQuery, id).Scan(&files) + if err != nil { + if err == sql.ErrNoRows { + err = fmt.Errorf("Product doesn't exist in db, goodluck deleting it") + return err + } + logger.WithField("err", err.Error()).Error("Error scanning image_urls into files variable, product might not be deleted!") + return err + } + + // do not throw error as deletion of Product data was successful. + if files != nil { + err = deleteImages(files) + if err != nil { + logger.WithField("err", err.Error()).Error("Error Couldn't remove the images file!") + } + } + + return nil +} + +// TODO Make updateProductQuery dynamic +func (s *pgStore) UpdateProductById(ctx context.Context, product Product, id int, images []*multipart.FileHeader) (Product, error, int) { + + var dbProduct Product + err := s.db.Get(&dbProduct, getProductByIDQuery, id) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while fetching product, product doesn't exist! ") + return Product{}, err, http.StatusBadRequest + } + + if product.Name == "" { + product.Name = dbProduct.Name + } + if product.Description == "" { + product.Description = dbProduct.Description + } + if product.Price == 0 { + product.Price = dbProduct.Price + } + if product.Discount == 0 { + product.Discount = dbProduct.Discount + } + if product.Tax == 0 { + product.Tax = dbProduct.Tax + } + if product.Quantity == 0 { + product.Quantity = dbProduct.Quantity + } + if product.CategoryId == 0 { + product.CategoryId = dbProduct.CategoryId + } + if product.Brand == "" { + product.Brand = dbProduct.Brand + } + if product.Color == "" { + product.Color = dbProduct.Color + } + if product.Size == "" { + product.Size = dbProduct.Size + } + + _, valid := product.Validate() + if valid == false { + return Product{}, fmt.Errorf("Product Validation failed. Invalid Fields present in the product. Check the limits. for e.g Discount shouldn't not be NaN."), http.StatusBadRequest + } + + if images != nil { + err = imagesStore(images, &product) + if err != nil { + logger.WithField("err", err.Error()).Error("Error inserting images in assets/productImages: " + product.Name) + return Product{}, err, http.StatusInternalServerError + } + } + + // Update images only after validations + if product.URLs != nil && len(product.URLs) != 0 { + files := dbProduct.URLs + err = deleteImages(files) + if err != nil { + logger.WithField("err", err.Error()).Error("Error Couldn't remove the images file!") + return Product{}, err, http.StatusInternalServerError + } + } else { + product.URLs = dbProduct.URLs + } + + product.Id = id + + var row *sqlx.Rows + + row, err = s.db.NamedQuery(updateProductQuery, product) + if err != nil { + logger.WithField("err", err.Error()).Error("Error updating product attribute(s) to database :" + string(id)) + return Product{}, err, http.StatusConflict + } + + if row.Next() { + err = row.Scan(&product.CategoryName) + if err != nil { + logger.WithField("err", err.Error()).Error("Error scanning product Category Name from database: " + product.Name) + return Product{}, err, http.StatusInternalServerError + } + } + + row.Close() + + return product, nil, http.StatusOK +} diff --git a/db/product_test.go b/db/product_test.go new file mode 100644 index 0000000..d535799 --- /dev/null +++ b/db/product_test.go @@ -0,0 +1,286 @@ +package db + +import ( + "context" + "fmt" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type ProductsTestSuite struct { + suite.Suite + dbStore Storer + db *sqlx.DB + sqlmock sqlmock.Sqlmock +} + +func (suite *ProductsTestSuite) SetupTest() { + dbStore, dbConn, sqlmock := InitMockDB() + suite.dbStore = dbStore + suite.db = dbConn + suite.sqlmock = sqlmock +} + +func (suite *ProductsTestSuite) TearDownTest() { + suite.db.Close() +} + +var urls = []string{"url1", "url2"} + +var testProduct = Product{ + Id: 1, + Name: "test organization", + Description: "test@gmail.com", + Price: 12, + Discount: 1, + Tax: 0.5, + Quantity: 15, + CategoryId: 5, + CategoryName: "2", + Brand: "IST", + Color: "black", + Size: "Medium", + URLs: urls, +} + +func (suite *ProductsTestSuite) TestValidateSuccess() { + product := Product{ + Name: "test product", + Description: "test description", + Price: 123.0, + Discount: 10, + Tax: 0.5, + Quantity: 5, + CategoryId: 1, + CategoryName: "testing", + Brand: "testing", + Color: "test", + Size: "test", + URLs: []string{"url1", "url2"}, + } + + _, valid := product.Validate() + + assert.True(suite.T(), valid) +} + +func (suite *ProductsTestSuite) TestValidateFailure() { + product := Product{ + Name: "", + Description: "test description", + Price: 123.0, + Discount: 999, + Tax: 0.5, + Quantity: 5, + CategoryId: 1, + CategoryName: "testing", + Brand: "testing", + Color: "test", + Size: "test", + URLs: []string{"url1", "url2"}, + } + + errRes, valid := product.Validate() + + assert.Equal(suite.T(), map[string]ErrorResponse(map[string]ErrorResponse{ + "error": ErrorResponse{ + Code: "Invalid_data", + Message: "Please Provide valid Product data", + Fields: map[string]string{ + "discount": "Can't be less than zero or more than Product's Price", + "product_name": "Can't be blank", + }, + }, + }), errRes) + assert.False(suite.T(), valid) +} + +func (suite *ProductsTestSuite) TestCreateProductSuccess() { + product := Product{ + Name: "test user", + Description: "test database", + Price: 123.0, + Discount: 10.0, + Tax: 0.5, + Quantity: 5, + CategoryId: 1, + CategoryName: "testing", + Brand: "testing", + Color: "testing", + Size: "testing", + //URLs: []string{"url1", "url2"}, + } + + /* suite.sqlmock.ExpectBegin() + + suite.sqlmock.ExpectExec("INSERT INTO products"). + WithArgs("test user", "test database", 123.0, 10.0, 0.5, 5, 1, "testing", "testing", "testing", "testing"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + suite.sqlmock.ExpectCommit() */ + + createdProduct, err := suite.dbStore.CreateProduct(context.Background(), product) + fmt.Println(createdProduct, err) + assert.Nil(suite.T(), suite.sqlmock.ExpectationsWereMet()) + assert.Equal(suite.T(), createdProduct, product) + assert.Nil(suite.T(), err) +} + +func (suite *ProductsTestSuite) TestCreateProductFailure() { + product := Product{ + Name: "test user", + Description: "test database", + Price: 123, + Discount: 10, + Tax: 0.5, + Quantity: 5.0, + CategoryId: 1, + CategoryName: "testing", + Brand: "testing", + Color: "test", + Size: "heigh", + URLs: []string{"url1", "url2"}, + } + + suite.db.Close() + suite.sqlmock.ExpectBegin() + suite.sqlmock.ExpectExec("INSERT INTO product"). + WithArgs("test user", "test database", 123, 10, 0.5, 5.0, 1, "testing", "testing", "test", "heigh"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + suite.sqlmock.ExpectRollback() + + _, err := suite.dbStore.CreateProduct(context.Background(), product) + + assert.NotNil(suite.T(), err) +} + +func (suite *ProductsTestSuite) TestUpdateProductStockByIdSuccess() { + product := Product{ + Name: "test user", + Description: "test database", + Price: 123, + Discount: 10, + Tax: 0.5, + Quantity: 5.0, + CategoryId: 1, + CategoryName: "testing", + Brand: "testing", + Color: "test", + Size: "heigh", + URLs: pq.StringArray{"url1", "url2"}, + } + + suite.sqlmock.ExpectBegin() + + UpdatedProduct, err := suite.dbStore.UpdateProductStockById(context.Background(), product, 1) + + assert.Nil(suite.T(), suite.sqlmock.ExpectationsWereMet()) + assert.Equal(suite.T(), UpdatedProduct, product) + assert.Nil(suite.T(), err) +} + +func (suite *ProductsTestSuite) TestUpdateProductStockByIdFailure() { + product := Product{ + Name: "test user", + Description: "test database", + Price: 123, + Discount: 10, + Tax: 0.5, + Quantity: 5.0, + CategoryId: 1, + CategoryName: "testing", + Brand: "testing", + Color: "test", + Size: "heigh", + URLs: []string{"url1", "url2"}, + } + + suite.sqlmock.ExpectBegin() + updatedProduct, err := suite.dbStore.UpdateProductStockById(context.Background(), product, 1) + assert.NotEqual(suite.T(), updatedProduct, product) + assert.NotNil(suite.T(), err) +} + +func (suite *ProductsTestSuite) TestDeleteProductByIdSuccess() { + suite.sqlmock.ExpectExec("DELETE"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := suite.dbStore.DeleteProductById(context.Background(), testProduct.Id) + + assert.Nil(suite.T(), err) +} + +/* func (suite *ProductsTestSuite) TestUpdateProductByIdSuccess() { + product := Product{ + Name: "", + Description: "test database", + Price: 123, + Discount: 10, + Tax: 0.5, + Quantity: 5, + CategoryId: 1, + Brand: "testing", + Color: "test", + Size: "heigh", + } + + //suite.sqlmock.ExpectBegin() + + UpdatedProduct, err := suite.dbStore.UpdateProductById(context.Background(), product, 1) + //suite.sqlmock.ExpectCommit() + fmt.Println("Update Product--->", UpdatedProduct) + assert.Nil(suite.T(), suite.sqlmock.ExpectationsWereMet()) + assert.Equal(suite.T(), UpdatedProduct, product) + assert.Nil(suite.T(), err) +} */ + +/* func (suite *ProductsTestSuite) TestUpdateProductByIdSuccess() { + suite.sqlmock.ExpectExec("UPDATE products"). + WithArgs("test organization", "test@gmail.com", 100.0, 1.0, 2.0, 5, 1, "test", "test", "test"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + suite.sqlmock.ExpectQuery("SELECT"). + WillReturnRows(mockedRows) + + org, err := suite.dbStore.UpdateProductById(context.Background(), testProduct, testProduct.Id) + + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), testProduct, org) +} */ + +func (suite *ProductsTestSuite) TestUpdateProductByIdFailure() { + product := Product{ + Name: "test user", + Description: "test database", + Price: 123, + Discount: 10, + Tax: 0.5, + Quantity: 5.0, + CategoryId: 1, + CategoryName: "testing", + Brand: "testing", + Color: "test", + Size: "heigh", + URLs: []string{"url1", "url2"}, + } + + suite.sqlmock.ExpectBegin() + updatedProduct, err := suite.dbStore.UpdateProductById(context.Background(), product, 1) + assert.NotEqual(suite.T(), updatedProduct, product) + assert.NotNil(suite.T(), err) +} + +/* func (suite *ProductsTestSuite) TestListProductsSuccess() { + suite.sqlmock.ExpectQuery(getProductQuery). + WillReturnRows(mockedRows) + + _, org, err := suite.dbStore.ListProducts(context.Background(), 1, 1) + + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), []Product{testProduct}, org) +} */ diff --git a/db/user.go b/db/user.go deleted file mode 100644 index 17715e9..0000000 --- a/db/user.go +++ /dev/null @@ -1,22 +0,0 @@ -package db - -import ( - "context" - - logger "github.com/sirupsen/logrus" -) - -type User struct { - Name string `db:"name" json:"full_name"` - Age int `db:"age" json:"age"` -} - -func (s *pgStore) ListUsers(ctx context.Context) (users []User, err error) { - err = s.db.Select(&users, "SELECT * FROM users ORDER BY name ASC") - if err != nil { - logger.WithField("err", err.Error()).Error("Error listing users") - return - } - - return -} diff --git a/docs/Product_api.docx b/docs/Product_api.docx new file mode 100644 index 0000000..6bb9cf1 Binary files /dev/null and b/docs/Product_api.docx differ diff --git a/go.mod b/go.mod index bd1ae4f..a085d5c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module joshsoftware/go-e-commerce go 1.14 require ( + github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/gorilla/mux v1.8.0 + github.com/gorilla/schema v1.2.0 github.com/jmoiron/sqlx v1.2.0 github.com/lib/pq v1.8.0 github.com/mattes/migrate v3.0.1+incompatible diff --git a/go.sum b/go.sum index 0b155a0..26ddacd 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -23,6 +25,9 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/bxcodec/faker v2.0.1+incompatible h1:P0KUpUw5w6WJXwrPfv35oc91i4d8nf40Nwln+M/+faA= +github.com/bxcodec/faker/v3 v3.5.0 h1:Rahy6dwbd6up0wbwbV7dFyQb+jmdC51kpATuUdnzfMg= +github.com/bxcodec/faker/v3 v3.5.0/go.mod h1:gF31YgnMSMKgkvl+fyEo1xuSMbEuieyqfeslGYFjneM= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= @@ -70,6 +75,8 @@ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5m github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= +github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= diff --git a/main.go b/main.go index e0ad81d..638e510 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,7 @@ func main() { } if err := cliApp.Run(os.Args); err != nil { + //logger.WithField("err", err.Error()).Error("Error Couldn't find proper env!") panic(err) } } diff --git a/migration.sql b/migration.sql new file mode 100644 index 0000000..512c1a0 --- /dev/null +++ b/migration.sql @@ -0,0 +1,24 @@ +INSERT INTO category (cname, description) VALUES ('Clothes','All wearable fabrics, '); +INSERT INTO category (cname, description) VALUES('Electronics',' stores or generates electricity'); +INSERT INTO category (cname, description) VALUES('Mobile','The mobile phone can be used to communicate '); +INSERT INTO category (cname, description) VALUES('Watch','A watch is a portable timepiece intended '); +INSERT INTO category (cname, description) VALUES('Books','There are several things to consider in order'); +INSERT INTO category (cname, description) VALUES('Sports','Shoes are for regular comfort wear'); + + +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, size, image_urls) VALUES('Polo Shirt', 'Benetton Men Classic Fit Polo Shirt',511, 10, 5, 10, 1 ,'Polo','Sky Blue', 'Medium', ARRAY ['assets/productImages/poloshirt1.jpeg','assets/productImages/poloshirt2.jpeg', 'assets/productImages/poloshirt3.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, size, image_urls) VALUES('Wrangler', 'Men Slim Fit Jeans', 600, 20, 5, 12, 1 , 'Armani','Charcoal Black','Large', ARRAY ['assets/productImages/Wrangler1.jpeg','assets/productImages/Wrangler2.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, size, image_urls) VALUES('Dragon Jacket','Made from the skin of one of the dragons', 700, 40, 5, 9, 1 ,'Veteran','Black','Extra Large', ARRAY ['assets/productImages/DragonJacket1.jpeg','assets/productImages/DragonJacket2.jpeg', 'assets/productImages/DragonJacket3.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, image_urls) VALUES('HD Ready Android LED TV ', 'Resolution: HD Ready Android TV (1366x768)', 1200, 20, 10, 12, 2 ,'Samsung','Black', ARRAY ['assets/productImages/AndroidLEDTV1.jpeg', 'assets/productImages/AndroidLEDTV2.jpeg','assets/productImages/AndroidLEDTV3.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, image_urls) VALUES('Sony DSC W830 Cyber-Shot 20.1 MP ', 'Shoot Camera (Black) with 8X ', 1500, 50, 15, 8, 2 , 'Samsung','Blue', ARRAY ['assets/productImages/SonyDSC1.jpeg','assets/productImages/SonyDSC2.jpeg', 'assets/productImages/SonyDSC3.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, image_urls) VALUES('Charger', 'Mi 10W Charger with Cable (1.2 Meter, Black)', 500, 5, 4, 21, 2 ,'One Plus','White', ARRAY ['assets/productImages/Charger1.jpeg','assets/productImages/Charger2.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, image_urls) VALUES('Apple iPhone 11 Pro (64GB)', '5.8-inch (14.7 cm) ', 60000, 5, 10, 15, 3 ,'Apple', 'Golden', ARRAY ['assets/productImages/AndroidLEDTV1.jpeg', 'assets/productImages/AndroidLEDTV2.jpeg','assets/productImages/AndroidLEDTV3.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, image_urls) VALUES('Apple iPhone 11 Pro (64GB) Max', '5.8-inch (14.7 cm) ', 700000, 4, 30, 15, 3, 'Apple','Black' , ARRAY ['assets/productImages/AndroidLEDTV1.jpeg', 'assets/productImages/AndroidLEDTV2.jpeg','assets/productImages/AndroidLEDTV3.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, image_urls) VALUES('Apple iPhone XR (64GB)', '6.1-inch (15.5 cm) Liquid Retina HD LCD display', 50000, 6, 20, 15, 3, 'Apple','Grey' , ARRAY ['assets/productImages/AndroidLEDTV1.jpeg', 'assets/productImages/AndroidLEDTV2.jpeg','assets/productImages/AndroidLEDTV3.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, image_urls) VALUES ('Rolex Watch','by wearing it you are bound to feel realaxed',2100, 10, 10, 23, 4, 'Rolex', 'Blue', ARRAY ['assets/productImages/RelaxWatch1.jpeg','assets/productImages/RelaxWatch2.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, image_urls) VALUES ('Titan Watch','With the look of and feel of old days',2000, 15, 8, 5, 4 ,'Titan','Ocean Blue', ARRAY ['assets/productImages/TitanWatch1.jpeg','assets/productImages/TitanWatch2.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, image_urls) VALUES ('Sonata Watch','Stylished belts and longer battery',3010, 20, 12, 5, 4 ,'Sonata','Golden', ARRAY ['assets/productImages/SonataWatch1.jpeg','assets/productImages/SonataWatch2.jpeg', 'assets/productImages/SonataWatch3.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, image_urls) VALUES ('Wings of Fire','autobiography by visionary scientist',332, 10, 5, 2, 5, 'TechMax Publications', ARRAY ['assets/productImages/WingsofFire1.jpeg','assets/productImages/WingsofFire2.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, image_urls) VALUES ('Thoughts to Inspire','famous quotes by Swami Vivekananda', 150, 15, 10, 2, 5, 'Technical Publications' , ARRAY ['assets/productImages/vivkananadbook1.jpeg','assets/productImages/vivkananadbook2.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand, color, size, image_urls) VALUES ('Lancer','Mens Running Shoes', 150, 15, 10, 9, 6 , 'Nike', 'Red', 'Small', ARRAY ['assets/productImages/MensRunningShoes1.jpeg','assets/productImages/MensRunningShoes2.jpeg']); +INSERT INTO products (name, description, price, discount, quantity, tax, cid, brand) VALUES ('Football','Sporting Goods', 200, 40, 15, 12, 6, 'Cosco' ); \ No newline at end of file diff --git a/migrations/1587381324_create_users.down.sql b/migrations/1587381324_create_users.down.sql deleted file mode 100644 index cc1f647..0000000 --- a/migrations/1587381324_create_users.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE users; diff --git a/migrations/1587381324_create_users.up.sql b/migrations/1587381324_create_users.up.sql deleted file mode 100644 index f893282..0000000 --- a/migrations/1587381324_create_users.up.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE users ( - name text, - age integer -); diff --git a/migrations/1599714973_category.down.sql b/migrations/1599714973_category.down.sql new file mode 100644 index 0000000..9cc8699 --- /dev/null +++ b/migrations/1599714973_category.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS category; diff --git a/migrations/1599714973_category.up.sql b/migrations/1599714973_category.up.sql new file mode 100644 index 0000000..197aac9 --- /dev/null +++ b/migrations/1599714973_category.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS category ( + cid SERIAL PRIMARY KEY, + cname varchar(50) UNIQUE NOT NULL, + description varchar(200) NOT NULL +); \ No newline at end of file diff --git a/migrations/1599715195_products.down.sql b/migrations/1599715195_products.down.sql new file mode 100644 index 0000000..39a3c0e --- /dev/null +++ b/migrations/1599715195_products.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS products; diff --git a/migrations/1599715195_products.up.sql b/migrations/1599715195_products.up.sql new file mode 100644 index 0000000..163369b --- /dev/null +++ b/migrations/1599715195_products.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + name varchar(50) UNIQUE NOT NULL , + description varchar(200) NOT NULL, + price float NOT NULL, CHECK (price > 0), + discount float NOT NULL DEFAULT 0, CHECK (discount >= 0 AND discount <= 100), + quantity int NOT NULL CHECK (quantity >= 0 AND quantity <= 1000), + tax float NOT NULL DEFAULT 0, CHECK (tax >= 0 AND tax <= 100), + cid int NOT NULL , + brand varchar(50) NOT NULL , + color varchar(50) NOT NULL DEFAULT '', + size varchar(50) NOT NULL DEFAULT '', + image_urls text[] DEFAULT NULL, + FOREIGN KEY(cid) + REFERENCES Category(cid) ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/service/common_http_test.go b/service/common_http_test.go new file mode 100644 index 0000000..03f07df --- /dev/null +++ b/service/common_http_test.go @@ -0,0 +1,59 @@ +package service + +import ( + "bytes" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + + "github.com/gorilla/mux" +) + +//jwtmiddleware "github.com/auth0/go-jwt-middleware" +//jwt "github.com/dgrijalva/jwt-go" + +// path: is used to configure router path(eg: /product/{id}) +// requestURL: current request path (eg: /product/1) +func makeHTTPCall(method, path, requestURL, body string, handlerFunc http.HandlerFunc) (recorder *httptest.ResponseRecorder) { + // create a http request using the given parameters + req, _ := http.NewRequest(method, requestURL, strings.NewReader(body)) + + // test recorder created for capturing apiresponses + recorder = httptest.NewRecorder() + + // create a router to serve the handler in test with the prepared request + router := mux.NewRouter() + router.HandleFunc(path, handlerFunc).Methods(method) + + // serve the request and write the response to recorder + router.ServeHTTP(recorder, req) + return +} + +func makeHTTPCallWithHeader(method, path, requestURL string, writer *multipart.Writer, payload *bytes.Buffer, handlerFunc http.HandlerFunc) (recorder *httptest.ResponseRecorder) { + // create a http request using the given parameters + + // Don't forget to set the content type, this will contain the boundary. + + err := writer.Close() + if err != nil { + fmt.Println(err) + } + + req, _ := http.NewRequest(method, requestURL, strings.NewReader(payload.String())) + req.Header.Set("Content-Type", writer.FormDataContentType()) + //fmt.Println(req) + + // test recorder created for capturing apiresponses + recorder = httptest.NewRecorder() + + // create a router to serve the handler in test with the prepared request + router := mux.NewRouter() + router.HandleFunc(path, handlerFunc).Methods(method) + + // serve the request and write the response to recorder + router.ServeHTTP(recorder, req) + return +} diff --git a/service/coverage.out b/service/coverage.out new file mode 100644 index 0000000..01796af --- /dev/null +++ b/service/coverage.out @@ -0,0 +1,96 @@ +mode: set +joshsoftware/go-e-commerce/service/router.go:17.57,35.2 10 0 +joshsoftware/go-e-commerce/service/filters_http.go:26.69,27.74 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:27.74,44.18 9 0 +joshsoftware/go-e-commerce/service/filters_http.go:49.3,49.17 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:53.3,54.17 2 0 +joshsoftware/go-e-commerce/service/filters_http.go:65.3,66.17 2 0 +joshsoftware/go-e-commerce/service/filters_http.go:78.3,78.25 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:91.3,91.30 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:95.3,95.25 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:99.3,99.25 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:103.3,103.24 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:107.3,107.25 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:111.3,112.17 2 0 +joshsoftware/go-e-commerce/service/filters_http.go:123.3,123.41 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:133.3,137.17 4 0 +joshsoftware/go-e-commerce/service/filters_http.go:147.3,150.17 3 0 +joshsoftware/go-e-commerce/service/filters_http.go:161.3,163.22 3 0 +joshsoftware/go-e-commerce/service/filters_http.go:44.18,46.4 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:49.17,51.4 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:54.17,63.4 4 0 +joshsoftware/go-e-commerce/service/filters_http.go:66.17,75.4 4 0 +joshsoftware/go-e-commerce/service/filters_http.go:78.25,88.4 5 0 +joshsoftware/go-e-commerce/service/filters_http.go:91.30,93.4 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:95.25,97.4 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:99.25,101.4 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:103.24,105.4 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:107.25,109.4 1 0 +joshsoftware/go-e-commerce/service/filters_http.go:112.17,121.4 4 0 +joshsoftware/go-e-commerce/service/filters_http.go:123.41,131.4 3 0 +joshsoftware/go-e-commerce/service/filters_http.go:137.17,146.4 4 0 +joshsoftware/go-e-commerce/service/filters_http.go:150.17,159.4 4 0 +joshsoftware/go-e-commerce/service/ping_http.go:14.61,18.16 3 0 +joshsoftware/go-e-commerce/service/ping_http.go:23.2,24.21 2 0 +joshsoftware/go-e-commerce/service/ping_http.go:18.16,21.3 2 0 +joshsoftware/go-e-commerce/service/product_http.go:21.62,22.74 1 1 +joshsoftware/go-e-commerce/service/product_http.go:22.74,27.18 3 1 +joshsoftware/go-e-commerce/service/product_http.go:31.3,31.17 1 1 +joshsoftware/go-e-commerce/service/product_http.go:35.3,36.17 2 1 +joshsoftware/go-e-commerce/service/product_http.go:47.3,48.17 2 1 +joshsoftware/go-e-commerce/service/product_http.go:60.3,60.25 1 1 +joshsoftware/go-e-commerce/service/product_http.go:72.3,73.17 2 1 +joshsoftware/go-e-commerce/service/product_http.go:84.3,84.41 1 1 +joshsoftware/go-e-commerce/service/product_http.go:94.3,98.17 4 1 +joshsoftware/go-e-commerce/service/product_http.go:109.3,112.17 3 1 +joshsoftware/go-e-commerce/service/product_http.go:123.3,125.22 3 1 +joshsoftware/go-e-commerce/service/product_http.go:27.18,29.4 1 0 +joshsoftware/go-e-commerce/service/product_http.go:31.17,33.4 1 0 +joshsoftware/go-e-commerce/service/product_http.go:36.17,45.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:48.17,57.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:60.25,70.4 5 0 +joshsoftware/go-e-commerce/service/product_http.go:73.17,82.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:84.41,92.4 3 0 +joshsoftware/go-e-commerce/service/product_http.go:98.17,107.4 4 1 +joshsoftware/go-e-commerce/service/product_http.go:112.17,121.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:136.64,137.74 1 1 +joshsoftware/go-e-commerce/service/product_http.go:137.74,141.17 3 1 +joshsoftware/go-e-commerce/service/product_http.go:152.3,153.17 2 1 +joshsoftware/go-e-commerce/service/product_http.go:164.3,165.17 2 1 +joshsoftware/go-e-commerce/service/product_http.go:175.3,176.9 2 1 +joshsoftware/go-e-commerce/service/product_http.go:141.17,150.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:153.17,162.4 4 1 +joshsoftware/go-e-commerce/service/product_http.go:165.17,174.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:186.63,187.74 1 1 +joshsoftware/go-e-commerce/service/product_http.go:187.74,191.17 3 1 +joshsoftware/go-e-commerce/service/product_http.go:202.3,203.13 2 1 +joshsoftware/go-e-commerce/service/product_http.go:218.3,220.17 3 1 +joshsoftware/go-e-commerce/service/product_http.go:230.3,231.9 2 1 +joshsoftware/go-e-commerce/service/product_http.go:191.17,200.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:203.13,205.18 2 0 +joshsoftware/go-e-commerce/service/product_http.go:214.4,215.10 2 0 +joshsoftware/go-e-commerce/service/product_http.go:205.18,213.5 3 0 +joshsoftware/go-e-commerce/service/product_http.go:220.17,229.4 4 1 +joshsoftware/go-e-commerce/service/product_http.go:242.67,243.74 1 0 +joshsoftware/go-e-commerce/service/product_http.go:243.74,247.17 3 0 +joshsoftware/go-e-commerce/service/product_http.go:258.3,259.17 2 0 +joshsoftware/go-e-commerce/service/product_http.go:270.3,272.9 3 0 +joshsoftware/go-e-commerce/service/product_http.go:247.17,256.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:259.17,268.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:282.72,283.74 1 0 +joshsoftware/go-e-commerce/service/product_http.go:283.74,292.29 5 0 +joshsoftware/go-e-commerce/service/product_http.go:303.3,305.32 2 0 +joshsoftware/go-e-commerce/service/product_http.go:316.3,318.17 3 0 +joshsoftware/go-e-commerce/service/product_http.go:330.3,334.13 3 0 +joshsoftware/go-e-commerce/service/product_http.go:349.3,351.17 3 0 +joshsoftware/go-e-commerce/service/product_http.go:362.3,363.9 2 0 +joshsoftware/go-e-commerce/service/product_http.go:292.29,301.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:305.32,314.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:318.17,327.4 4 0 +joshsoftware/go-e-commerce/service/product_http.go:334.13,336.18 2 0 +joshsoftware/go-e-commerce/service/product_http.go:345.4,346.10 2 0 +joshsoftware/go-e-commerce/service/product_http.go:336.18,344.5 3 0 +joshsoftware/go-e-commerce/service/product_http.go:351.17,360.4 4 0 +joshsoftware/go-e-commerce/service/response.go:21.77,23.16 2 1 +joshsoftware/go-e-commerce/service/response.go:29.2,31.21 3 1 +joshsoftware/go-e-commerce/service/response.go:23.16,27.3 3 0 diff --git a/service/filters_http.go b/service/filters_http.go new file mode 100644 index 0000000..aec3f0c --- /dev/null +++ b/service/filters_http.go @@ -0,0 +1,204 @@ +package service + +import ( + "fmt" + "joshsoftware/go-e-commerce/db" + "math" + "net/http" + "strconv" + + logger "github.com/sirupsen/logrus" +) + +// @Title getProductByFilters +// @Description list all Products with specified filters +// @Router /products/filters [GET] +// @Params /products/filters?categoryid=id&price=asc&brand=name&size=name&color=name +// price can be asc or desc, it will stored as a string +// categoryid will be an integer value, but for convinience it will be stored as string +// brand, size, color will be case-sensitive string +// @Accept json +// @Success 200 {object} +// @Failure 404 {object} +// @Features This API can replace ListProducts API, but time Complexity will be a bit high + +func getProductByFiltersHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + var filter db.Filter + + // filter.Price will either be "asc" or "desc" + // All these object's are used as string itself + // Using String really made some things easy in dynamic query writing + filter.CategoryId = req.URL.Query().Get("category_id") + filter.Price = req.URL.Query().Get("price") + filter.Brand = req.URL.Query().Get("brand") + filter.Size = req.URL.Query().Get("size") + filter.Color = req.URL.Query().Get("color") + + pageStr := req.URL.Query().Get("page") + limitStr := req.URL.Query().Get("limit") + + // Setting default limit as 5 + if limitStr == "" { + limitStr = "5" + } + + // Setting default page as 1 + if pageStr == "" { + pageStr = "1" + } + + limit, err := strconv.Atoi(limitStr) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while converting limitStr to int") + Message := "Limits value invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + page, err := strconv.Atoi(pageStr) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while converting pageStr to int") + Message := "Page value invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + // Avoid divide by zero exception and -ve values for page and limit + if (limit > 0 && page > 0) == false { + err = fmt.Errorf("limit or page are non-positive") + logger.WithField("err", err.Error()).Error("Error limit or page were invalid values") + Message := "limits or page value are non-positive" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + // Checking for flags, true means we need to filter by that field + if filter.CategoryId != "" { + filter.CategoryFlag = true + } + + if filter.Price != "" { + filter.PriceFlag = true + } + + if filter.Brand != "" { + filter.BrandFlag = true + } + + if filter.Size != "" { + filter.SizeFlag = true + } + + if filter.Color != "" { + filter.ColorFlag = true + } + + offset := (page - 1) * limit + offsetStr := strconv.Itoa(offset) + totalRecords, products, err := deps.Store.FilteredProducts(req.Context(), filter, limitStr, offsetStr) + if err != nil { + logger.WithField("err", err.Error()).Error("Error getting filtered records or Page not Found") + Message := "Error getting filtered records or Page not Found" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + var pagination db.Pagination + pagination.TotalPages = int(math.Ceil(float64(totalRecords) / float64(limit))) + pagination.Products = products + response(rw, http.StatusOK, pagination) + return + }) + +} + +// @Title getProductBySearch +// @Description list all Products with specified filters +// @Router /products/search [GET] +// @Params /products/search?text=apple+that+can+be+eaten +// checking will take place in product name , brand and category name +// brand, size, color will be also be checked case-insensitively string +// @Accept json +// @Success 200 {object} +// @Failure 404 {object} + +// TODO Optimize the queries + +func getProductBySearchHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + pageStr := req.URL.Query().Get("page") + limitStr := req.URL.Query().Get("limit") + text := req.URL.Query().Get("text") + + // Setting default limit as 5 + if limitStr == "" { + limitStr = "5" + } + + // Setting default page as 1 + if pageStr == "" { + pageStr = "1" + } + + limit, err := strconv.Atoi(limitStr) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while converting limit to int") + Message := "limits value invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + page, err := strconv.Atoi(pageStr) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while converting page to int") + Message := "page value invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + // Avoid divide by zero exception and -ve values for page and limit + if limit <= 0 || page <= 0 { + err = fmt.Errorf("limit or page are non-positive") + logger.WithField("err", err.Error()).Error("Error limit or page were invalid values") + Message := "limits or page value are non-positive." + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + var totalRecords int + var products []db.Product + offset := (page - 1) * limit + offsetStr := strconv.Itoa(offset) + if text == "" { + // Behave same as List All Products and return + totalRecords, products, err = deps.Store.ListProducts(req.Context(), limit, offset) + + if err != nil { + logger.WithField("err", err.Error()).Error("Error Couldn't find any Product records or Page out of range") + Message := "Couldn't find any Products records or Page out of range" + responseMsg(rw, http.StatusInternalServerError, Message) + return + } + goto Skip + } + + totalRecords, products, err = deps.Store.SearchProductsByText(req.Context(), text, limitStr, offsetStr) + if err != nil || totalRecords == 0 { + logger.WithField("err", err.Error()).Error("Error Couldn't find any matching search records or Page out of range") + Message := "Couldn't find any matching search records or Page out of range" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + Skip: + var pagination db.Pagination + pagination.TotalPages = int(math.Ceil(float64(totalRecords) / float64(limit))) + pagination.Products = products + response(rw, http.StatusOK, pagination) + return + }) + +} diff --git a/service/filters_http_test.go b/service/filters_http_test.go new file mode 100644 index 0000000..009657e --- /dev/null +++ b/service/filters_http_test.go @@ -0,0 +1,126 @@ +package service + +import ( + "errors" + "joshsoftware/go-e-commerce/db" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify, including assertion methods. + +type FilterHandlerTestSuite struct { + suite.Suite + + dbMock *db.DBMockStore +} + +func (suite *FilterHandlerTestSuite) SetupTest() { + suite.dbMock = &db.DBMockStore{} +} + +func TestFilterHandlerTestSuite(t *testing.T) { + suite.Run(t, new(FilterHandlerTestSuite)) +} + +// function covers FilteredRecordsCount as well as Filteredrecords +func (suite *FilterHandlerTestSuite) TestGetProductByFiltersSuccess() { + + suite.dbMock.On("FilteredProducts", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, []db.Product{}, nil) + + recorder := makeHTTPCall( + http.MethodGet, + "/products/filters", + "/products/filters?limit=5&page=1&brand=Apple&categoryid=;&brand=Apple&color=Black", + "", + getProductByFiltersHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *FilterHandlerTestSuite) TestFilteredRecordsWhenDBFailure() { + + // Count not expected on filter with brand as Apple and price in desc as failure test + /* suite.dbMock.On("FilteredRecordsCount", mock.Anything, mock.Anything).Return(0, + errors.New("Error getting count of filtered records")) */ + // When calling FilteredRecords with any args, always return + // that fakeProducts Array along with nil as error + suite.dbMock.On("FilteredProducts", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(0, []db.Product{}, + errors.New("Error getting filtered records or Page not Found")) + + recorder := makeHTTPCall( + http.MethodGet, + "/products/filters", + "/products/filters?limit=5&page=1", + "", + getProductByFiltersHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), `{"error":{"message":"Error getting filtered records or Page not Found"}}`, recorder.Body.String()) + assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code) + suite.dbMock.AssertExpectations(suite.T()) + +} + +func (suite *FilterHandlerTestSuite) TestGetProductBySearchSuccess() { + + var urls = []string{"url1", "url2"} + //var color = "Black" + //var size = "Medium" + + suite.dbMock.On("SearchProductsByText", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, + []db.Product{ + db.Product{ + Id: 1, + Name: "test organization", + Description: "test@gmail.com", + Price: 12, + Discount: 1, + Tax: 0.5, + Quantity: 15, + CategoryId: 5, + CategoryName: "2", + Brand: "IST", + Color: "&color", + Size: "&size", + URLs: urls, + }, + }, + nil) + + recorder := makeHTTPCall( + http.MethodGet, + "/products/search", + "/products/search?limit=5&page=1&text=test", + "", + getProductBySearchHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *FilterHandlerTestSuite) TestGetProductBySearchWhenDBFailure() { + + suite.dbMock.On("SearchProductsByText", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(0, []db.Product{}, + errors.New("Couldn't find any matching search records or Page out of range")) + + recorder := makeHTTPCall( + http.MethodGet, + "/products/search", + "/products/search?limit=5&page=1&text=Apple", + "", + getProductBySearchHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), `{"error":{"message":"Couldn't find any matching search records or Page out of range"}}`, recorder.Body.String()) + assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code) + suite.dbMock.AssertExpectations(suite.T()) +} diff --git a/service/product_http.go b/service/product_http.go new file mode 100644 index 0000000..e8fc52c --- /dev/null +++ b/service/product_http.go @@ -0,0 +1,332 @@ +package service + +import ( + "fmt" + "html" + "joshsoftware/go-e-commerce/db" + "math" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/gorilla/schema" + logger "github.com/sirupsen/logrus" +) + +// @Title listProducts +// @Description list all Products +// @Router /products [GET] +// @Accept json +// @Success 200 {object} +// @Failure 400 {object} +func listProductsHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + limitStr := req.URL.Query().Get("limit") + pageStr := req.URL.Query().Get("page") + + if limitStr == "" { + limitStr = "5" + } + + if pageStr == "" { + pageStr = "1" + } + + limit, err := strconv.Atoi(limitStr) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while converting limitStr to int") + Message := "Limit value invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + page, err := strconv.Atoi(pageStr) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while converting pageStr to int") + Message := "Page value invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + // Avoid divide by zero exception and -ve values for page and limit + if limit <= 0 || page <= 0 { + err = fmt.Errorf("limit or page are non-positive") + logger.WithField("err", err.Error()).Error("Error limit or page contained invalid value") + Message := "limits or page value invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + offset := (page - 1) * limit + totalRecords, products, err := deps.Store.ListProducts(req.Context(), limit, offset) + + if err != nil { + logger.WithField("err", err.Error()).Error("Error Couldn't find any Product records or Page out of range") + Message := "Couldn't find any Products records or Page out of range" + responseMsg(rw, http.StatusInternalServerError, Message) + return + } + + var pagination db.Pagination + pagination.TotalPages = int(math.Ceil(float64(totalRecords) / float64(limit))) + pagination.Products = products + response(rw, http.StatusOK, pagination) + return + }) +} + +var decoder = schema.NewDecoder() + +// @ Title getProductById +// @ Description get single product by its id +// @ Router /product/product_id [get] +// @ Accept json +// @ Success 200 {object} +// @ Failure 400 {object} +func getProductByIdHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + vars := mux.Vars(req) + id, err := strconv.Atoi(vars["product_id"]) + if err != nil { + logger.WithField("err", err.Error()).Error("Error id key is missing") + Message := "Error product_id is invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + product, err := deps.Store.GetProductByID(req.Context(), id) + if err != nil { + logger.WithField("err", err.Error()).Error("Error fetching Product data, no Product found") + Message := "Error feching data, Error fetching Product data, no Product found." + responseMsg(rw, http.StatusBadRequest, Message) + return + } + response(rw, http.StatusOK, product) + return + }) +} + +// @Title createProduct +// @Description create a Product, insert into DB +// @Router /createProduct [POST] +// @Accept json +// @Success 200 {object} +// @Failure 400 {object} +func createProductHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + var product db.Product + + // Parse input, multipart/form-data + err := req.ParseMultipartForm(15 << 20) // 15 MB Max File Size + if err != nil { + logger.WithField("err", err.Error()).Error("Error while parsing the Product form") + Message := "Invalid Form Data! Error while parsing the Product form" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + formdata := req.MultipartForm + contents := formdata.Value + images := formdata.File["images"] + + // Handling XSS attack + for key, vals := range contents { + contents[key] = nil + for _, val := range vals { + contents[key] = append(contents[key], html.EscapeString(val)) + } + } + + err = decoder.Decode(&product, contents) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while decoding product data from the form") + Message := "Invalid form contents, Error while decoding product data from the form" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + errRes, valid := product.Validate() + if valid == false { + response(rw, http.StatusBadRequest, errRes) + return + } + createdProduct, err, errCode := deps.Store.CreateProduct(req.Context(), product, images) + switch errCode { + case http.StatusConflict: + logger.WithField("err", err.Error()).Error("Product name Already exists or kkey value violates schema constraint(s)") + Message := "Product name Already exists or key value violates schema constraint(s)" + responseMsg(rw, http.StatusConflict, Message) + + case http.StatusOK: + response(rw, http.StatusOK, successResponse{Data: createdProduct}) + + default: + logger.WithField("err", err.Error()).Error("Error while inserting Product") + Message := "Internal server Error, facing issue while inserting Product" + responseMsg(rw, http.StatusInternalServerError, Message) + } + return + }) +} + +// @ Title deleteProductById +// @ Description delete product by its id +// @ Router /product/product_id [delete] +// @ Accept json +// @ Success 200 {object} +// @ Failure 400 {object} +func deleteProductByIdHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + vars := mux.Vars(req) + id, err := strconv.Atoi(vars["product_id"]) + if err != nil { + logger.WithField("err", err.Error()).Error("Error id key is missing") + Message := "Error id is missing/invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + err = deps.Store.DeleteProductById(req.Context(), id) + if err != nil { + logger.WithField("err", err.Error()).Error("Error fetching data no row found") + Message := "Internal server error (Error feching data, probably Product doesn't exist.)" + responseMsg(rw, http.StatusInternalServerError, Message) + return + } + + response(rw, http.StatusOK, successResponse{ + Data: "Product Deleted Successfully!", + }) + return + }) +} + +// @ Title updateProductStockById +// @ Description update product by its id +// @ Router /product/product_id [put] +// @ Accept json +// @ Success 200 {object} +// @ Failure 400 {object} +func updateProductStockByIdHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + Id := req.URL.Query().Get("product_id") + Count := req.URL.Query().Get("stock") + var err error + + // Handle errors + productId, err := strconv.Atoi(Id) + + if Id == "" || err != nil { + logger.WithField("err", err.Error()).Error("Error product_id parameter is missing or corrupt") + Message := "Error id is missing/invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + count, err := strconv.Atoi(Count) + + if Count == "" || err != nil { + logger.WithField("err", err.Error()).Error("Error stock parameter is missing or corrupt") + Message := "Error stock parameter is missing or corrupt" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + var updatedProduct db.Product + updatedProduct, err, errCode := deps.Store.UpdateProductStockById(req.Context(), count, productId) + switch errCode { + case http.StatusBadRequest: + logger.WithField("err", err.Error()).Error("Error Product doesn't exist Or User Stock Updation is illegal!") + Message := "Either product doesn't exist with that id or Please Check your Inputs. e.g Stock Can't be negative or greater than 1000." + responseMsg(rw, http.StatusBadRequest, Message) + + case http.StatusOK: + response(rw, http.StatusOK, successResponse{Data: updatedProduct}) + + default: + logger.WithField("err", err.Error()).Error("Error while updating Stock attribute of Product") + Message := "Internal server Error, facing issue while updating Stock attribute of Product" + responseMsg(rw, http.StatusInternalServerError, Message) + } + + return + }) +} + +// @ Title updateProductById +// @ Description update product by its id +// @ Router /product/product_id [put] +// @ Accept json +// @ Success 200 {object} +// @ Failure 400 {object} +func updateProductByIdHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + vars := mux.Vars(req) + var product db.Product + + id, err := strconv.Atoi(vars["product_id"]) + if err != nil { + logger.WithField("err", err.Error()).Error("Error id key is missing") + Message := "Error id is missing/invalid" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + err = req.ParseMultipartForm(15 << 20) // 15 MB Max File Size + if err != nil { + logger.WithField("err", err.Error()).Error("Error while parsing the Product form") + Message := "Invalid Form Data, please include atleast one field(form Content) with value!" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + formdata := req.MultipartForm + contents := formdata.Value + images := formdata.File["images"] + + // Handling XSS attack + for key, vals := range contents { + contents[key] = nil + for _, val := range vals { + contents[key] = append(contents[key], html.EscapeString(val)) + } + } + + err = decoder.Decode(&product, contents) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while decoding product data from the form") + Message := "Invalid form contents, Error while decoding product data from the form" + responseMsg(rw, http.StatusBadRequest, Message) + return + } + + var updatedProduct db.Product + updatedProduct, err, errCode := deps.Store.UpdateProductById(req.Context(), product, id, images) + switch errCode { + case http.StatusBadRequest: + logger.WithField("err", err.Error()).Error("Error Product doesn't exist Or User inputs are Invalid!") + Message := "Either product doesn't exist with that id or Please Check your Inputs. e.g Price Can't be negative, tax Can't be more than 100% etc." + responseMsg(rw, http.StatusBadRequest, Message) + + case http.StatusConflict: + logger.WithField("err", err.Error()).Error("Product name Already exists or key value violates schema constraint(s)") + Message := "Product name Already exists or key value violates schema constraint(s)" + responseMsg(rw, http.StatusConflict, Message) + + case http.StatusOK: + response(rw, http.StatusOK, successResponse{Data: updatedProduct}) + + default: + logger.WithField("err", err.Error()).Error("Error while updating product attribute") + Message := "Internal server error, Error while updating product attribute" + responseMsg(rw, http.StatusInternalServerError, Message) + } + + return + }) +} diff --git a/service/product_http_test.go b/service/product_http_test.go new file mode 100644 index 0000000..8782bb5 --- /dev/null +++ b/service/product_http_test.go @@ -0,0 +1,417 @@ +package service + +import ( + "bytes" + "errors" + "fmt" + "joshsoftware/go-e-commerce/db" + "mime/multipart" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type ProductsHandlerTestSuite struct { + suite.Suite + + dbMock *db.DBMockStore +} + +func (suite *ProductsHandlerTestSuite) SetupTest() { + suite.dbMock = &db.DBMockStore{} +} + +func TestExampleTestSuite(t *testing.T) { + suite.Run(t, new(ProductsHandlerTestSuite)) +} + +func (suite *ProductsHandlerTestSuite) TestGetProductByIdHandlerSuccess() { + + suite.dbMock.On("GetProductByID", mock.Anything, mock.Anything).Return( + db.Product{ + Id: 1, + Name: "test", + Description: "test database", + Price: 123, + Discount: 10, + Tax: 0.5, + Quantity: 5.0, + CategoryId: 1, + CategoryName: "testing", + Brand: "new brand", + Color: "Black", + Size: "Larger", + }, nil, + ) + + recorder := makeHTTPCall( + http.MethodGet, + "/products/{product_id:[0-9]+}", + "/products/1", + "", + getProductByIdHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"id":1,"product_title":"test","description":"test database","product_price":123,"discount":10,"tax":0.5,"stock":5,"category_id":1,"category":"testing","brand":"new brand","color":"Black","size":"Larger","image_urls":null}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) + +} + +func (suite *ProductsHandlerTestSuite) TestGetProductByIdWhenDBFailure() { + + suite.dbMock.On("GetProductByID", mock.Anything, mock.Anything).Return( + db.Product{}, errors.New("Error in fetching data"), + ) + + recorder := makeHTTPCall( + http.MethodGet, + "/product/{product_id:[0-9]+}", + "/product/1", + "", + getProductByIdHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code) + assert.Equal(suite.T(), `{"error":{"message":"Error feching data, Error fetching Product data, no Product found."}}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *ProductsHandlerTestSuite) TestListProductsSuccess() { + + suite.dbMock.On("ListProducts", mock.Anything, mock.Anything, mock.Anything).Return(1, + []db.Product{ + db.Product{ + Id: 1, + Name: "test organization", + Description: "test@gmail.com", + Price: 12, + Discount: 1, + Tax: 0.5, + Quantity: 15, + CategoryId: 5, + CategoryName: "2", + Brand: "IST", + Color: "Black", + Size: "Larger", + }, + }, + nil, + ) + + recorder := makeHTTPCall( + http.MethodGet, + "/products", + "/products?limit=1&page=1", + "", + listProductsHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"products":[{"id":1,"product_title":"test organization","description":"test@gmail.com","product_price":12,"discount":1,"tax":0.5,"stock":15,"category_id":5,"category":"2","brand":"IST","color":"Black","size":"Larger","image_urls":null}],"total_pages":1}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *ProductsHandlerTestSuite) TestListProductsDBFailure() { + + suite.dbMock.On("ListProducts", mock.Anything, mock.Anything, mock.Anything).Return(0, + []db.Product{}, + errors.New("error fetching Products records"), + ) + + recorder := makeHTTPCall(http.MethodGet, + "/products", + "/products?limit=1&page=1", + "", + listProductsHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusInternalServerError, recorder.Code) + assert.Equal(suite.T(), `{"error":{"message":"Couldn't find any Products records or Page out of range"}}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +var urls = []string{"url1", "url2"} + +var testProduct = db.Product{ + Id: 1, + Name: "test organization", + Description: "test@gmail.com", + Price: 12, + Discount: 1, + Tax: 0.5, + Quantity: 15, + CategoryId: 5, + CategoryName: "2", + Brand: "IST", + Color: "Black", + Size: "Medium", + URLs: urls, +} + +func (suite *ProductsHandlerTestSuite) TestCreateProductSuccess() { + + payload := &bytes.Buffer{} + writer := multipart.NewWriter(payload) + _ = writer.WriteField("product_title", "test organization") + _ = writer.WriteField("description", "test@gmail.com") + _ = writer.WriteField("product_price", "12") + _ = writer.WriteField("discount", "1") + _ = writer.WriteField("tax", "0.5") + _ = writer.WriteField("stock", "15") + _ = writer.WriteField("category_id", "5") + _ = writer.WriteField("category", "2") + _ = writer.WriteField("color", "Black") + _ = writer.WriteField("size", "Medium") + _ = writer.WriteField("brand", "IST") + err := writer.Close() + if err != nil { + fmt.Println(err) + } + + suite.dbMock.On("CreateProduct", mock.Anything, mock.Anything, mock.Anything).Return( + db.Product{ + Id: 1, + Name: "test organization", + Description: "test@gmail.com", + Price: 12, + Discount: 1, + Tax: 0.5, + Quantity: 15, + CategoryId: 5, + CategoryName: "2", + Brand: "IST", + Color: "Black", + Size: "Medium", + URLs: urls, + }, + nil, + 200, + ) + + recorder := makeHTTPCallWithHeader( + http.MethodPost, + "/createProduct", + "/createProduct", + writer, + payload, + createProductHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":{"id":1,"product_title":"test organization","description":"test@gmail.com","product_price":12,"discount":1,"tax":0.5,"stock":15,"category_id":5,"category":"2","brand":"IST","color":"Black","size":"Medium","image_urls":["url1","url2"]}}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *ProductsHandlerTestSuite) TestCreateProductFailure() { + + payload := &bytes.Buffer{} + writer := multipart.NewWriter(payload) + _ = writer.WriteField("product_title", "test organization") + _ = writer.WriteField("description", "test@gmail.com") + _ = writer.WriteField("product_price", "12") + _ = writer.WriteField("discount", "1") + _ = writer.WriteField("tax", "0.5") + _ = writer.WriteField("stock", "15") + _ = writer.WriteField("category_id", "5") + _ = writer.WriteField("category", "2") + _ = writer.WriteField("color", "Black") + _ = writer.WriteField("size", "Medium") + _ = writer.WriteField("brand", "IST") + + suite.dbMock.On("CreateProduct", mock.Anything, mock.Anything, mock.Anything).Return( + db.Product{}, + fmt.Errorf("Product Already Exists!"), + 409, + ) + + recorder := makeHTTPCallWithHeader( + http.MethodPost, + "/createProduct", + "/createProduct", + writer, + payload, + createProductHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusConflict, recorder.Code) + assert.Equal(suite.T(), `{"error":{"message":"Product name Already exists or key value violates schema constraint(s)"}}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *ProductsHandlerTestSuite) TestCreateProductValidationFailure() { + + payload := &bytes.Buffer{} + writer := multipart.NewWriter(payload) + _ = writer.WriteField("product_title", "test organization") + _ = writer.WriteField("description", "test@gmail.com") + _ = writer.WriteField("product_price", "12") + _ = writer.WriteField("discount", "-15") + _ = writer.WriteField("tax", "0.5") + _ = writer.WriteField("stock", "15") + _ = writer.WriteField("category_id", "5") + _ = writer.WriteField("category", "2") + _ = writer.WriteField("color", "Black") + _ = writer.WriteField("size", "Medium") + _ = writer.WriteField("brand", "IST") + + recorder := makeHTTPCallWithHeader( + http.MethodPost, + "/createProduct", + "/createProduct", + writer, + payload, + createProductHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code) + assert.Equal(suite.T(), `{"error":{"code":"Invalid_data","message":"Please Provide valid Product data","fields":{"discount":"Can't be less than zero or more than 100 %"}}}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) + +} + +func (suite *ProductsHandlerTestSuite) TestDeleteProductByIdSuccess() { + + suite.dbMock.On("DeleteProductById", mock.Anything, 1).Return( + nil, + ) + + recorder := makeHTTPCall(http.MethodDelete, + "/product/{product_id:[0-9]+}", + "/product/1", + "", + deleteProductByIdHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":"Product Deleted Successfully!"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *ProductsHandlerTestSuite) TestDeleteProductByIdDbFailure() { + + suite.dbMock.On("DeleteProductById", mock.Anything, 1).Return( + errors.New("Error while deleting Products"), + ) + + recorder := makeHTTPCall(http.MethodDelete, + "/product/{product_id:[0-9]+}", + "/product/1", + "", + deleteProductByIdHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusInternalServerError, recorder.Code) + assert.Equal(suite.T(), `{"error":{"message":"Internal server error (Error feching data, probably Product doesn't exist.)"}}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *ProductsHandlerTestSuite) TestUpdateProductStockByIdSuccess() { + + suite.dbMock.On("UpdateProductStockById", mock.Anything, 2, 1).Return(testProduct, nil, 200) + + recorder := makeHTTPCall(http.MethodPut, + "/product/stock", + "/product/stock?product_id=1&stock=2", + "", + updateProductStockByIdHandler(Dependencies{Store: suite.dbMock}), + ) + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":{"id":1,"product_title":"test organization","description":"test@gmail.com","product_price":12,"discount":1,"tax":0.5,"stock":15,"category_id":5,"category":"2","brand":"IST","color":"Black","size":"Medium","image_urls":["url1","url2"]}}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) + +} + +func (suite *ProductsHandlerTestSuite) TestUpdateProductStockByIdFailure() { + + suite.dbMock.On("UpdateProductStockById", mock.Anything, mock.Anything, "a").Return(db.Product{}, errors.New("Error id is missing/invalid")) + + recorder := makeHTTPCall(http.MethodPut, + "/product/stock", + "/product/stock?product_id=1&stock=a", + "", + updateProductStockByIdHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code) + assert.Equal(suite.T(), `{"error":{"message":"Error stock parameter is missing or corrupt"}}`, recorder.Body.String()) + +} + +func (suite *ProductsHandlerTestSuite) TestUpdateProductByIdSuccess() { + + payload := &bytes.Buffer{} + writer := multipart.NewWriter(payload) + _ = writer.WriteField("product_title", "test organization") + _ = writer.WriteField("description", "test@gmail.com") + _ = writer.WriteField("product_price", "12") + _ = writer.WriteField("discount", "1") + _ = writer.WriteField("tax", "0.5") + _ = writer.WriteField("stock", "15") + + suite.dbMock.On("UpdateProductById", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + db.Product{ + Id: 1, + Name: "test organization", + Description: "newUpdatedtest@gmail.com", + Price: 120, + Discount: 10, + Tax: 0.05, + Quantity: 150, + CategoryId: 5, + CategoryName: "2", + Brand: "IST", + Color: "Black", + Size: "Medium", + URLs: urls, + }, + nil, + http.StatusOK, + ) + + recorder := makeHTTPCallWithHeader( + http.MethodPut, + "/product/{product_id:[0-9]+}", + "/product/1", + writer, + payload, + updateProductByIdHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":{"id":1,"product_title":"test organization","description":"newUpdatedtest@gmail.com","product_price":120,"discount":10,"tax":0.05,"stock":150,"category_id":5,"category":"2","brand":"IST","color":"Black","size":"Medium","image_urls":["url1","url2"]}}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} +func (suite *ProductsHandlerTestSuite) TestUpdateProductByIdFailure() { + + payload := &bytes.Buffer{} + writer := multipart.NewWriter(payload) + _ = writer.WriteField("product_title", "test organization") + _ = writer.WriteField("description", "test@gmail.com") + _ = writer.WriteField("product_price", "12") + _ = writer.WriteField("discount", "1") + _ = writer.WriteField("tax", "0.5") + _ = writer.WriteField("stock", "15") + + suite.dbMock.On("UpdateProductById", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + db.Product{}, + fmt.Errorf("Product Couldn't get updated! Internal Error."), + http.StatusInternalServerError, + ) + + recorder := makeHTTPCallWithHeader( + http.MethodPut, + "/product/{product_id:[0-9]+}", + "/product/1", + writer, + payload, + updateProductByIdHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusInternalServerError, recorder.Code) + assert.Equal(suite.T(), `{"error":{"message":"Internal server error, Error while updating product attribute"}}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} diff --git a/service/response.go b/service/response.go new file mode 100644 index 0000000..fda1bd1 --- /dev/null +++ b/service/response.go @@ -0,0 +1,40 @@ +package service + +import ( + "encoding/json" + "net/http" + + logger "github.com/sirupsen/logrus" +) + +type errorResponse struct { + Error interface{} `json:"error"` +} +type successResponse struct { + Data interface{} `json:"data"` +} + +type messageObject struct { + Message string `json:"message"` +} + +func responseMsg(rw http.ResponseWriter, status int, msgbody string) { + response(rw, status, errorResponse{ + Error: messageObject{ + Message: msgbody, + }, + }) +} + +func response(rw http.ResponseWriter, status int, responseBody interface{}) { + respBytes, err := json.Marshal(responseBody) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while marshaling core values data") + rw.WriteHeader(http.StatusInternalServerError) + return + } + + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(status) + rw.Write(respBytes) +} diff --git a/service/router.go b/service/router.go index 121ad64..a77524a 100644 --- a/service/router.go +++ b/service/router.go @@ -23,6 +23,14 @@ func InitRouter(deps Dependencies) (router *mux.Router) { // Version 1 API management v1 := fmt.Sprintf("application/vnd.%s.v1", config.AppName()) - router.HandleFunc("/users", listUsersHandler(deps)).Methods(http.MethodGet).Headers(versionHeader, v1) + router.HandleFunc("/products", listProductsHandler(deps)).Methods(http.MethodGet).Headers(versionHeader, v1) + router.HandleFunc("/product/{product_id:[0-9]+}", getProductByIdHandler(deps)).Methods(http.MethodGet).Headers(versionHeader, v1) + router.HandleFunc("/createProduct", createProductHandler(deps)).Methods(http.MethodPost).Headers(versionHeader, v1) + router.HandleFunc("/product/{product_id:[0-9]+}", deleteProductByIdHandler(deps)).Methods(http.MethodDelete).Headers(versionHeader, v1) + router.HandleFunc("/products/filters", getProductByFiltersHandler(deps)).Methods(http.MethodGet).Headers(versionHeader, v1) + router.HandleFunc("/product/stock", updateProductStockByIdHandler(deps)).Methods(http.MethodPut).Headers(versionHeader, v1) + router.HandleFunc("/products/search", getProductBySearchHandler(deps)).Methods(http.MethodGet).Headers(versionHeader, v1) + router.HandleFunc("/product/{product_id:[0-9]+}", updateProductByIdHandler(deps)).Methods(http.MethodPut).Headers(versionHeader, v1) + return } diff --git a/service/user_http.go b/service/user_http.go deleted file mode 100644 index c544bcd..0000000 --- a/service/user_http.go +++ /dev/null @@ -1,35 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - - logger "github.com/sirupsen/logrus" -) - -// @Title listUsers -// @Description list all User -// @Router /users [get] -// @Accept json -// @Success 200 {object} -// @Failure 400 {object} -func listUsersHandler(deps Dependencies) http.HandlerFunc { - return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - users, err := deps.Store.ListUsers(req.Context()) - if err != nil { - logger.WithField("err", err.Error()).Error("Error fetching data") - rw.WriteHeader(http.StatusInternalServerError) - return - } - - respBytes, err := json.Marshal(users) - if err != nil { - logger.WithField("err", err.Error()).Error("Error marshaling users data") - rw.WriteHeader(http.StatusInternalServerError) - return - } - - rw.Header().Add("Content-Type", "application/json") - rw.Write(respBytes) - }) -} diff --git a/service/user_http_test.go b/service/user_http_test.go deleted file mode 100644 index 4e2b367..0000000 --- a/service/user_http_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package service - -import ( - "errors" - "joshsoftware/go-e-commerce/db" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gorilla/mux" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" -) - -// Define the suite, and absorb the built-in basic suite -// functionality from testify - including assertion methods. -type UsersHandlerTestSuite struct { - suite.Suite - - dbMock *db.DBMockStore -} - -func (suite *UsersHandlerTestSuite) SetupTest() { - suite.dbMock = &db.DBMockStore{} -} - -func TestExampleTestSuite(t *testing.T) { - suite.Run(t, new(UsersHandlerTestSuite)) -} - -func (suite *UsersHandlerTestSuite) TestListUsersSuccess() { - suite.dbMock.On("ListUsers", mock.Anything).Return( - []db.User{ - db.User{Name: "test-user", Age: 18}, - }, - nil, - ) - - recorder := makeHTTPCall( - http.MethodGet, - "/users", - "", - listUsersHandler(Dependencies{Store: suite.dbMock}), - ) - - assert.Equal(suite.T(), http.StatusOK, recorder.Code) - assert.Equal(suite.T(), `[{"full_name":"test-user","age":18}]`, recorder.Body.String()) - suite.dbMock.AssertExpectations(suite.T()) -} - -func (suite *UsersHandlerTestSuite) TestListUsersWhenDBFailure() { - suite.dbMock.On("ListUsers", mock.Anything).Return( - []db.User{}, - errors.New("error fetching user records"), - ) - - recorder := makeHTTPCall( - http.MethodGet, - "/users", - "", - listUsersHandler(Dependencies{Store: suite.dbMock}), - ) - - assert.Equal(suite.T(), http.StatusInternalServerError, recorder.Code) - suite.dbMock.AssertExpectations(suite.T()) -} - -func makeHTTPCall(method, path, body string, handlerFunc http.HandlerFunc) (recorder *httptest.ResponseRecorder) { - // create a http request using the given parameters - req, _ := http.NewRequest(method, path, strings.NewReader(body)) - - // test recorder created for capturing api responses - recorder = httptest.NewRecorder() - - // create a router to serve the handler in test with the prepared request - router := mux.NewRouter() - router.HandleFunc(path, handlerFunc).Methods(method) - - // serve the request and write the response to recorder - router.ServeHTTP(recorder, req) - return -}