diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..34e2de18f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.19 + +WORKDIR /app/manabie + +COPY go.* ./ + +RUN go mod tidy + +COPY . ./ + +RUN cd /app/manabie/cmd/ && go build -o /server + +EXPOSE 9000 + +CMD ["/server"] \ No newline at end of file diff --git a/README.md b/README.md index 6398b5c09..72fccb183 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,53 @@ -### Requirements - -- Implement one single API which accepts a todo task and records it - - There is a maximum **limit of N tasks per user** that can be added **per day**. - - Different users can have **different** maximum daily limit. -- Write integration (functional) tests -- Write unit tests -- Choose a suitable architecture to make your code simple, organizable, and maintainable -- Using Docker to run locally - - Using Docker for database (if used) is mandatory. -- Write a concise README - - How to run your code locally? - - A sample “curl” command to call your API - - How to run your unit tests locally? - - What do you love about your solution? - - What else do you want us to know about however you do not have enough time to complete? - -### Notes - -- We're using Golang at Manabie. **However**, we encourage you to use the programming language that you are most comfortable with because we want you to **shine** with all your skills and knowledge. - -### How to submit your solution? - -- Fork this repo and show us your development progress via a PR - -### Interesting facts about Manabie - -- Monthly there are about 2 million lines of code changes (inserted/updated/deleted) committed into our GitHub repositories. To avoid **regression bugs**, we write different kinds of **automated tests** (unit/integration (functionality)/end2end) as parts of the definition of done of our assigned tasks. -- We nurture the cultural values: **knowledge sharing** and **good communication**, therefore good written documents and readable, organizable, and maintainable code are in our blood when we build any features to grow our products. -- We have **collaborative** culture at Manabie. Feel free to ask trieu@manabie.com any questions. We are very happy to answer all of them. - -Thank you for spending time to read and attempt our take-home assessment. We are looking forward to your submission. +### Run Project. +Use docker run command: +- `docker-compose up` + +### Run Test +- Step 1: `cd /test` +- Step 2: Run command `go test -v` +### List Api: +#### Call curl below or import file postman in package document. +- auth/register: register account. + `` + curl --location --request POST 'localhost:9000/auth/register' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "userName": "admin", + "passWord": "123456" + }' + `` +- auth/login: login + `` + curl --location --request POST 'localhost:9000/auth/login' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "userName": "admin", + "passWord": "123456" + }' + `` +- task/create: create task. + `` + curl --location --request POST 'localhost:9000/task/create' \ + --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODcwMjAwMDEsImlzcyI6Im1hbmliaWUtdG9kbyIsIklkIjoyfQ.VYSoGawZE_6XrXRfurkswKYo1x1doAAmFDpuplrUFcU' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "content": "Task test danh 4" + }' + `` +- task/list: get task by created date. + `` + curl --location --request GET 'localhost:9000/task/list?createdDate=2023-04-18' \ + --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODcwMjAwMDEsImlzcyI6Im1hbmliaWUtdG9kbyIsIklkIjoyfQ.VYSoGawZE_6XrXRfurkswKYo1x1doAAmFDpuplrUFcU' + `` + +### Solution +Limit the user to add work by day if the limit is reached then use redis cache to +reduce the load on the database, cache on redis with userId and expire at the end of the day. + +### Technology used +- Golang version 1.19 +- Postgres +- Redis +- Gin, Gorm +- Docker +- testcontainer \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 000000000..130b668bb --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "log" + "togo/pkg/config" + "togo/pkg/db_client" + "togo/pkg/routes" + "togo/pkg/services" + "togo/pkg/utils" +) + +func main() { + c, err := config.LoadConfig() + if err != nil { + log.Fatalln("Failed at config", err) + } + + r := gin.Default() + h := db_client.Init(c.DBUrl) + jwt := utils.JwtWrapper{ + SecretKey: c.JWTSecretKey, + Issuer: "manibie-todo", + ExpirationHours: 24 * 60, + } + + redisClient := redis.NewClient(&redis.Options{ + Addr: c.RedisUrl, + Password: c.RedisPassWord, + DB: 0, + }) + + s := services.Server{ + H: h, + Jwt: jwt, + Redis: redisClient, + } + routes.RegisterRoutes(r, &s) + r.Run(c.Port) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..559d659c4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +version: "3" + +networks: + go-manabie-network: + driver: bridge +volumes: + postgres: + +services: + database-postgres: + image: postgres + container_name: c-postgres + hostname: postgres + ports: + - "5432:5432" + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=admin123 + volumes: + - postgres:/var/lib/postgresql/data + - ./postgres/script_db.sql:/docker-entrypoint-initdb.d/script_db.sql + networks: + - go-manabie-network + cache: + image: redis:6.2-alpine + container_name: c-cache + restart: always + ports: + - '6379:6379' + command: redis-server --save 20 1 --loglevel warning --requirepass admin123 + networks: + - go-manabie-network + api-manabie: + build: + dockerfile: Dockerfile + context: . + container_name: c-manabie + hostname: manabie + depends_on: + - database-postgres + - cache + restart: always + ports: + - "9000:9000" + networks: + - go-manabie-network \ No newline at end of file diff --git a/document/ManabieTest.postman_collection.json b/document/ManabieTest.postman_collection.json new file mode 100644 index 000000000..83062c3a9 --- /dev/null +++ b/document/ManabieTest.postman_collection.json @@ -0,0 +1,138 @@ +{ + "info": { + "_postman_id": "9e203b2e-ed76-43fa-8fa1-6017da1b723f", + "name": "ManabieTest", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "localhost:9001/auth/register", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userName\": \"admin\",\n \"passWord\": \"123456\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:9001/auth/register", + "host": [ + "localhost" + ], + "port": "9001", + "path": [ + "auth", + "register" + ] + } + }, + "response": [] + }, + { + "name": "localhost:9001/auth/login", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userName\": \"admin\",\n \"passWord\": \"123456\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:9001/auth/login", + "host": [ + "localhost" + ], + "port": "9001", + "path": [ + "auth", + "login" + ] + } + }, + "response": [] + }, + { + "name": "localhost:9001/task/create", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODcwMjAwMDEsImlzcyI6Im1hbmliaWUtdG9kbyIsIklkIjoyfQ.VYSoGawZE_6XrXRfurkswKYo1x1doAAmFDpuplrUFcU", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"Task test danh 4\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:9001/task/create", + "host": [ + "localhost" + ], + "port": "9001", + "path": [ + "task", + "create" + ] + } + }, + "response": [] + }, + { + "name": "localhost:9001/task/list?createdDate=2023-04-18", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODcwMjAwMDEsImlzcyI6Im1hbmliaWUtdG9kbyIsIklkIjoyfQ.VYSoGawZE_6XrXRfurkswKYo1x1doAAmFDpuplrUFcU", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:9001/task/list?createdDate=2023-04-18", + "host": [ + "localhost" + ], + "port": "9001", + "path": [ + "task", + "list" + ], + "query": [ + { + "key": "createdDate", + "value": "2023-04-18" + } + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..a24fa0475 --- /dev/null +++ b/go.mod @@ -0,0 +1,86 @@ +module togo + +go 1.19 + +require ( + github.com/gin-gonic/gin v1.9.0 + github.com/go-playground/assert/v2 v2.2.0 + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/google/uuid v1.3.0 + github.com/redis/go-redis/v9 v9.0.3 + github.com/spf13/viper v1.15.0 + golang.org/x/crypto v0.8.0 + gorm.io/driver/postgres v1.5.0 + gorm.io/gorm v1.25.0 +) + +require ( + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.5.2 // indirect + github.com/atkinsonbg/go-gmux-db-testcontainers v0.0.0-20210123160844-a46ed7ad99ed // indirect + github.com/bytedance/sonic v1.8.0 // indirect + github.com/cenkalti/backoff/v4 v4.2.0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/containerd/containerd v1.6.19 // indirect + github.com/cpuguy83/dockercfg v0.3.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/docker/distribution v2.8.1+incompatible // indirect + github.com/docker/docker v23.0.1+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.11.2 // indirect + github.com/goccy/go-json v0.10.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kelseyhightower/envconfig v1.4.0 // indirect + github.com/klauspost/compress v1.11.13 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/lib/pq v1.7.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/patternmatcher v0.5.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/term v0.0.0-20221128092401-c43b287e0e0f // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc2 // indirect + github.com/opencontainers/runc v1.1.3 // indirect + github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rogpeppe/go-internal v1.8.1 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect + github.com/spf13/afero v1.9.3 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.8.2 // indirect + github.com/subosito/gotenv v1.4.2 // indirect + github.com/testcontainers/testcontainers-go v0.19.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.9 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/net v0.9.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect + google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef // indirect + google.golang.org/grpc v1.52.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 000000000..855cc2156 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,24 @@ +package config + +import "github.com/spf13/viper" + +type Config struct { + Port string `mapstructure:"PORT"` + DBUrl string `mapstructure:"DB_URL"` + JWTSecretKey string `mapstructure:"JWT_SECRET_KEY"` + RedisUrl string `mapstructure:"REDIS_URL"` + RedisPassWord string `mapstructure:"REDIS_PASSWORD"` +} + +func LoadConfig() (config Config, err error) { + viper.AddConfigPath("./pkg/config/envs") + viper.SetConfigName("dev") + viper.SetConfigType("env") + viper.AutomaticEnv() + err = viper.ReadInConfig() + if err != nil { + return + } + err = viper.Unmarshal(&config) + return +} diff --git a/pkg/config/envs/dev.env b/pkg/config/envs/dev.env new file mode 100644 index 000000000..8415b60a8 --- /dev/null +++ b/pkg/config/envs/dev.env @@ -0,0 +1,8 @@ +PORT=:9000 +DB_URL=postgres://postgres:admin123@c-postgres:5432/postgres +JWT_SECRET_KEY=TEST_ABC +REDIS_URL=c-cache:6379 +REDIS_PASSWORD=admin123 + +##REDIS_URL=localhost:6379 +##DB_URL=postgres://postgres:admin123@localhost:5432/postgres \ No newline at end of file diff --git a/pkg/db_client/database.go b/pkg/db_client/database.go new file mode 100644 index 000000000..557f7962d --- /dev/null +++ b/pkg/db_client/database.go @@ -0,0 +1,21 @@ +package db_client + +import ( + "gorm.io/driver/postgres" + "gorm.io/gorm" + "log" +) + +type Handler struct { + DB *gorm.DB +} + +func Init(url string) Handler { + db, err := gorm.Open(postgres.Open(url), &gorm.Config{}) + if err != nil { + log.Fatalln(err) + } + //db.AutoMigrate(&models.User{}) + //db.AutoMigrate(&models.Task{}) + return Handler{db} +} diff --git a/pkg/models/response.go b/pkg/models/response.go new file mode 100644 index 000000000..532bfa7c9 --- /dev/null +++ b/pkg/models/response.go @@ -0,0 +1,22 @@ +package models + +type Response[T any] struct { + Message string `json:"message"` + Status int64 `json:"status"` + Data []T `json:"data"` +} + +type TaskRequest struct { + Content string `json:"content"` +} + +type AuthRequest struct { + UserName string `json:"userName"` + PassWord string `json:"PassWord"` +} + +type LoginResponse struct { + Message string `json:"message"` + Status int64 `json:"status"` + Token string `json:"token"` +} diff --git a/pkg/models/task.go b/pkg/models/task.go new file mode 100644 index 000000000..204a0aa30 --- /dev/null +++ b/pkg/models/task.go @@ -0,0 +1,14 @@ +package models + +type Task struct { + TaskId string `json:"task_id"` + UserId int64 `json:"user_id"` + Content string `json:"content"` + CreatedDate string `json:"created_date"` + EventTime string `json:"event_time"` +} + +type CountTask struct { + NumberTask int64 `json:"number_task"` + LimitTask int64 `json:"limit_task"` +} diff --git a/pkg/models/user.go b/pkg/models/user.go new file mode 100644 index 000000000..5d49f526b --- /dev/null +++ b/pkg/models/user.go @@ -0,0 +1,8 @@ +package models + +type User struct { + Id int64 `json:"id" gorm:"primaryKey"` + UserName string `json:"user_name" gorm:"column:user_name"` + Password string `json:"pass_word" gorm:"column:pass_word"` + LimitTask int64 `json:"limit_task" gorm:"column:limit_task"` +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go new file mode 100644 index 000000000..3e2ea5bff --- /dev/null +++ b/pkg/routes/routes.go @@ -0,0 +1,17 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + "togo/pkg/services" +) + +func RegisterRoutes(r *gin.Engine, s *services.Server) { + routes := r.Group("/auth") + routes.POST("/register", s.RegisterAccount) + routes.POST("/login", s.LoginAccount) + + routesTask := r.Group("/task") + routesTask.Use(s.Validate) + routesTask.GET("/list", s.GetTaskByDate) + routesTask.POST("/create", s.CreateTask) +} diff --git a/pkg/services/auth.go b/pkg/services/auth.go new file mode 100644 index 000000000..4142d6dfe --- /dev/null +++ b/pkg/services/auth.go @@ -0,0 +1,97 @@ +package services + +import ( + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "net/http" + "strings" + "togo/pkg/db_client" + "togo/pkg/models" + "togo/pkg/utils" +) + +type Server struct { + H db_client.Handler + Jwt utils.JwtWrapper + Redis *redis.Client +} + +func (s *Server) RegisterAccount(ctx *gin.Context) { + payload := models.AuthRequest{} + if err := ctx.BindJSON(&payload); err != nil { + ctx.AbortWithError(http.StatusBadRequest, err) + return + } + + var user models.User + if result := s.H.DB.Where(&models.User{UserName: payload.UserName}).First(&user); result.Error == nil { + ctx.JSON(http.StatusConflict, "UserName already exists") + return + } + + user.UserName = payload.UserName + user.Password = utils.HashPassword(payload.PassWord) + user.LimitTask = 5 + s.H.DB.Create(&user) + + var response models.Response[any] + response.Status = http.StatusOK + response.Message = "Create User Success" + ctx.JSON(http.StatusOK, response) +} + +func (s *Server) LoginAccount(ctx *gin.Context) { + payload := models.AuthRequest{} + if err := ctx.BindJSON(&payload); err != nil { + ctx.AbortWithError(http.StatusBadRequest, err) + return + } + + var user models.User + if result := s.H.DB.Where(&models.User{UserName: payload.UserName}).First(&user); result.Error != nil { + ctx.JSON(http.StatusNotFound, "Username not found.") + return + } + + checkPass := utils.CheckPasswordHash(payload.PassWord, user.Password) + if !checkPass { + ctx.JSON(http.StatusNotFound, "Password invalid.") + return + } + + token, _ := s.Jwt.GenerateToken(user) + var tokenResponse = models.LoginResponse{ + Status: http.StatusOK, + Message: "Login Success", + Token: token, + } + ctx.JSON(http.StatusOK, tokenResponse) +} + +func (s *Server) Validate(ctx *gin.Context) { + authorization := ctx.Request.Header.Get("authorization") + if authorization == "" { + ctx.AbortWithStatus(http.StatusUnauthorized) + return + } + + token := strings.Split(authorization, "Bearer ") + if len(token) < 2 { + ctx.AbortWithStatus(http.StatusUnauthorized) + return + } + + claims, err := s.Jwt.ValidateToken(token[1]) + if err != nil { + ctx.AbortWithStatus(http.StatusUnauthorized) + return + } + + var user models.User + if result := s.H.DB.Where(&models.User{Id: claims.Id}).First(&user); result.Error != nil { + ctx.AbortWithStatus(http.StatusUnauthorized) + return + } + ctx.Set("userId", user.Id) + ctx.Next() +} diff --git a/pkg/services/task.go b/pkg/services/task.go new file mode 100644 index 000000000..f28c45c48 --- /dev/null +++ b/pkg/services/task.go @@ -0,0 +1,85 @@ +package services + +import ( + "context" + "fmt" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "net/http" + "time" + "togo/pkg/models" + "togo/pkg/utils" +) + +func (s *Server) GetTaskByDate(ctx *gin.Context) { + createdDate := ctx.Query("createdDate") + userId, _ := ctx.Get("userId") + + var task []models.Task + if result := s.H.DB.Where(&models.Task{ + UserId: userId.(int64), + CreatedDate: createdDate, + }).Find(&task); result.Error != nil { + ctx.JSON(http.StatusBadRequest, result.Error.Error()) + return + } + + var response models.Response[models.Task] + response.Status = http.StatusOK + response.Message = "OK" + response.Data = task + ctx.JSON(http.StatusOK, response) +} + +func (s *Server) CreateTask(ctx *gin.Context) { + request := models.TaskRequest{} + if err := ctx.BindJSON(&request); err != nil { + ctx.AbortWithError(http.StatusBadRequest, err) + return + } + + userIdJwt, _ := ctx.Get("userId") + userId := userIdJwt.(int64) + keyRedisCache := fmt.Sprintf("USER_LIMIT_TASK_%v", userIdJwt) + result, _ := s.Redis.Exists(context.Background(), keyRedisCache).Result() + if result == 1 { + ctx.JSON(http.StatusBadRequest, "Limit create task by date.") + return + } + + var countTask models.CountTask + var createDateNow = time.Now().Format("2006-01-02") + query := s.H.DB.Table("users").Select("users.limit_task , count(*) as number_task") + query = query.Joins(" left join tasks on users.id = tasks.user_id ") + query = query.Where(" users.id = ? and tasks.created_date= ?", userId, createDateNow).Group("users.limit_task") + query = query.Scan(&countTask) + + if countTask.LimitTask > 0 && countTask.NumberTask >= countTask.LimitTask { + ctx.JSON(http.StatusBadRequest, "Limit create task by date.") + go func() { + nowDate := time.Now() + endDate := utils.EndDate(nowDate) + secondExpire := (endDate.UTC().UnixMilli() - nowDate.UTC().UnixMilli()) / 1000 + err := s.Redis.Set(context.Background(), keyRedisCache, "true", time.Duration(secondExpire)*time.Second).Err() + if err != nil { + fmt.Println("Set cache redis limit user task fail") + } + }() + return + } + + var task models.Task + task.UserId = userId + task.TaskId = uuid.New().String() + task.CreatedDate = createDateNow + task.EventTime = time.Now().Format("2006-01-02 15:04:05") + task.Content = request.Content + + s.H.DB.Create(&task) + + var response models.Response[models.Task] + response.Status = http.StatusOK + response.Message = "Create task success" + response.Data = []models.Task{task} + ctx.JSON(http.StatusOK, response) +} diff --git a/pkg/utils/hash.go b/pkg/utils/hash.go new file mode 100644 index 000000000..695b214f2 --- /dev/null +++ b/pkg/utils/hash.go @@ -0,0 +1,20 @@ +package utils + +import ( + "golang.org/x/crypto/bcrypt" + "time" +) + +func HashPassword(password string) string { + bytes, _ := bcrypt.GenerateFromPassword([]byte(password), 5) + return string(bytes) +} + +func CheckPasswordHash(password string, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func EndDate(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()) +} diff --git a/pkg/utils/jwt.go b/pkg/utils/jwt.go new file mode 100644 index 000000000..e0e3e8e1d --- /dev/null +++ b/pkg/utils/jwt.go @@ -0,0 +1,65 @@ +package utils + +import ( + "errors" + "github.com/golang-jwt/jwt" + "time" + "togo/pkg/models" +) + +type JwtWrapper struct { + SecretKey string + Issuer string + ExpirationHours int64 +} + +type jwtClaims struct { + jwt.StandardClaims + Id int64 +} + +func (w *JwtWrapper) GenerateToken(user models.User) (signedToken string, err error) { + claims := &jwtClaims{ + Id: user.Id, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: time.Now().Local().Add(time.Hour * time.Duration(w.ExpirationHours)).Unix(), + Issuer: w.Issuer, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + signedToken, err = token.SignedString([]byte(w.SecretKey)) + + if err != nil { + return "", err + } + + return signedToken, nil +} + +func (w *JwtWrapper) ValidateToken(signedToken string) (claims *jwtClaims, err error) { + token, err := jwt.ParseWithClaims( + signedToken, + &jwtClaims{}, + func(token *jwt.Token) (interface{}, error) { + return []byte(w.SecretKey), nil + }, + ) + + if err != nil { + return + } + + claims, ok := token.Claims.(*jwtClaims) + + if !ok { + return nil, errors.New("couldn't parse claims") + } + + if claims.ExpiresAt < time.Now().Local().Unix() { + return nil, errors.New("JWT is expired") + } + + return claims, nil +} diff --git a/postgres/script_db.sql b/postgres/script_db.sql new file mode 100644 index 000000000..5268b67aa --- /dev/null +++ b/postgres/script_db.sql @@ -0,0 +1,18 @@ + +CREATE TABLE users ( + id INT GENERATED ALWAYS AS IDENTITY, + user_name VARCHAR(50) UNIQUE NOT NULL, + pass_word VARCHAR NOT NULL, + limit_task INT DEFAULT 5 NOT null, + PRIMARY KEY(id) +); + +CREATE TABLE tasks ( + task_id VARCHAR NOT NULL, + user_id INT NOT NULL REFERENCES users(id), + "content" VARCHAR(100) NOT NULL, + created_date DATE NOT NULL, + event_time DATE NOT NULL, + PRIMARY KEY (task_id), + FOREIGN KEY(user_id) REFERENCES users(id) +); \ No newline at end of file diff --git a/test/main_test.go b/test/main_test.go new file mode 100644 index 000000000..2eb108ad3 --- /dev/null +++ b/test/main_test.go @@ -0,0 +1,251 @@ +package test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + "togo/pkg/db_client" + "togo/pkg/models" + "togo/pkg/services" + "togo/pkg/utils" +) + +var serverTest = SetupTestDatabase() + +func SetUpRouter() *gin.Engine { + router := gin.Default() + return router +} + +func TestRegisterAccount(t *testing.T) { + //serverTest := SetupTestDatabase() + r := SetUpRouter() + r.POST("/auth/register", serverTest.RegisterAccount) + var jsonStr = []byte(`{"UserName":"admin", "passWord": "123456"}`) + req, _ := http.NewRequest("POST", "/auth/register", bytes.NewBuffer(jsonStr)) + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + + if res.Code == http.StatusConflict { + t.Log("Register account by userName is exits.") + return + } + if res.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, res.Code) + return + } + bodyRes, _ := ioutil.ReadAll(res.Body) + var registerResult models.Response[models.LoginResponse] + err := json.Unmarshal(bodyRes, ®isterResult) + if err != nil { + t.Errorf("Error parse body response register account.") + return + } + t.Log("Register account success") +} + +func TestLoginAccount(t *testing.T) { + //serverTest := SetupTestDatabase() + r := SetUpRouter() + r.POST("/auth/login", serverTest.LoginAccount) + var jsonStr = []byte(`{"UserName":"admin", "passWord": "123456"}`) + req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonStr)) + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + + if res.Code == http.StatusNotFound { + t.Errorf("User not found.") + return + } + + if res.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, res.Code) + return + } + + bodyRes, _ := ioutil.ReadAll(res.Body) + var result models.LoginResponse + err := json.Unmarshal(bodyRes, &result) + + if err != nil { + t.Errorf("Error parse body response login") + return + } + if result.Status != http.StatusOK { + t.Errorf("Login fail, message = " + result.Message) + return + } + if result.Token == "" { + t.Errorf("Create token by login fail") + return + } + t.Log("Token login success: " + result.Token) +} + +func LoginGetToken() models.LoginResponse { + //serverTest := SetupTestDatabase() + r := SetUpRouter() + r.POST("/auth/login", serverTest.LoginAccount) + var jsonStr = []byte(`{"UserName":"admin", "passWord": "123456"}`) + req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonStr)) + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + if res.Code == http.StatusNotFound { + return models.LoginResponse{} + } + if res.Code != http.StatusOK { + return models.LoginResponse{} + } + bodyRes, _ := ioutil.ReadAll(res.Body) + var result models.LoginResponse + json.Unmarshal(bodyRes, &result) + return result +} + +func TestCreateTask(t *testing.T) { + token := LoginGetToken().Token + //serverTest := SetupTestDatabase() + r := SetUpRouter() + + r.Use(serverTest.Validate) + r.POST("/task/create", serverTest.CreateTask) + + var jsonCreate = []byte(`{"content":"Task test danh 4"}`) + req, _ := http.NewRequest("POST", "/task/create", bytes.NewBuffer(jsonCreate)) + req.Header.Set("Authorization", "Bearer "+token) + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + + if res.Code == http.StatusUnauthorized { + t.Errorf("Token is expire or invalid.") + return + } + + if res.Code == http.StatusBadRequest { + t.Log("User limit task.") + return + } + + if res.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, res.Code) + return + } + + bodyRes, _ := ioutil.ReadAll(res.Body) + var createTaskResul models.Response[models.Task] + err := json.Unmarshal(bodyRes, &createTaskResul) + if err != nil { + t.Errorf("Error parse body response create task.") + return + } + + if createTaskResul.Status != 200 { + t.Errorf("Create task fail, message = " + createTaskResul.Message) + return + } + t.Log("Create task success") +} + +func TestGetListTask(t *testing.T) { + token := LoginGetToken().Token + //serverTest := SetupTestDatabase() + r := SetUpRouter() + + r.Use(serverTest.Validate) + r.GET("/task/list", serverTest.GetTaskByDate) + var createDateNow = time.Now().Format("2006-01-02") + req, _ := http.NewRequest("GET", "/task/list?createdDate="+createDateNow, nil) + req.Header.Set("Authorization", "Bearer "+token) + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + + if res.Code == http.StatusUnauthorized { + t.Errorf("Token is expire or invalid.") + return + } + + if res.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, res.Code) + return + } + + bodyRes, _ := ioutil.ReadAll(res.Body) + var createTaskResul models.Response[models.Task] + err := json.Unmarshal(bodyRes, &createTaskResul) + if err != nil { + t.Errorf("Error parse body response create task.") + return + } + + if createTaskResul.Status != 200 { + t.Errorf("Get list data fail, message" + createTaskResul.Message) + return + } + t.Log("Get list create success") +} + +func SetupTestDatabase() services.Server { + containerReq := testcontainers.ContainerRequest{ + Image: "postgres:latest", + ExposedPorts: []string{"5432/tcp"}, + WaitingFor: wait.ForListeningPort("5432/tcp"), + Env: map[string]string{ + "POSTGRES_DB": "testdb", + "POSTGRES_PASSWORD": "postgres", + "POSTGRES_USER": "postgres", + }, + } + + dbContainer, _ := testcontainers.GenericContainer( + context.Background(), + testcontainers.GenericContainerRequest{ + ContainerRequest: containerReq, + Started: true, + }) + + host, _ := dbContainer.Host(context.Background()) + port, _ := dbContainer.MappedPort(context.Background(), "5432") + + // 3.2 Create db connection string and connect + var url = fmt.Sprintf("postgres://postgres:postgres@%v:%v/testdb", host, port.Port()) + dbTest := db_client.Init(url) + dbTest.DB.AutoMigrate(&models.User{}) + dbTest.DB.AutoMigrate(&models.Task{}) + jwtTest := utils.JwtWrapper{ + SecretKey: "key-test", + Issuer: "manibie-todo-test", + ExpirationHours: 24 * 60, + } + + redisRequest := testcontainers.ContainerRequest{ + Image: "redis:latest", + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), + } + + redisServer, _ := testcontainers.GenericContainer(context.Background(), testcontainers.GenericContainerRequest{ + ContainerRequest: redisRequest, + Started: true, + }) + + hostRedis, _ := redisServer.Host(context.Background()) + + redisTest := redis.NewClient(&redis.Options{Addr: hostRedis + ":6379", DB: 0}) + + serverTest := services.Server{ + H: dbTest, + Jwt: jwtTest, + Redis: redisTest, + } + return serverTest +}