From 308e4f180c0820fe842659c17aa13c27e3ee5315 Mon Sep 17 00:00:00 2001 From: lawtrann Date: Sat, 2 Jul 2022 04:43:57 +0700 Subject: [PATCH 1/5] Submission for [Manabie] Technical Assignment for Backend Engineer _ Vinh Tran --- .env | 5 ++ README.md | 99 +++++++++++++++++++++----- cmd/webservice/main.go | 37 ++++++++++ go.mod | 7 ++ go.sum | 4 ++ http/handler.go | 79 +++++++++++++++++++++ http/helper.go | 16 +++++ http/mapping.go | 9 +++ mocks/togodb.go | 23 ++++++ postgres/tododb.go | 133 +++++++++++++++++++++++++++++++++++ queries.sql | 52 ++++++++++++++ services/todoservice.go | 50 +++++++++++++ services/todoservice_test.go | 81 +++++++++++++++++++++ togo.go | 31 ++++++++ 14 files changed, 607 insertions(+), 19 deletions(-) create mode 100644 .env create mode 100644 cmd/webservice/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 http/handler.go create mode 100644 http/helper.go create mode 100644 http/mapping.go create mode 100644 mocks/togodb.go create mode 100644 postgres/tododb.go create mode 100644 queries.sql create mode 100644 services/todoservice.go create mode 100644 services/todoservice_test.go create mode 100644 togo.go diff --git a/.env b/.env new file mode 100644 index 000000000..a5f79d7fd --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +POSTGRESQL_HOST=localhost +POSTGRESQL_PORT=5432 +POSTGRESQL_USERNAME=postgres +POSTGRESQL_PASSWORD=admin +POSTGRESQL_DATABASE=togos \ No newline at end of file diff --git a/README.md b/README.md index 8df9d4d3a..3d6bb4ef6 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,91 @@ ### 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 -- 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 +### Choose a suitable architecture to make your code simple, organizable, and maintainable + +├── cmd/ +│ └── webservice/ +│ | └── main.go # main func +├── http/ +│ ├── handler.go # Determine service based on url, http.Method +│ ├── helper.go # Utility functions for supporting http.Handler +│ ├── mapping.go # Register url for http.Handler +├── mocks/ +│ ├── togodb.go # mocks postgres package for testing purpose +├── postgres/ +│ ├── togodb.go # contain repository layer +├── services/ +│ ├── todoservice_test.go # for testing purpose +│ ├── todoservice.go # contain business layer +├── .env # configuration variables for connecting postgresql +├── .gitignore +├── go.mod +├── go.sum +├── LICENSE +├── queries.sql # Querie to create user, todos tables and retrieve data +├── README.md +├── togo.go # Domain types for defining all the objects in our domain and interface that we perform on those objects + +- Ben Johnson purposes 4 principles to structure our code. + 1. Root Package is for domain types + 2. Group subpackages by dependency + 3. Use a shared mock subpackage + 4. Main package ties together dependencies + +- Ref links: https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1 -- 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. +### Write a concise README +### How to run your code locally? -### How to submit your solution? +1. Download and Install PgAdmin4 tools (postgresql): https://www.postgresql.org/download/ +2. Open queries.sql file, then Excute from "--> START" to "--< END" to create "users", "todos" tables +3. Download/Update go modules + ```bash + go mod tidy + ``` +4. Start webservice + ```bash + go run cmd/webservice/main.go + ``` +5. Send request with curl or Postman + ```bash + curl -XPOST 'http://localhost:3000/api/lawtrann/todos' -H 'Content-Type: application/json' -d '{"description":"todo something"}' + ``` -- Fork this repo and show us your development progress via a PR +### A sample “curl” command to call your API + ```bash + curl -XPOST 'http://localhost:3000/api/lawtrann/todos' -H 'Content-Type: application/json' -d '{"description":"todo something"}' + ``` -### Interesting facts about Manabie +### How to run your unit tests locally? +- Unit Test + ```bash + go test -v ./services/ + ``` -- 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. +- Test cases + - Testcase#1: Create NewUser with Description:"Todo 1" + ```bash + curl -XPOST 'http://localhost:3000/api/newuser/todos' -H 'Content-Type: application/json' -d '{"description":"Todo 1"}' + ``` + - Testcase#2: add Description:"Todo 2" for NewUser on testcase#1 + ```bash + curl -XPOST 'http://localhost:3000/api/newuser/todos' -H 'Content-Type: application/json' -d '{"description":"Todo 2"}' + ``` + - Testcase#3: add bunch of Description:"Todo n" until getting "you have reached the limit of adding todo task per day" message + ```bash + curl -XPOST 'http://localhost:3000/api/newuser/todos' -H 'Content-Type: application/json' -d '{"description":"todo n"}' + ``` -Thank you for spending time to read and attempt our take-home assessment. We are looking forward to your submission. +### What do you love about your solution? +- Testability + - With such a pluggable system, we can test the functionality of each layer separately by injecting a mock version of the dependent layers +- Clear separation between layers + - In our domain package we can see an interface for each layer in our application. This helps us to have a clear boundary between each layer. + +### What else do you want us to know about however you do not have enough time to complete? +- I haven't dealt with the system's logging yet, nor have I used middleware in this project. +- Using route open source like Chi to optimize router. +- Using docker to run postgredb instead of creating manually through queries.sql file. diff --git a/cmd/webservice/main.go b/cmd/webservice/main.go new file mode 100644 index 000000000..2359b60c8 --- /dev/null +++ b/cmd/webservice/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/joho/godotenv" + "github.com/lawtrann/togo/http" + "github.com/lawtrann/togo/postgres" + "github.com/lawtrann/togo/services" +) + +func main() { + if err := run(); err != nil { + fmt.Println(fmt.Errorf("error - server failed to start. err: %v", err)) + } +} + +func run() error { + if _, err := os.Stat(".env"); err == nil { + err = godotenv.Load() + if err != nil { + log.Fatalf("Error loading .env file") + os.Exit(1) + } + } + + db, err := postgres.NewTodoDB() + if err != nil { + panic(err) + } + svc := services.NewTodoService(db) + h := http.NewHandler(svc) + http.RegisterService(h) + return http.ListenAndServe(":3000", nil) +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..c8d158b16 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/lawtrann/togo + +go 1.18 + +require github.com/lib/pq v1.10.6 + +require github.com/joho/godotenv v1.4.0 diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..1aed807e6 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs= +github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/http/handler.go b/http/handler.go new file mode 100644 index 000000000..2399ac2b7 --- /dev/null +++ b/http/handler.go @@ -0,0 +1,79 @@ +package http + +import ( + "encoding/json" + "net/http" + "regexp" + "strings" + + "github.com/lawtrann/togo" +) + +type Handler struct { + TodoService togo.TodoService + TodoPattern *regexp.Regexp +} + +func NewHandler(todoService togo.TodoService) *Handler { + return &Handler{ + TodoService: todoService, + TodoPattern: regexp.MustCompile(`^/api/(?P\w+)/todos/?$`), + } +} + +func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h.TodoPattern.MatchString(r.URL.Path) { + switch r.Method { + case http.MethodPost: + matches := h.TodoPattern.FindStringSubmatch(r.URL.Path) + result := make(map[string]string) + for i, name := range h.TodoPattern.SubexpNames() { + if i != 0 && name != "" { + result[name] = matches[i] + } + } + userName := strings.ToLower(result["user_name"]) + + h.CreateTodo(userName, w, r) + default: + w.WriteHeader(http.StatusNotFound) + } + } else { + w.WriteHeader(http.StatusNotFound) + } +} + +func (h *Handler) parserRequest(r *http.Request) (togo.Todo, error) { + dec := json.NewDecoder(r.Body) + var t togo.Todo + err := dec.Decode(&t) + if err != nil { + return togo.Todo{}, err + } + return t, nil +} + +func (h *Handler) CreateTodo(userName string, w http.ResponseWriter, r *http.Request) { + t, err := h.parserRequest(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Could not parse Todo object")) + return + } + + // Check if empty description + if len(t.Description) <= 0 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Empty Todo Description!")) + return + } + + res, err := h.TodoService.AddTodoByUser(userName, &t) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + encodeResponseAsJSON(res, w) +} diff --git a/http/helper.go b/http/helper.go new file mode 100644 index 000000000..1a9e2b1a4 --- /dev/null +++ b/http/helper.go @@ -0,0 +1,16 @@ +package http + +import ( + "encoding/json" + "io" + "net/http" +) + +func encodeResponseAsJSON(data interface{}, w io.Writer) { + enc := json.NewEncoder(w) + enc.Encode(data) +} + +func ListenAndServe(port string, handler http.Handler) error { + return http.ListenAndServe(port, handler) +} diff --git a/http/mapping.go b/http/mapping.go new file mode 100644 index 000000000..5200a2849 --- /dev/null +++ b/http/mapping.go @@ -0,0 +1,9 @@ +package http + +import ( + "net/http" +) + +func RegisterService(handler http.Handler) { + http.Handle("/api/", handler) +} diff --git a/mocks/togodb.go b/mocks/togodb.go new file mode 100644 index 000000000..abacfeb26 --- /dev/null +++ b/mocks/togodb.go @@ -0,0 +1,23 @@ +package mocks + +import "github.com/lawtrann/togo" + +type TodoDB struct { + GetUserByNameResp togo.User + GetUserByNameErr error + IsExceedPerDayResp bool + IsExceedPerDayErr error + AddTodoByUserErr error +} + +func (tb TodoDB) GetUserByName(userName string) (*togo.User, error) { + return &tb.GetUserByNameResp, tb.GetUserByNameErr +} + +func (tb TodoDB) IsExceedPerDay(u togo.User) (bool, error) { + return tb.IsExceedPerDayResp, tb.IsExceedPerDayErr +} + +func (tb TodoDB) AddTodoByUser(u *togo.User, t *togo.Todo, uFlag bool) error { + return tb.AddTodoByUserErr +} diff --git a/postgres/tododb.go b/postgres/tododb.go new file mode 100644 index 000000000..080736bb7 --- /dev/null +++ b/postgres/tododb.go @@ -0,0 +1,133 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "strconv" + + "github.com/lawtrann/togo" + _ "github.com/lib/pq" +) + +type TodoDB struct { + DB *sql.DB +} + +func GetDns() string { + host := os.Getenv("POSTGRESQL_HOST") + port, err := strconv.Atoi(os.Getenv("POSTGRESQL_PORT")) + if err != nil { + log.Fatalf("Postgres port %s is not valid", os.Getenv("POSTGRESQL_PORT")) + os.Exit(1) + } + user := os.Getenv("POSTGRESQL_USERNAME") + password := os.Getenv("POSTGRESQL_PASSWORD") + dbname := os.Getenv("POSTGRESQL_DATABASE") + + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname) +} + +func NewTodoDB() (togo.TodoDB, error) { + db, err := sql.Open("postgres", GetDns()) + if err != nil { + panic(err) + } + + return &TodoDB{ + DB: db, + }, nil +} + +// Get user by username +func (td *TodoDB) GetUserByName(userName string) (*togo.User, error) { + var result togo.User + + // Query for a value based on a single row. + err := td.DB.QueryRow("SELECT id, user_name, limited_per_day FROM users WHERE user_name = $1", + userName).Scan(&result.ID, &result.UserName, &result.LimitedPerDay) + + if err != nil && err != sql.ErrNoRows { + return &togo.User{}, err + } + + return &result, nil +} + +// Check if exceed a limited per day +func (td *TodoDB) IsExceedPerDay(u togo.User) (bool, error) { + var result bool + + // Query for a value based on a single row. + err := td.DB.QueryRow("SELECT (COUNT(*) >= $1) is_exceed FROM todos WHERE user_id = $2 and create_date >= NOW()::date", + u.LimitedPerDay, u.ID).Scan(&result) + if err != nil && err != sql.ErrNoRows { + return false, err + } + + return result, nil +} + +// Adding new todo task to user if not exceed a limited per day +func (td *TodoDB) AddTodoByUser(u *togo.User, t *togo.Todo, uFlag bool) error { + + // Begin + ctx := context.Background() + tx, err := td.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + + // Create user if not exist + if !uFlag { + stmt := `INSERT INTO users( + user_name, + limited_per_day, + create_date, + create_user) + VALUES( + $1, + $2, + NOW(), + 'admin') + RETURNING id` + + // res, err := tx.ExecContext(ctx, stmt, &u.UserName, limited) + err = tx.QueryRowContext(ctx, stmt, &u.UserName, &u.LimitedPerDay).Scan(&t.UserID) + if err != nil && err != sql.ErrNoRows { + tx.Rollback() + return err + } + } + + stmt := `INSERT INTO todos( + todo_id, + user_id, + description, + create_date, + create_user) + VALUES( + (SELECT (COUNT(*) + 1) FROM todos WHERE user_id = $1), + $1, + $2, + NOW(), + 'admin') + RETURNING todo_id` + + err = tx.QueryRowContext(ctx, stmt, t.UserID, t.Description).Scan(&t.TodoID) + if err != nil && err != sql.ErrNoRows { + tx.Rollback() + return err + } + + // Commit + err = tx.Commit() + if err != nil { + return err + } + + return nil +} diff --git a/queries.sql b/queries.sql new file mode 100644 index 000000000..1f9334af4 --- /dev/null +++ b/queries.sql @@ -0,0 +1,52 @@ + +-- Create User, Todos tables -- +--> START +DROP TABLE IF EXISTS todos; +DROP TABLE IF EXISTS users; + +CREATE TABLE users ( + id serial, + user_name VARCHAR(25) NOT NULL, + limited_per_day int NOT NULL, + create_date timestamp NOT NULL, + create_user VARCHAR(25) NOT NULL, + PRIMARY KEY (id) +); + +/* + one to many: User has many Todos +*/ + +DROP TABLE IF EXISTS todos; +CREATE TABLE todos ( + todo_id int NOT NULL, + user_id int NOT NULL, + description VARCHAR(100), + create_date timestamp NOT NULL, + create_user VARCHAR(25) NOT NULL, + PRIMARY KEY (todo_id, user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +--< END + +---------------------------------------------------------- + +select * from users; +select * from todos; + +-- Retrieve inserted todos +SELECT u.id, u.user_name, u.limited_per_day, STRING_AGG (t.description, ', '), t.create_date::date, NOW()::date as today + FROM users u + ,todos t + WHERE 1=1 + AND u.id = t.user_id +GROUP BY u.id, u.user_name, u.limited_per_day, t.create_date::date +ORDER BY u.id +; + +-- Check IsExceed +SELECT (COUNT(*) >= 7) is_exceed + FROM todos + WHERE 1=1 + AND user_id = 2 + AND create_date >= NOW()::date \ No newline at end of file diff --git a/services/todoservice.go b/services/todoservice.go new file mode 100644 index 000000000..60dca72fe --- /dev/null +++ b/services/todoservice.go @@ -0,0 +1,50 @@ +package services + +import ( + "errors" + "math/rand" + + "github.com/lawtrann/togo" +) + +type TodoService struct { + DB togo.TodoDB +} + +func NewTodoService(db togo.TodoDB) togo.TodoService { + return &TodoService{ + DB: db, + } +} + +// Adding new todo task to user if not exceed a limited per day +func (ts *TodoService) AddTodoByUser(userName string, t *togo.Todo) (*togo.Todo, error) { + + // uFlag for checking existed user, isExceed for checking limited per day + uFlag := false + + // Get user by name + u, err := ts.DB.GetUserByName(userName) + if err != nil { + return &togo.Todo{}, errors.New(err.Error()) + } + + if (togo.User{}) != *u { + t.UserID = u.ID + // flag existed user + uFlag = true + // checkif exceed a limited per day + isExceedPerDay, err := ts.DB.IsExceedPerDay(*u) + if isExceedPerDay || err != nil { + return &togo.Todo{}, errors.New("you have reached the limit of adding todo task per day") + } + } else { + u.UserName = userName + u.LimitedPerDay = rand.Intn(9) + 1 + } + + // Add todo with transaction + ts.DB.AddTodoByUser(u, t, uFlag) + + return t, nil +} diff --git a/services/todoservice_test.go b/services/todoservice_test.go new file mode 100644 index 000000000..65507a607 --- /dev/null +++ b/services/todoservice_test.go @@ -0,0 +1,81 @@ +package services + +import ( + "testing" + + "github.com/lawtrann/togo" + "github.com/lawtrann/togo/mocks" +) + +func TestAddTodoByUser_WithNewUser(t *testing.T) { + // Create DB mocks + db := mocks.TodoDB{} + + // New User + db.GetUserByNameResp = togo.User{} + db.GetUserByNameErr = nil + // Add todo with transaction + db.AddTodoByUserErr = nil + + // mocks DB to TodoService + svc := NewTodoService(db) + + // Input data + username := "test1" + todo := &togo.Todo{ + TodoID: 1, + UserID: 1, + Description: "Description 1", + } + // Got result + got, err := svc.AddTodoByUser(username, todo) + // Expected result + expected := togo.Todo{ + TodoID: 1, + UserID: 1, + Description: "Description 1", + } + + if *got != expected && err != nil { + t.Log(*got) + t.Error("Error while inserted new Todo!") + } +} + +func TestAddTodoByUser_WithExistedUser_Exceed(t *testing.T) { + // Create DB mocks + db := mocks.TodoDB{} + + // Existed User + db.GetUserByNameResp = togo.User{ + ID: 1, + UserName: "test1", + LimitedPerDay: 3, + } + db.GetUserByNameErr = nil + // IsExceed = true + db.IsExceedPerDayResp = true // check point + db.IsExceedPerDayErr = nil + // Add todo with transaction + db.AddTodoByUserErr = nil + + // mocks DB to TodoService + svc := NewTodoService(db) + + // Input data + username := "test1" + todo := &togo.Todo{ + TodoID: 1, + UserID: 1, + Description: "Description 1", + } + // Got result + got, err := svc.AddTodoByUser(username, todo) + // Expected result + expected := togo.Todo{} + + if *got != expected && err != nil { + t.Log(*got, expected) + t.Error("The number of tasks exceeds a limited per day") + } +} diff --git a/togo.go b/togo.go new file mode 100644 index 000000000..f6e67989d --- /dev/null +++ b/togo.go @@ -0,0 +1,31 @@ +package togo + +// User struct represents user fields +type User struct { + ID int `json:"id" db:"id"` + UserName string `json:"user_name" db:"user_name"` + LimitedPerDay int `json:"limited_per_day" db:"limited_per_day"` +} + +// Todo struct represents todo fields +type Todo struct { + TodoID int `json:"todo_id" db:"todo_id"` + UserID int `json:"user_id" db:"user_id"` + Description string `json:"description" db:"description"` +} + +// TodoService interface uses for handling Business +type TodoService interface { + // Adding new todo task to user if not exceed a limited per day + AddTodoByUser(userName string, t *Todo) (*Todo, error) +} + +// TodoDB interface uses for handling Todo Repository +type TodoDB interface { + // Get user by username + GetUserByName(userName string) (*User, error) + // Check if exceed a limited per day + IsExceedPerDay(u User) (bool, error) + // Adding new todo task to user if not exceed a limited per day + AddTodoByUser(u *User, t *Todo, uFlag bool) error +} From 8d96678d0e22d9e2d3839b37579d21c287aacdc2 Mon Sep 17 00:00:00 2001 From: lawtrann Date: Sat, 2 Jul 2022 04:50:05 +0700 Subject: [PATCH 2/5] Update README.md --- README.md | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/README.md b/README.md index 3d6bb4ef6..20b3d79a1 100644 --- a/README.md +++ b/README.md @@ -5,29 +5,6 @@ ### Choose a suitable architecture to make your code simple, organizable, and maintainable -├── cmd/ -│ └── webservice/ -│ | └── main.go # main func -├── http/ -│ ├── handler.go # Determine service based on url, http.Method -│ ├── helper.go # Utility functions for supporting http.Handler -│ ├── mapping.go # Register url for http.Handler -├── mocks/ -│ ├── togodb.go # mocks postgres package for testing purpose -├── postgres/ -│ ├── togodb.go # contain repository layer -├── services/ -│ ├── todoservice_test.go # for testing purpose -│ ├── todoservice.go # contain business layer -├── .env # configuration variables for connecting postgresql -├── .gitignore -├── go.mod -├── go.sum -├── LICENSE -├── queries.sql # Querie to create user, todos tables and retrieve data -├── README.md -├── togo.go # Domain types for defining all the objects in our domain and interface that we perform on those objects - - Ben Johnson purposes 4 principles to structure our code. 1. Root Package is for domain types 2. Group subpackages by dependency From 11363afa6e67f7e5e157966bfc40fda5238733c5 Mon Sep 17 00:00:00 2001 From: lawtrann Date: Sat, 2 Jul 2022 04:51:19 +0700 Subject: [PATCH 3/5] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 20b3d79a1..d85b3cfc8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ - Ref links: https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1 -### Write a concise README ### How to run your code locally? 1. Download and Install PgAdmin4 tools (postgresql): https://www.postgresql.org/download/ From dfe7b1d8e512a7a637316581c2333b55811b782c Mon Sep 17 00:00:00 2001 From: lawtrann Date: Sat, 2 Jul 2022 05:07:42 +0700 Subject: [PATCH 4/5] Update main.go --- cmd/webservice/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/webservice/main.go b/cmd/webservice/main.go index 2359b60c8..3412b0e6f 100644 --- a/cmd/webservice/main.go +++ b/cmd/webservice/main.go @@ -33,5 +33,6 @@ func run() error { svc := services.NewTodoService(db) h := http.NewHandler(svc) http.RegisterService(h) + fmt.Println("Starting webservice... on localhost:3000") return http.ListenAndServe(":3000", nil) } From 97e47a63a81fd17f5d865a5f5a3ce9ae6aa7264d Mon Sep 17 00:00:00 2001 From: lawtrann Date: Sat, 9 Jul 2022 16:37:55 +0700 Subject: [PATCH 5/5] Update more complete source code --- Makefile | 2 + README.md | 31 +-- cmd/webservice/main.go | 77 +++++- go.mod | 8 +- go.sum | 2 + http/handler.go | 79 ------ http/helper.go | 16 -- http/mapping.go | 9 - http/server.go | 96 +++++++ http/todo.go | 97 +++++++ http/todo_test.go | 234 +++++++++++++++++ mocks/todoRepo.go | 20 ++ mocks/todoservice.go | 15 ++ mocks/togodb.go | 23 -- mocks/userRepo.go | 20 ++ postgres/integration/TodoRepoAddNewUser.sql | 11 + postgres/integration/TodoRepoAddTodo.sql | 13 + .../integration/UserRepoGetUserByName.sql | 4 + .../UserRepoIsExceedLimitedPerDay.sql | 8 + postgres/postgres.go | 96 +++++++ postgres/todo.go | 71 ++++++ postgres/tododb.go | 133 ---------- postgres/user.go | 47 ++++ queries.sql | 21 +- service/todo.go | 55 ++++ service/todo_test.go | 241 ++++++++++++++++++ service/user.go | 34 +++ service/user_test.go | 154 +++++++++++ services/todoservice.go | 50 ---- services/todoservice_test.go | 81 ------ togo.go | 102 ++++++-- 31 files changed, 1388 insertions(+), 462 deletions(-) create mode 100644 Makefile delete mode 100644 http/handler.go delete mode 100644 http/helper.go delete mode 100644 http/mapping.go create mode 100644 http/server.go create mode 100644 http/todo.go create mode 100644 http/todo_test.go create mode 100644 mocks/todoRepo.go create mode 100644 mocks/todoservice.go delete mode 100644 mocks/togodb.go create mode 100644 mocks/userRepo.go create mode 100644 postgres/integration/TodoRepoAddNewUser.sql create mode 100644 postgres/integration/TodoRepoAddTodo.sql create mode 100644 postgres/integration/UserRepoGetUserByName.sql create mode 100644 postgres/integration/UserRepoIsExceedLimitedPerDay.sql create mode 100644 postgres/postgres.go create mode 100644 postgres/todo.go delete mode 100644 postgres/tododb.go create mode 100644 postgres/user.go create mode 100644 service/todo.go create mode 100644 service/todo_test.go create mode 100644 service/user.go create mode 100644 service/user_test.go delete mode 100644 services/todoservice.go delete mode 100644 services/todoservice_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..50e18a15f --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +run: + nodemon --exec "go run" cmd/webservice/main.go \ No newline at end of file diff --git a/README.md b/README.md index d85b3cfc8..ab7d6eebd 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ ### Requirements -- Implement one single API which accepts a todo task and records it + - 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. ### Choose a suitable architecture to make your code simple, organizable, and maintainable -- Ben Johnson purposes 4 principles to structure our code. +## Ben Johnson purposes 4 principles to structure our code. + 1. Root Package is for domain types 2. Group subpackages by dependency 3. Use a shared mock subpackage @@ -13,29 +14,29 @@ - Ref links: https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1 -### How to run your code locally? +## How to run your code locally? -1. Download and Install PgAdmin4 tools (postgresql): https://www.postgresql.org/download/ -2. Open queries.sql file, then Excute from "--> START" to "--< END" to create "users", "todos" tables -3. Download/Update go modules + 1. Download and Install PgAdmin4 tools (postgresql): https://www.postgresql.org/download/ + 2. Open queries.sql file, then Excute from "--> START" to "--< END" to create "users", "todos" tables + 3. Download/Update go modules ```bash go mod tidy ``` -4. Start webservice + 4. Start webservice ```bash go run cmd/webservice/main.go ``` -5. Send request with curl or Postman + 5. Send request with curl or Postman ```bash curl -XPOST 'http://localhost:3000/api/lawtrann/todos' -H 'Content-Type: application/json' -d '{"description":"todo something"}' ``` -### A sample “curl” command to call your API - ```bash - curl -XPOST 'http://localhost:3000/api/lawtrann/todos' -H 'Content-Type: application/json' -d '{"description":"todo something"}' - ``` +## A sample “curl” command to call your API + ```bash + curl -XPOST 'http://localhost:3000/api/lawtrann/todos' -H 'Content-Type: application/json' -d '{"description":"todo something"}' + ``` -### How to run your unit tests locally? +## How to run your unit tests locally? - Unit Test ```bash go test -v ./services/ @@ -55,13 +56,13 @@ curl -XPOST 'http://localhost:3000/api/newuser/todos' -H 'Content-Type: application/json' -d '{"description":"todo n"}' ``` -### What do you love about your solution? +## What do you love about your solution? - Testability - With such a pluggable system, we can test the functionality of each layer separately by injecting a mock version of the dependent layers - Clear separation between layers - In our domain package we can see an interface for each layer in our application. This helps us to have a clear boundary between each layer. -### What else do you want us to know about however you do not have enough time to complete? +## What else do you want us to know about however you do not have enough time to complete? - I haven't dealt with the system's logging yet, nor have I used middleware in this project. - Using route open source like Chi to optimize router. - Using docker to run postgredb instead of creating manually through queries.sql file. diff --git a/cmd/webservice/main.go b/cmd/webservice/main.go index 3412b0e6f..9a6b4d0ec 100644 --- a/cmd/webservice/main.go +++ b/cmd/webservice/main.go @@ -4,20 +4,16 @@ import ( "fmt" "log" "os" + "strconv" "github.com/joho/godotenv" "github.com/lawtrann/togo/http" "github.com/lawtrann/togo/postgres" - "github.com/lawtrann/togo/services" + "github.com/lawtrann/togo/service" ) func main() { - if err := run(); err != nil { - fmt.Println(fmt.Errorf("error - server failed to start. err: %v", err)) - } -} - -func run() error { + // Get enviroment variables if _, err := os.Stat(".env"); err == nil { err = godotenv.Load() if err != nil { @@ -26,13 +22,66 @@ func run() error { } } - db, err := postgres.NewTodoDB() - if err != nil { + // Start Main + m := NewMain() + + // Open database + m.DB.DSN = GetDns() + if err := m.DB.Open(); err != nil { + fmt.Println(err) panic(err) } - svc := services.NewTodoService(db) - h := http.NewHandler(svc) - http.RegisterService(h) - fmt.Println("Starting webservice... on localhost:3000") - return http.ListenAndServe(":3000", nil) + + // Instantiate repository. + // UserRepository + userRepo := postgres.NewUserRepo(m.DB) + // UserRepository + todoRepo := postgres.NewTodoRepo(m.DB) + + // Instantiate business services. + // UserService + userService := service.NewUserService(userRepo) + // TodoService + todoService := service.NewTodoService(todoRepo) + todoService.UserService = userService + + // Attach underlying services to the HTTP server. + m.HTTPServer.TodoService = todoService + + // Start the HTTP server. + fmt.Println("Running on ... localhost:3000") + m.HTTPServer.ListenAndServe(":3000") +} + +// Main represents the program. +type Main struct { + // Postgres database used by Postgres service implementations. + DB *postgres.DB + + // HTTP server for handling HTTP communication. + // Postgres services are attached to it before running. + HTTPServer *http.Server +} + +// NewMain returns a new instance of Main. +func NewMain() *Main { + return &Main{ + DB: postgres.NewDB(""), + HTTPServer: http.NewServer(), + } +} + +func GetDns() string { + host := os.Getenv("POSTGRESQL_HOST") + port, err := strconv.Atoi(os.Getenv("POSTGRESQL_PORT")) + if err != nil { + log.Fatalf("Postgres port %s is not valid", os.Getenv("POSTGRESQL_PORT")) + os.Exit(1) + } + user := os.Getenv("POSTGRESQL_USERNAME") + password := os.Getenv("POSTGRESQL_PASSWORD") + dbname := os.Getenv("POSTGRESQL_DATABASE") + + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname) } diff --git a/go.mod b/go.mod index c8d158b16..e427d7712 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/lawtrann/togo go 1.18 -require github.com/lib/pq v1.10.6 - -require github.com/joho/godotenv v1.4.0 +require ( + github.com/go-chi/chi v1.5.4 + github.com/joho/godotenv v1.4.0 + github.com/lib/pq v1.10.6 +) diff --git a/go.sum b/go.sum index 1aed807e6..4ddaf82e5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs= diff --git a/http/handler.go b/http/handler.go deleted file mode 100644 index 2399ac2b7..000000000 --- a/http/handler.go +++ /dev/null @@ -1,79 +0,0 @@ -package http - -import ( - "encoding/json" - "net/http" - "regexp" - "strings" - - "github.com/lawtrann/togo" -) - -type Handler struct { - TodoService togo.TodoService - TodoPattern *regexp.Regexp -} - -func NewHandler(todoService togo.TodoService) *Handler { - return &Handler{ - TodoService: todoService, - TodoPattern: regexp.MustCompile(`^/api/(?P\w+)/todos/?$`), - } -} - -func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if h.TodoPattern.MatchString(r.URL.Path) { - switch r.Method { - case http.MethodPost: - matches := h.TodoPattern.FindStringSubmatch(r.URL.Path) - result := make(map[string]string) - for i, name := range h.TodoPattern.SubexpNames() { - if i != 0 && name != "" { - result[name] = matches[i] - } - } - userName := strings.ToLower(result["user_name"]) - - h.CreateTodo(userName, w, r) - default: - w.WriteHeader(http.StatusNotFound) - } - } else { - w.WriteHeader(http.StatusNotFound) - } -} - -func (h *Handler) parserRequest(r *http.Request) (togo.Todo, error) { - dec := json.NewDecoder(r.Body) - var t togo.Todo - err := dec.Decode(&t) - if err != nil { - return togo.Todo{}, err - } - return t, nil -} - -func (h *Handler) CreateTodo(userName string, w http.ResponseWriter, r *http.Request) { - t, err := h.parserRequest(r) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Could not parse Todo object")) - return - } - - // Check if empty description - if len(t.Description) <= 0 { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Empty Todo Description!")) - return - } - - res, err := h.TodoService.AddTodoByUser(userName, &t) - - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - return - } - encodeResponseAsJSON(res, w) -} diff --git a/http/helper.go b/http/helper.go deleted file mode 100644 index 1a9e2b1a4..000000000 --- a/http/helper.go +++ /dev/null @@ -1,16 +0,0 @@ -package http - -import ( - "encoding/json" - "io" - "net/http" -) - -func encodeResponseAsJSON(data interface{}, w io.Writer) { - enc := json.NewEncoder(w) - enc.Encode(data) -} - -func ListenAndServe(port string, handler http.Handler) error { - return http.ListenAndServe(port, handler) -} diff --git a/http/mapping.go b/http/mapping.go deleted file mode 100644 index 5200a2849..000000000 --- a/http/mapping.go +++ /dev/null @@ -1,9 +0,0 @@ -package http - -import ( - "net/http" -) - -func RegisterService(handler http.Handler) { - http.Handle("/api/", handler) -} diff --git a/http/server.go b/http/server.go new file mode 100644 index 000000000..743d3fde4 --- /dev/null +++ b/http/server.go @@ -0,0 +1,96 @@ +package http + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/lawtrann/togo" +) + +// Server represents an HTTP server. It is meant to wrap all HTTP functionality used by the application +// so that dependent packages (such as cmd/webservice) don't need to reference the "net/http" package at all. +type Server struct { + // A Server defines parameters for running an HTTP server. + Server *http.Server + // Mux is a simple HTTP route multiplexer that parses a request path, records any URL params, and executes an end handler + Router *chi.Mux + + // Servics used by the various HTTP routes. + TodoService togo.TodoService +} + +func NewServer() *Server { + // Create a new server that wraps the net/http server & add a chi router. + s := &Server{ + Server: &http.Server{}, + Router: chi.NewRouter(), + } + + // RequestID is a middleware that injects a request ID into the context of each request. + s.Router.Use(middleware.RequestID) + // Logger is a middleware that logs the start and end of each request. + s.Router.Use(middleware.Logger) + // Recoverer is a middleware that recovers from panics, logs the panic, and returns a HTTP 500 status if possible. + s.Router.Use(middleware.Recoverer) + // Stop processing after 2.5 seconds. + s.Router.Use(middleware.Timeout(2500 * time.Millisecond)) + // CleanPath middleware will clean out double slash mistakes from a user's request path. + s.Router.Use(middleware.CleanPath) + // Allowed content type. + s.Router.Use(middleware.AllowContentType("application/json")) + + // Setup error handling routes. + s.Router.NotFound(s.HandleNotFound) + // Setup method not allowed. + s.Router.MethodNotAllowed(http.HandlerFunc(s.HandleMethodNotAllowed)) + + // Register api routes. + s.Router.Mount("/api", s.RegisterTodoRoutes()) + + return s +} + +// Handles requests to routes that don't exist. +func (s *Server) HandleNotFound(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + // Encode response as JSON + resj := togo.TemplateResponse{ + Status: http.StatusNotFound, + Message: togo.ErrPageNotFound.Error(), + } + err := encodeResponseAsJSON(resj, w) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + } +} + +// Handles requests to routes that method is not allowed. +func (s *Server) HandleMethodNotAllowed(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + // Encode response as JSON + resj := togo.TemplateResponse{ + Status: http.StatusMethodNotAllowed, + Message: togo.ErrHTTPMethodNotAllowed.Error(), + } + err := encodeResponseAsJSON(resj, w) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + } +} + +func (s *Server) ListenAndServe(port string) error { + return http.ListenAndServe(port, s.Router) +} + +// Utility functions +func encodeResponseAsJSON(data interface{}, w io.Writer) error { + return json.NewEncoder(w).Encode(data) +} diff --git a/http/todo.go b/http/todo.go new file mode 100644 index 000000000..492eec595 --- /dev/null +++ b/http/todo.go @@ -0,0 +1,97 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/go-chi/chi" + "github.com/lawtrann/togo" +) + +// RegisterTodoRoutes is a helper function for registering all todo routes. +func (s *Server) RegisterTodoRoutes() *chi.Mux { + r := chi.NewRouter() + + // API endpoint for creating user if not existed + r.Route("/{username}", func(r chi.Router) { + r.Use(s.UserCtx) + // API endpoint for creating todo. + r.Post("/todos", s.HandlerTodoAdd) + }) + + return r +} + +// This struct represent a key/value for username path parameter +type userName struct{} + +func (s *Server) UserCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username := chi.URLParam(r, "username") + // attach username to context + ctx := context.WithValue(r.Context(), userName{}, username) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (s *Server) HandlerTodoAdd(w http.ResponseWriter, r *http.Request) { + // Parse + var todo *togo.Todo + if err := json.NewDecoder(r.Body).Decode(&todo); err != nil || todo.Description == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + // Encode response as JSON + resj := togo.TemplateResponse{ + Status: http.StatusUnprocessableEntity, + Message: togo.ErrCouldNotParseObject.Error(), + } + err := encodeResponseAsJSON(resj, w) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + } + return + } + + userName, ok := r.Context().Value(userName{}).(string) + if !ok { + w.WriteHeader(http.StatusUnprocessableEntity) + w.Write([]byte("Could not parse username")) + return + } + + // Handle with TodoService + res, err := s.TodoService.Add(r.Context(), todo, userName) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Encode response as JSON + resj := togo.TemplateResponse{ + Status: http.StatusOK, + Message: err.Error(), + } + err = encodeResponseAsJSON(resj, w) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + return + } + + // Render output to the client based on JSON + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + resj := togo.TemplateResponse{ + Status: http.StatusCreated, + Message: togo.ErrSuccessAddingNewTodo.Error(), + Data: *res, + } + err = encodeResponseAsJSON(resj, w) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } +} diff --git a/http/todo_test.go b/http/todo_test.go new file mode 100644 index 000000000..e576acc2f --- /dev/null +++ b/http/todo_test.go @@ -0,0 +1,234 @@ +package http + +import ( + "context" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/lawtrann/togo" + "github.com/lawtrann/togo/mocks" +) + +func TestHandlerTodoAdd_NewUser_NewTodo(t *testing.T) { + // Mock TodoService + svc := mocks.TodoService{} + svc.AddFn = func(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) { + return &togo.Todo{ID: 1, Description: "Todo something"}, nil + } + // New Test Server + s := NewServer() + s.TodoService = &svc + + // New request + r := httptest.NewRequest(http.MethodPost, "/api/lawtrann/todos", strings.NewReader(`{"description":"Todo something"}`)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // s.HandlerTodoAdd(w, r) + s.Router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + + got := strings.TrimRight(string(data), "\n") + expected := `{"Status":201,"Message":"Successfully adding new todo task","Data":{"id":1,"description":"Todo something"}}` + + if got != expected { + t.Log(got, err, expected) + t.Error("Error while running TestHandlerTodoAdd_NewUser_NewTodo") + } +} + +func TestHandlerTodoAdd_ExistedUser_NewTodo(t *testing.T) { + // Mock TodoService + svc := mocks.TodoService{} + svc.AddFn = func(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) { + return &togo.Todo{ID: 2, Description: "Todo something"}, nil + } + // New Test Server + s := NewServer() + s.TodoService = &svc + + // New request + r := httptest.NewRequest(http.MethodPost, "/api/lawtrann/todos", strings.NewReader(`{"description":"Todo something"}`)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // s.HandlerTodoAdd(w, r) + s.Router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + + got := strings.TrimRight(string(data), "\n") + expected := `{"Status":201,"Message":"Successfully adding new todo task","Data":{"id":2,"description":"Todo something"}}` + + if got != expected { + t.Log(got, err, expected) + t.Error("Error while running TestHandlerTodoAdd_ExistedUser_NewTodo") + } +} + +func TestHandlerTodoAdd_ExistedUser_IsExceed(t *testing.T) { + // Mock TodoService + svc := mocks.TodoService{} + svc.AddFn = func(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) { + return &togo.Todo{}, togo.ErrIsExceedLimitedPerDay + } + // New Test Server + s := NewServer() + s.TodoService = &svc + + // New request + r := httptest.NewRequest(http.MethodPost, "/api/lawtrann/todos", strings.NewReader(`{"description":"Todo something"}`)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // s.HandlerTodoAdd(w, r) + s.Router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + + got := strings.TrimRight(string(data), "\n") + expected := `{"Status":200,"Message":"You have reached the limit of adding todo task per day","Data":{"id":0,"description":""}}` + + if got != expected { + t.Log(got, err, expected) + t.Error("Error while running TestHandlerTodoAdd_ExistedUser_IsExceed") + } +} + +func TestHandlerTodoAdd_PageNotFound(t *testing.T) { + // Mock TodoService + svc := mocks.TodoService{} + svc.AddFn = func(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) { + return &togo.Todo{ID: 1, Description: "Todo something"}, nil + } + // New Test Server + s := NewServer() + s.TodoService = &svc + + // New request + r := httptest.NewRequest(http.MethodPost, "/api/lawtrann/todosss", strings.NewReader(`{"description":"Todo something"}`)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Set username to context + ctx := context.WithValue(r.Context(), userName{}, "lawtrann") + r = r.WithContext(ctx) + + // s.HandleNotFound(w, r) + s.Router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + + got := strings.TrimRight(string(data), "\n") + expected := `{"Status":404,"Message":"Your page cannot be found","Data":{"id":0,"description":""}}` + + if got != expected { + t.Log(got, err, expected) + t.Error("Error while running TestHandlerTodoAdd_PageNotFound") + } +} + +func TestHandlerTodoAdd_HTTPMethodNotAllowed(t *testing.T) { + // Mock TodoService + svc := mocks.TodoService{} + svc.AddFn = func(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) { + return &togo.Todo{ID: 1, Description: "Todo something"}, nil + } + // New Test Server + s := NewServer() + s.TodoService = &svc + + // New request + r := httptest.NewRequest(http.MethodGet, "/api/lawtrann/todos", strings.NewReader(`{"description":"Todo something"}`)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // s.HandlerTodoAdd(w, r) + s.Router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + + got := strings.TrimRight(string(data), "\n") + expected := `{"Status":405,"Message":"HTTP Method is not allowed","Data":{"id":0,"description":""}}` + + if got != expected { + t.Log(got, err, expected) + t.Error("Error while running TestHandlerTodoAdd_HTTPMethodNotAllowed") + } +} + +func TestHandlerTodoAdd_InvalidBody(t *testing.T) { + // Mock TodoService + svc := mocks.TodoService{} + svc.AddFn = func(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) { + return &togo.Todo{ID: 1, Description: "Todo something"}, nil + } + // New Test Server + s := NewServer() + s.TodoService = &svc + + // New request + r := httptest.NewRequest(http.MethodPost, "/api/lawtrann/todos", strings.NewReader(`{"test":""}`)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // s.HandlerTodoAdd(w, r) + s.Router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + + got := strings.TrimRight(string(data), "\n") + expected := `{"Status":422,"Message":"Could not parse Todo object","Data":{"id":0,"description":""}}` + + if got != expected { + t.Log(got, err, expected) + t.Error("Error while running TestHandlerTodoAdd_InvalidBody") + } +} + +func TestHandlerTodoAdd_EmptyDescription(t *testing.T) { + // Mock TodoService + svc := mocks.TodoService{} + svc.AddFn = func(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) { + return &togo.Todo{ID: 1, Description: "Todo something"}, nil + } + // New Test Server + s := NewServer() + s.TodoService = &svc + + // New request + r := httptest.NewRequest(http.MethodPost, "/api/lawtrann/todos", strings.NewReader(`{"description":""}`)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // s.HandlerTodoAdd(w, r) + s.Router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + + got := strings.TrimRight(string(data), "\n") + expected := `{"Status":422,"Message":"Could not parse Todo object","Data":{"id":0,"description":""}}` + + if got != expected { + t.Log(got, err, expected) + t.Error("Error while running TestHandlerTodoAdd_EmptyDescription") + } +} diff --git a/mocks/todoRepo.go b/mocks/todoRepo.go new file mode 100644 index 000000000..c7136fe3f --- /dev/null +++ b/mocks/todoRepo.go @@ -0,0 +1,20 @@ +package mocks + +import ( + "context" + + "github.com/lawtrann/togo" +) + +type TodoRepo struct { + AddFn func(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) + AddWithNewUserFn func(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) +} + +func (tp *TodoRepo) Add(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + return tp.AddFn(ctx, t, u) +} + +func (tp *TodoRepo) AddWithNewUser(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + return tp.AddWithNewUserFn(ctx, t, u) +} diff --git a/mocks/todoservice.go b/mocks/todoservice.go new file mode 100644 index 000000000..ed5b82bf4 --- /dev/null +++ b/mocks/todoservice.go @@ -0,0 +1,15 @@ +package mocks + +import ( + "context" + + "github.com/lawtrann/togo" +) + +type TodoService struct { + AddFn func(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) +} + +func (ts *TodoService) Add(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) { + return ts.AddFn(ctx, t, username) +} diff --git a/mocks/togodb.go b/mocks/togodb.go deleted file mode 100644 index abacfeb26..000000000 --- a/mocks/togodb.go +++ /dev/null @@ -1,23 +0,0 @@ -package mocks - -import "github.com/lawtrann/togo" - -type TodoDB struct { - GetUserByNameResp togo.User - GetUserByNameErr error - IsExceedPerDayResp bool - IsExceedPerDayErr error - AddTodoByUserErr error -} - -func (tb TodoDB) GetUserByName(userName string) (*togo.User, error) { - return &tb.GetUserByNameResp, tb.GetUserByNameErr -} - -func (tb TodoDB) IsExceedPerDay(u togo.User) (bool, error) { - return tb.IsExceedPerDayResp, tb.IsExceedPerDayErr -} - -func (tb TodoDB) AddTodoByUser(u *togo.User, t *togo.Todo, uFlag bool) error { - return tb.AddTodoByUserErr -} diff --git a/mocks/userRepo.go b/mocks/userRepo.go new file mode 100644 index 000000000..50bac04a5 --- /dev/null +++ b/mocks/userRepo.go @@ -0,0 +1,20 @@ +package mocks + +import ( + "context" + + "github.com/lawtrann/togo" +) + +type UserRepo struct { + GetUserByNameFn func(ctx context.Context, username string) (*togo.User, error) + IsExceedPerDayFn func(ctx context.Context, u *togo.User) (bool, error) +} + +func (ur *UserRepo) GetUserByName(ctx context.Context, username string) (*togo.User, error) { + return ur.GetUserByNameFn(ctx, username) +} + +func (ur *UserRepo) IsExceedPerDay(ctx context.Context, u *togo.User) (bool, error) { + return ur.IsExceedPerDayFn(ctx, u) +} diff --git a/postgres/integration/TodoRepoAddNewUser.sql b/postgres/integration/TodoRepoAddNewUser.sql new file mode 100644 index 000000000..a492d1a35 --- /dev/null +++ b/postgres/integration/TodoRepoAddNewUser.sql @@ -0,0 +1,11 @@ +INSERT INTO users( + user_name, + limited_per_day, + create_date, + create_user) +VALUES( + $1, + $2, + NOW(), + 'admin') +RETURNING id \ No newline at end of file diff --git a/postgres/integration/TodoRepoAddTodo.sql b/postgres/integration/TodoRepoAddTodo.sql new file mode 100644 index 000000000..05031dbd5 --- /dev/null +++ b/postgres/integration/TodoRepoAddTodo.sql @@ -0,0 +1,13 @@ +INSERT INTO todos( + todo_id, + user_id, + description, + create_date, + create_user) +VALUES( + (SELECT (COUNT(*) + 1) FROM todos WHERE user_id = $1), + $1, + $2, + NOW(), + 'admin') +RETURNING todo_id \ No newline at end of file diff --git a/postgres/integration/UserRepoGetUserByName.sql b/postgres/integration/UserRepoGetUserByName.sql new file mode 100644 index 000000000..812df2fd1 --- /dev/null +++ b/postgres/integration/UserRepoGetUserByName.sql @@ -0,0 +1,4 @@ +SELECT id, user_name, limited_per_day + FROM users + WHERE 1=1 + AND user_name = $1 \ No newline at end of file diff --git a/postgres/integration/UserRepoIsExceedLimitedPerDay.sql b/postgres/integration/UserRepoIsExceedLimitedPerDay.sql new file mode 100644 index 000000000..f540c1772 --- /dev/null +++ b/postgres/integration/UserRepoIsExceedLimitedPerDay.sql @@ -0,0 +1,8 @@ +SELECT (u.limited_per_day <= d.count_todos) is_exceed + FROM users u + INNER JOIN (SELECT d.user_id, COUNT(*) count_todos + FROM todos d + GROUP BY d.user_id) d + ON u.id = d.user_id + WHERE 1=1 + AND u.id = $1 \ No newline at end of file diff --git a/postgres/postgres.go b/postgres/postgres.go new file mode 100644 index 000000000..ec2aaa956 --- /dev/null +++ b/postgres/postgres.go @@ -0,0 +1,96 @@ +package postgres + +import ( + "context" + "database/sql" + "embed" + "fmt" + "time" + + _ "github.com/lib/pq" +) + +//go:embed integration/*.sql +var integrationFS embed.FS + +type DB struct { + DB *sql.DB + Ctx context.Context // Background context + Cancel func() // Cancel background context + + // Datasource name + DSN string + + // Returns the current time. Defaults to time.Now(). + Now func() time.Time +} + +// NewDB returns a new instance of DB associated with the given datasource name. +func NewDB(dsn string) *DB { + db := &DB{ + DSN: dsn, + Now: time.Now, + } + db.Ctx, db.Cancel = context.WithCancel(context.Background()) + return db +} + +// Open opens the database connection. +func (db *DB) Open() (err error) { + // Ensure a DSN is set before attempting to open the database. + if db.DSN == "" { + return fmt.Errorf("DSN required") + } + + // Connect to the database. + if db.DB, err = sql.Open("postgres", db.DSN); err != nil { + return err + } + + return nil +} + +// Close closes the database connection. +func (db *DB) Close() error { + // Cancel background context. + db.Cancel() + + // Close database. + if db.DB != nil { + return db.DB.Close() + } + return nil +} + +// Tx wraps the SQL Tx object to provide a timestamp at the start of the transaction. +type Tx struct { + *sql.Tx + db *DB + now time.Time +} + +// BeginTx starts a transaction and returns a wrapper Tx type. This type +// provides a reference to the database and a fixed timestamp at the start of +// the transaction. The timestamp allows us to mock time during tests as well. +func (db *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) { + tx, err := db.DB.BeginTx(ctx, opts) + if err != nil { + return nil, err + } + + // Return wrapper Tx that includes the transaction start time. + return &Tx{ + Tx: tx, + db: db, + now: db.Now().UTC().Truncate(time.Second), + }, nil +} + +func ISQLTemplate(filename string) string { + file, err := integrationFS.ReadFile("integration/" + filename) + if err != nil { + fmt.Println(err) + return "" + } + return string(file) +} diff --git a/postgres/todo.go b/postgres/todo.go new file mode 100644 index 000000000..4aa2ea687 --- /dev/null +++ b/postgres/todo.go @@ -0,0 +1,71 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + + "github.com/lawtrann/togo" +) + +type TodoRepo struct { + DB *DB +} + +func NewTodoRepo(db *DB) *TodoRepo { + return &TodoRepo{DB: db} +} + +func (tr *TodoRepo) Add(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + var result togo.Todo + + // Retrieve user by name + fmt.Println(tr.DB.Now().Format("2006-01-02 15:04:05"), "\n", ISQLTemplate("TodoRepoAddTodo.sql")) + err := tr.DB.DB.QueryRow(ISQLTemplate("TodoRepoAddTodo.sql"), + u.ID, t.Description).Scan(&t.ID) + if err != nil && err != sql.ErrNoRows { + fmt.Println(err) + return nil, err + } + + result.ID = t.ID + result.Description = t.Description + + return &result, nil +} + +func (tr *TodoRepo) AddWithNewUser(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + var result togo.Todo + + tx, err := tr.DB.BeginTx(ctx, nil) + if err != nil { + fmt.Println(err) + return nil, err + } + defer tx.Commit() + + // Add new user + fmt.Println(tr.DB.Now().Format("2006-01-02 15:04:05"), "\n", ISQLTemplate("TodoRepoAddNewUser.sql")) + err = tx.QueryRowContext(ctx, ISQLTemplate("TodoRepoAddNewUser.sql"), + u.Username, u.LimitedPerDay).Scan(&u.ID) + if err != nil && err != sql.ErrNoRows { + fmt.Println(err) + tx.Rollback() + return nil, err + } + + // Add todo into created user + fmt.Println(tr.DB.Now().Format("2006-01-02 15:04:05"), "\n", ISQLTemplate("TodoRepoAddTodo.sql")) + err = tx.QueryRowContext(ctx, ISQLTemplate("TodoRepoAddTodo.sql"), + u.ID, t.Description).Scan(&t.ID) + if err != nil && err != sql.ErrNoRows { + fmt.Println(err) + tx.Rollback() + return nil, err + } + + result.ID = t.ID + result.Description = t.Description + + return &result, nil +} diff --git a/postgres/tododb.go b/postgres/tododb.go deleted file mode 100644 index 080736bb7..000000000 --- a/postgres/tododb.go +++ /dev/null @@ -1,133 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "fmt" - "log" - "os" - "strconv" - - "github.com/lawtrann/togo" - _ "github.com/lib/pq" -) - -type TodoDB struct { - DB *sql.DB -} - -func GetDns() string { - host := os.Getenv("POSTGRESQL_HOST") - port, err := strconv.Atoi(os.Getenv("POSTGRESQL_PORT")) - if err != nil { - log.Fatalf("Postgres port %s is not valid", os.Getenv("POSTGRESQL_PORT")) - os.Exit(1) - } - user := os.Getenv("POSTGRESQL_USERNAME") - password := os.Getenv("POSTGRESQL_PASSWORD") - dbname := os.Getenv("POSTGRESQL_DATABASE") - - return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", - host, port, user, password, dbname) -} - -func NewTodoDB() (togo.TodoDB, error) { - db, err := sql.Open("postgres", GetDns()) - if err != nil { - panic(err) - } - - return &TodoDB{ - DB: db, - }, nil -} - -// Get user by username -func (td *TodoDB) GetUserByName(userName string) (*togo.User, error) { - var result togo.User - - // Query for a value based on a single row. - err := td.DB.QueryRow("SELECT id, user_name, limited_per_day FROM users WHERE user_name = $1", - userName).Scan(&result.ID, &result.UserName, &result.LimitedPerDay) - - if err != nil && err != sql.ErrNoRows { - return &togo.User{}, err - } - - return &result, nil -} - -// Check if exceed a limited per day -func (td *TodoDB) IsExceedPerDay(u togo.User) (bool, error) { - var result bool - - // Query for a value based on a single row. - err := td.DB.QueryRow("SELECT (COUNT(*) >= $1) is_exceed FROM todos WHERE user_id = $2 and create_date >= NOW()::date", - u.LimitedPerDay, u.ID).Scan(&result) - if err != nil && err != sql.ErrNoRows { - return false, err - } - - return result, nil -} - -// Adding new todo task to user if not exceed a limited per day -func (td *TodoDB) AddTodoByUser(u *togo.User, t *togo.Todo, uFlag bool) error { - - // Begin - ctx := context.Background() - tx, err := td.DB.BeginTx(ctx, nil) - if err != nil { - return err - } - - // Create user if not exist - if !uFlag { - stmt := `INSERT INTO users( - user_name, - limited_per_day, - create_date, - create_user) - VALUES( - $1, - $2, - NOW(), - 'admin') - RETURNING id` - - // res, err := tx.ExecContext(ctx, stmt, &u.UserName, limited) - err = tx.QueryRowContext(ctx, stmt, &u.UserName, &u.LimitedPerDay).Scan(&t.UserID) - if err != nil && err != sql.ErrNoRows { - tx.Rollback() - return err - } - } - - stmt := `INSERT INTO todos( - todo_id, - user_id, - description, - create_date, - create_user) - VALUES( - (SELECT (COUNT(*) + 1) FROM todos WHERE user_id = $1), - $1, - $2, - NOW(), - 'admin') - RETURNING todo_id` - - err = tx.QueryRowContext(ctx, stmt, t.UserID, t.Description).Scan(&t.TodoID) - if err != nil && err != sql.ErrNoRows { - tx.Rollback() - return err - } - - // Commit - err = tx.Commit() - if err != nil { - return err - } - - return nil -} diff --git a/postgres/user.go b/postgres/user.go new file mode 100644 index 000000000..e5bc60649 --- /dev/null +++ b/postgres/user.go @@ -0,0 +1,47 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + + "github.com/lawtrann/togo" +) + +type UserRepo struct { + DB *DB +} + +func NewUserRepo(db *DB) *UserRepo { + return &UserRepo{DB: db} +} + +func (ur *UserRepo) GetUserByName(ctx context.Context, username string) (*togo.User, error) { + var result togo.User + + // Retrieve user by name + fmt.Println(ur.DB.Now().Format("2006-01-02 15:04:05"), "\n", ISQLTemplate("UserRepoGetUserByName.sql")) + err := ur.DB.DB.QueryRow(ISQLTemplate("UserRepoGetUserByName.sql"), + username).Scan(&result.ID, &result.Username, &result.LimitedPerDay) + if err != nil && err != sql.ErrNoRows { + fmt.Println(err) + return &togo.User{}, err + } + + return &result, nil +} + +func (ur *UserRepo) IsExceedPerDay(ctx context.Context, u *togo.User) (bool, error) { + result := false + + // Check if exceed per day + fmt.Println(ur.DB.Now().Format("2006-01-02 15:04:05"), "\n", ISQLTemplate("UserRepoIsExceedLimitedPerDay.sql")) + err := ur.DB.DB.QueryRow(ISQLTemplate("UserRepoIsExceedLimitedPerDay.sql"), + u.ID).Scan(&result) + if err != nil && err != sql.ErrNoRows { + fmt.Println(err) + return false, err + } + + return result, nil +} diff --git a/queries.sql b/queries.sql index 1f9334af4..3d4e8bb1c 100644 --- a/queries.sql +++ b/queries.sql @@ -1,6 +1,4 @@ - --- Create User, Todos tables -- ---> START +-- START DROP TABLE IF EXISTS todos; DROP TABLE IF EXISTS users; @@ -17,7 +15,6 @@ CREATE TABLE users ( one to many: User has many Todos */ -DROP TABLE IF EXISTS todos; CREATE TABLE todos ( todo_id int NOT NULL, user_id int NOT NULL, @@ -27,12 +24,9 @@ CREATE TABLE todos ( PRIMARY KEY (todo_id, user_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); ---< END - ----------------------------------------------------------- +-- END -select * from users; -select * from todos; +----------------------------------------------------------------------------- -- Retrieve inserted todos SELECT u.id, u.user_name, u.limited_per_day, STRING_AGG (t.description, ', '), t.create_date::date, NOW()::date as today @@ -42,11 +36,4 @@ SELECT u.id, u.user_name, u.limited_per_day, STRING_AGG (t.description, ', '), t AND u.id = t.user_id GROUP BY u.id, u.user_name, u.limited_per_day, t.create_date::date ORDER BY u.id -; - --- Check IsExceed -SELECT (COUNT(*) >= 7) is_exceed - FROM todos - WHERE 1=1 - AND user_id = 2 - AND create_date >= NOW()::date \ No newline at end of file +; \ No newline at end of file diff --git a/service/todo.go b/service/todo.go new file mode 100644 index 000000000..4ec98e888 --- /dev/null +++ b/service/todo.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + "fmt" + "math/rand" + + "github.com/lawtrann/togo" +) + +type TodoService struct { + Repo togo.TodoRepo + + UserService togo.UserService +} + +func NewTodoService(rp togo.TodoRepo) *TodoService { + return &TodoService{Repo: rp} +} + +func (ts TodoService) Add(ctx context.Context, t *togo.Todo, username string) (*togo.Todo, error) { + var res *togo.Todo + var u *togo.User + + u, err := ts.UserService.GetUserByName(ctx, username) + if err != nil { + fmt.Println(err) + return &togo.Todo{}, err + } + + // checkif user exist + if (togo.User{}) != *u { + // checkif exceed a limited per day + isExceedPerDay, err := ts.UserService.IsExceedPerDay(ctx, u) + if isExceedPerDay || err != nil { + return &togo.Todo{}, togo.ErrIsExceedLimitedPerDay + } + res, err = ts.Repo.Add(ctx, t, u) + if err != nil { + fmt.Println(err) + return &togo.Todo{}, err + } + } else { + // set user's name and limited per day + u.Username = username + u.LimitedPerDay = rand.Intn(10) + 1 + res, err = ts.Repo.AddWithNewUser(ctx, t, u) + if err != nil { + fmt.Println(err) + return &togo.Todo{}, err + } + } + + return res, nil +} diff --git a/service/todo_test.go b/service/todo_test.go new file mode 100644 index 000000000..de16dca60 --- /dev/null +++ b/service/todo_test.go @@ -0,0 +1,241 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/lawtrann/togo" + "github.com/lawtrann/togo/mocks" +) + +func TestAdd_NewUser_NewTodo(t *testing.T) { + // Input data + ctx := context.Background() + todo := togo.Todo{ + Description: "Todo something", + } + userName := "lawtrann" + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.GetUserByNameFn = func(ctx context.Context, username string) (*togo.User, error) { + return &togo.User{}, nil + } + // Create UserService + userService := NewUserService(&userRepo) + + // Mock TodoRepo + todoRepo := mocks.TodoRepo{} + todoRepo.AddWithNewUserFn = func(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + return &togo.Todo{ID: 1, Description: "Todo something"}, nil + } + + // Create TodoService + svc := NewTodoService(&todoRepo) + svc.UserService = userService + + // Got result + got, err := svc.Add(ctx, &todo, userName) + // Expect result + expect := togo.Todo{ID: 1, Description: "Todo something"} + if *got != expect || err != nil { + t.Log(*got, err, expect) + t.Error("Error while running TestAdd_NewUser_NewTodo") + } +} + +func TestAdd_ExistedUser_NewTodo_NotExceed(t *testing.T) { + // Input data + ctx := context.Background() + todo := togo.Todo{ + Description: "Todo something", + } + userName := "lawtrann" + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.GetUserByNameFn = func(ctx context.Context, username string) (*togo.User, error) { + return &togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5}, nil + } + userRepo.IsExceedPerDayFn = func(ctx context.Context, u *togo.User) (bool, error) { + return false, nil + } + // Create UserService + userService := NewUserService(&userRepo) + + // Mock TodoRepo + todoRepo := mocks.TodoRepo{} + todoRepo.AddFn = func(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + return &togo.Todo{ID: 2, Description: "Todo something"}, nil + } + + // Create TodoService + svc := NewTodoService(&todoRepo) + svc.UserService = userService + + // Got result + got, err := svc.Add(ctx, &todo, userName) + // Expect result + expect := togo.Todo{ID: 2, Description: "Todo something"} + if *got != expect || err != nil { + t.Log(*got, err, expect) + t.Error("Error while running TestAdd_ExistedUser_NewTodo_NotExceed") + } +} + +func TestAdd_ExistedUser_NewTodo_WithExceed(t *testing.T) { + // Input data + ctx := context.Background() + todo := togo.Todo{ + Description: "Todo something", + } + userName := "lawtrann" + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.GetUserByNameFn = func(ctx context.Context, username string) (*togo.User, error) { + return &togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5}, nil + } + userRepo.IsExceedPerDayFn = func(ctx context.Context, u *togo.User) (bool, error) { + return true, nil + } + // Create UserService + userService := NewUserService(&userRepo) + + // Mock TodoRepo + todoRepo := mocks.TodoRepo{} + todoRepo.AddFn = func(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + return &togo.Todo{ID: 2, Description: "Todo something"}, nil + } + + // Create TodoService + svc := NewTodoService(&todoRepo) + svc.UserService = userService + + // Got result + got, err := svc.Add(ctx, &todo, userName) + // Expect result + expect := togo.Todo{} + if *got != expect && err != nil { + t.Log(*got, err, expect) + t.Error("Error while running TestAdd_ExistedUser_NewTodo_WithExceed") + } +} + +func TestAdd_ExistedUser_NewTodo_WithGetUserByNameFnError(t *testing.T) { + // Input data + ctx := context.Background() + todo := togo.Todo{ + Description: "Todo something", + } + userName := "lawtrann" + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.GetUserByNameFn = func(ctx context.Context, username string) (*togo.User, error) { + return &togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5}, errors.New("Error occurs") + } + userRepo.IsExceedPerDayFn = func(ctx context.Context, u *togo.User) (bool, error) { + return false, nil + } + // Create UserService + userService := NewUserService(&userRepo) + + // Mock TodoRepo + todoRepo := mocks.TodoRepo{} + todoRepo.AddFn = func(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + return &togo.Todo{ID: 2, Description: "Todo something"}, nil + } + + // Create TodoService + svc := NewTodoService(&todoRepo) + svc.UserService = userService + + // Got result + got, err := svc.Add(ctx, &todo, userName) + // Expect result + expect := togo.Todo{} + if *got != expect && err != nil { + t.Log(*got, err, expect) + t.Error("Error while running TestAdd_ExistedUser_NewTodo_WithGetUserByNameFnError") + } +} + +func TestAdd_ExistedUser_NewTodo_WithIsExceedPerDayFnError(t *testing.T) { + // Input data + ctx := context.Background() + todo := togo.Todo{ + Description: "Todo something", + } + userName := "lawtrann" + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.GetUserByNameFn = func(ctx context.Context, username string) (*togo.User, error) { + return &togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5}, nil + } + userRepo.IsExceedPerDayFn = func(ctx context.Context, u *togo.User) (bool, error) { + return false, errors.New("Error occurs") + } + // Create UserService + userService := NewUserService(&userRepo) + + // Mock TodoRepo + todoRepo := mocks.TodoRepo{} + todoRepo.AddFn = func(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + return &togo.Todo{ID: 2, Description: "Todo something"}, nil + } + + // Create TodoService + svc := NewTodoService(&todoRepo) + svc.UserService = userService + + // Got result + got, err := svc.Add(ctx, &todo, userName) + // Expect result + expect := togo.Todo{} + if *got != expect && err != nil { + t.Log(*got, err, expect) + t.Error("Error while running TestAdd_ExistedUser_NewTodo_WithIsExceedPerDayFnError") + } +} + +func TestAdd_ExistedUser_NewTodo_WithAddFnError(t *testing.T) { + // Input data + ctx := context.Background() + todo := togo.Todo{ + Description: "Todo something", + } + userName := "lawtrann" + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.GetUserByNameFn = func(ctx context.Context, username string) (*togo.User, error) { + return &togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5}, nil + } + userRepo.IsExceedPerDayFn = func(ctx context.Context, u *togo.User) (bool, error) { + return false, nil + } + // Create UserService + userService := NewUserService(&userRepo) + + // Mock TodoRepo + todoRepo := mocks.TodoRepo{} + todoRepo.AddFn = func(ctx context.Context, t *togo.Todo, u *togo.User) (*togo.Todo, error) { + return &togo.Todo{ID: 2, Description: "Todo something"}, errors.New("Error occurs") + } + + // Create TodoService + svc := NewTodoService(&todoRepo) + svc.UserService = userService + + // Got result + got, err := svc.Add(ctx, &todo, userName) + // Expect result + expect := togo.Todo{} + if *got != expect && err != nil { + t.Log(*got, err, expect) + t.Error("Error while running TestAdd_ExistedUser_NewTodo_WithAddFnError") + } +} diff --git a/service/user.go b/service/user.go new file mode 100644 index 000000000..743156ca5 --- /dev/null +++ b/service/user.go @@ -0,0 +1,34 @@ +package service + +import ( + "context" + "fmt" + + "github.com/lawtrann/togo" +) + +type UserService struct { + Repo togo.UserRepo +} + +func NewUserService(rp togo.UserRepo) *UserService { + return &UserService{Repo: rp} +} + +func (us *UserService) GetUserByName(ctx context.Context, username string) (*togo.User, error) { + result, err := us.Repo.GetUserByName(ctx, username) + if err != nil { + fmt.Println(err) + return &togo.User{}, err + } + return result, nil +} + +func (us *UserService) IsExceedPerDay(ctx context.Context, u *togo.User) (bool, error) { + result, err := us.Repo.IsExceedPerDay(ctx, u) + if err != nil { + fmt.Println(err) + return false, err + } + return result, nil +} diff --git a/service/user_test.go b/service/user_test.go new file mode 100644 index 000000000..39d556200 --- /dev/null +++ b/service/user_test.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/lawtrann/togo" + "github.com/lawtrann/togo/mocks" +) + +func TestGetUserByName_UserNotExisted(t *testing.T) { + // Input data + ctx := context.Background() + userName := "lawtrann" + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.GetUserByNameFn = func(ctx context.Context, username string) (*togo.User, error) { + return &togo.User{}, nil + } + + // Create UserService + svc := NewUserService(&userRepo) + + // Got result + got, err := svc.GetUserByName(ctx, userName) + // Expect result + expect := togo.User{} + if *got != expect || err != nil { + t.Log(*got, err, expect) + t.Error("Error while running TestGetUserByName_UserNotExisteds") + } +} + +func TestGetUserByName_UserExisted(t *testing.T) { + // Input data + ctx := context.Background() + userName := "lawtrann" + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.GetUserByNameFn = func(ctx context.Context, username string) (*togo.User, error) { + return &togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5}, nil + } + + // Create UserService + svc := NewUserService(&userRepo) + + // Got result + got, err := svc.GetUserByName(ctx, userName) + // Expect result + expect := togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5} + if *got != expect || err != nil { + t.Log(*got, err, expect) + t.Error("Error while running TestGetUserByName_UserExisted") + } +} + +func TestGetUserByName_WithError(t *testing.T) { + // Input data + ctx := context.Background() + userName := "lawtrann" + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.GetUserByNameFn = func(ctx context.Context, username string) (*togo.User, error) { + return &togo.User{}, errors.New("Error occurs") + } + + // Create UserService + svc := NewUserService(&userRepo) + + // Got result + got, err := svc.GetUserByName(ctx, userName) + // Expect result + expect := togo.User{} + if *got != expect && err != nil { + t.Log(*got, err, expect) + t.Error("Error while running TestGetUserByName_WithError") + } +} + +func TestIsExceedPerDay_Exceed(t *testing.T) { + // Input data + ctx := context.Background() + user := togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5} + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.IsExceedPerDayFn = func(ctx context.Context, u *togo.User) (bool, error) { + return true, nil + } + + // Create UserService + svc := NewUserService(&userRepo) + + // Got result + got, err := svc.IsExceedPerDay(ctx, &user) + // Expect result + expect := true + if got != expect || err != nil { + t.Log(got, err, expect) + t.Error("Error while running TestIsExceedPerDay_Exceed") + } +} + +func TestIsExceedPerDay_NotExceed(t *testing.T) { + // Input data + ctx := context.Background() + user := togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5} + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.IsExceedPerDayFn = func(ctx context.Context, u *togo.User) (bool, error) { + return false, nil + } + + // Create UserService + svc := NewUserService(&userRepo) + + // Got result + got, err := svc.IsExceedPerDay(ctx, &user) + // Expect result + expect := false + if got != expect || err != nil { + t.Log(got, err, expect) + t.Error("Error while running TestIsExceedPerDay_NotExceed") + } +} + +func TestIsExceedPerDay_WithError(t *testing.T) { + // Input data + ctx := context.Background() + user := togo.User{ID: 1, Username: "lawtrann", LimitedPerDay: 5} + + // Mock UserRepo + userRepo := mocks.UserRepo{} + userRepo.IsExceedPerDayFn = func(ctx context.Context, u *togo.User) (bool, error) { + return false, errors.New("Error occurs") + } + + // Create UserService + svc := NewUserService(&userRepo) + + // Got result + got, err := svc.IsExceedPerDay(ctx, &user) + // Expect result + expect := false + if got != expect && err != nil { + t.Log(got, err, expect) + t.Error("Error while running TestIsExceedPerDay_NotExceed") + } +} diff --git a/services/todoservice.go b/services/todoservice.go deleted file mode 100644 index 60dca72fe..000000000 --- a/services/todoservice.go +++ /dev/null @@ -1,50 +0,0 @@ -package services - -import ( - "errors" - "math/rand" - - "github.com/lawtrann/togo" -) - -type TodoService struct { - DB togo.TodoDB -} - -func NewTodoService(db togo.TodoDB) togo.TodoService { - return &TodoService{ - DB: db, - } -} - -// Adding new todo task to user if not exceed a limited per day -func (ts *TodoService) AddTodoByUser(userName string, t *togo.Todo) (*togo.Todo, error) { - - // uFlag for checking existed user, isExceed for checking limited per day - uFlag := false - - // Get user by name - u, err := ts.DB.GetUserByName(userName) - if err != nil { - return &togo.Todo{}, errors.New(err.Error()) - } - - if (togo.User{}) != *u { - t.UserID = u.ID - // flag existed user - uFlag = true - // checkif exceed a limited per day - isExceedPerDay, err := ts.DB.IsExceedPerDay(*u) - if isExceedPerDay || err != nil { - return &togo.Todo{}, errors.New("you have reached the limit of adding todo task per day") - } - } else { - u.UserName = userName - u.LimitedPerDay = rand.Intn(9) + 1 - } - - // Add todo with transaction - ts.DB.AddTodoByUser(u, t, uFlag) - - return t, nil -} diff --git a/services/todoservice_test.go b/services/todoservice_test.go deleted file mode 100644 index 65507a607..000000000 --- a/services/todoservice_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package services - -import ( - "testing" - - "github.com/lawtrann/togo" - "github.com/lawtrann/togo/mocks" -) - -func TestAddTodoByUser_WithNewUser(t *testing.T) { - // Create DB mocks - db := mocks.TodoDB{} - - // New User - db.GetUserByNameResp = togo.User{} - db.GetUserByNameErr = nil - // Add todo with transaction - db.AddTodoByUserErr = nil - - // mocks DB to TodoService - svc := NewTodoService(db) - - // Input data - username := "test1" - todo := &togo.Todo{ - TodoID: 1, - UserID: 1, - Description: "Description 1", - } - // Got result - got, err := svc.AddTodoByUser(username, todo) - // Expected result - expected := togo.Todo{ - TodoID: 1, - UserID: 1, - Description: "Description 1", - } - - if *got != expected && err != nil { - t.Log(*got) - t.Error("Error while inserted new Todo!") - } -} - -func TestAddTodoByUser_WithExistedUser_Exceed(t *testing.T) { - // Create DB mocks - db := mocks.TodoDB{} - - // Existed User - db.GetUserByNameResp = togo.User{ - ID: 1, - UserName: "test1", - LimitedPerDay: 3, - } - db.GetUserByNameErr = nil - // IsExceed = true - db.IsExceedPerDayResp = true // check point - db.IsExceedPerDayErr = nil - // Add todo with transaction - db.AddTodoByUserErr = nil - - // mocks DB to TodoService - svc := NewTodoService(db) - - // Input data - username := "test1" - todo := &togo.Todo{ - TodoID: 1, - UserID: 1, - Description: "Description 1", - } - // Got result - got, err := svc.AddTodoByUser(username, todo) - // Expected result - expected := togo.Todo{} - - if *got != expected && err != nil { - t.Log(*got, expected) - t.Error("The number of tasks exceeds a limited per day") - } -} diff --git a/togo.go b/togo.go index f6e67989d..fa1ec059e 100644 --- a/togo.go +++ b/togo.go @@ -1,31 +1,89 @@ package togo -// User struct represents user fields -type User struct { - ID int `json:"id" db:"id"` - UserName string `json:"user_name" db:"user_name"` - LimitedPerDay int `json:"limited_per_day" db:"limited_per_day"` +import ( + "context" +) + +// General errors. +const ( + ErrSuccessAddingNewTodo = Error("Successfully adding new todo task") + ErrIsExceedLimitedPerDay = Error("You have reached the limit of adding todo task per day") + ErrPageNotFound = Error("Your page cannot be found") + ErrHTTPMethodNotAllowed = Error("HTTP Method is not allowed") + ErrCouldNotParseObject = Error("Could not parse Todo object") +) + +// Error is a domain error encountered while processing chronograf requests +type Error string + +// Error ToString method +func (e Error) Error() string { + return string(e) } -// Todo struct represents todo fields +// Todo represents a todo in the system. Todos are typically created via API. type Todo struct { - TodoID int `json:"todo_id" db:"todo_id"` - UserID int `json:"user_id" db:"user_id"` - Description string `json:"description" db:"description"` + ID int `json:"id"` + // Description of todo task + Description string `json:"description"` + + // Timestamps for todo creation & last update. + // CreatedAt time.Time `json:"created_at"` + // CreatedBy string `json:"created_by"` + // UpdatedAt time.Time `json:"updated_at"` + // UpdatedBy string `json:"updated_by"` } -// TodoService interface uses for handling Business +// TodoService represents a service for managing todos. type TodoService interface { - // Adding new todo task to user if not exceed a limited per day - AddTodoByUser(userName string, t *Todo) (*Todo, error) -} - -// TodoDB interface uses for handling Todo Repository -type TodoDB interface { - // Get user by username - GetUserByName(userName string) (*User, error) - // Check if exceed a limited per day - IsExceedPerDay(u User) (bool, error) - // Adding new todo task to user if not exceed a limited per day - AddTodoByUser(u *User, t *Todo, uFlag bool) error + // Add new todo task + Add(ctx context.Context, t *Todo, username string) (*Todo, error) +} + +// TodoRepo represents a repository for managing todos. +type TodoRepo interface { + // Add new todo task + Add(ctx context.Context, t *Todo, u *User) (*Todo, error) + // Add new todo task with new user + AddWithNewUser(ctx context.Context, t *Todo, u *User) (*Todo, error) +} + +// User represents a user in the system. +type User struct { + ID int `json:"id"` + // User's preferred name & limited todo per day. + Username string `json:"user_name"` + LimitedPerDay int `json:"limited_per_day"` + + // Timestamps for user creation & last update. + // CreatedAt time.Time `json:"created_at"` + // CreatedBy string `json:"created_by"` + // UpdatedAt time.Time `json:"updated_at"` + // UpdatedBy string `json:"updated_by"` +} + +// UserService represents a service for managing users. +type UserService interface { + // Retrieves a user by name along with their associated auth objects. + GetUserByName(ctx context.Context, username string) (*User, error) + // Check the number of todos created has reached the limited per day or not. + IsExceedPerDay(ctx context.Context, u *User) (bool, error) +} + +// UserRepo represents a repository for managing users. +type UserRepo interface { + // Retrieves a user by name along with their associated auth objects. + GetUserByName(ctx context.Context, username string) (*User, error) + // Check the number of todos created has reached the limited per day or not. + IsExceedPerDay(ctx context.Context, u *User) (bool, error) +} + +// ErrResponse represent a error with json format +type TemplateResponse struct { + // HTTP response status codes + Status int + // Error message + Message string + // Todo object + Data Todo }