From 9ce2d571968295627f323b86f792ad44f3ffad01 Mon Sep 17 00:00:00 2001 From: Arseny Balobanov Date: Fri, 20 Mar 2020 00:57:07 +0300 Subject: [PATCH] Adding todo-app coverage task. --- coverme/README.md | 76 +++++++++++++++++++++++++++ coverme/app/app.go | 99 ++++++++++++++++++++++++++++++++++++ coverme/app/coverage_test.go | 5 ++ coverme/client/client.go | 70 +++++++++++++++++++++++++ coverme/main.go | 18 +++++++ coverme/models/storage.go | 70 +++++++++++++++++++++++++ coverme/models/todo.go | 25 +++++++++ coverme/utils/httputils.go | 32 ++++++++++++ go.mod | 2 + go.sum | 4 ++ 10 files changed, 401 insertions(+) create mode 100644 coverme/README.md create mode 100644 coverme/app/app.go create mode 100644 coverme/app/coverage_test.go create mode 100644 coverme/client/client.go create mode 100644 coverme/main.go create mode 100644 coverme/models/storage.go create mode 100644 coverme/models/todo.go create mode 100644 coverme/utils/httputils.go diff --git a/coverme/README.md b/coverme/README.md new file mode 100644 index 0000000..649407b --- /dev/null +++ b/coverme/README.md @@ -0,0 +1,76 @@ +## coverme + +В этой задаче нужно покрыть простой todo-app http сервис unit тестами. + +Имеющиеся `_test.go` файлы лучше не трогать, +при тестировании все изменения перетираются. + +Package main можно не тестировать. + +Тестирующая система будет проверяться code coverage. +Порог задан в [coverage_test.go](./app/coverage_test.go) + +Как посмотреть coverage: +``` +go test -v -cover ./coverme/... +``` + +## Ссылки + +1. cover: https://blog.golang.org/cover +2. [gomock](https://github.com/golang/mock) для создания мока базы данных при тестировании серевера +3. [httptest.ResponseRecorder](https://golang.org/pkg/net/http/httptest/#ResponseRecorder) для тестирования handler'ов сервера +4. [httptest.Server](https://golang.org/pkg/net/http/httptest/#Server) для тестирования клинета + +## O сервисе + +Todo-app с минимальной функциональностью + client. + +Запуск: +``` +✗ go run ./coverme/main.go -port 6029 +``` + +Health check: +``` +✗ curl -i -X GET localhost:6029/ +HTTP/1.1 200 OK +Content-Type: application/json +Date: Thu, 19 Mar 2020 21:46:02 GMT +Content-Length: 24 + +"API is up and working!" +``` + +Создать новое todo: +``` +✗ curl -i localhost:6029/todo/create -d '{"title":"A","content":"a"}' +HTTP/1.1 201 Created +Content-Type: application/json +Date: Thu, 19 Mar 2020 21:41:31 GMT +Content-Length: 51 + +{"id":0,"title":"A","content":"a","finished":false} +``` + +Получить todo по id: +``` +✗ curl -i localhost:6029/todo/0 +HTTP/1.1 200 OK +Content-Type: application/json +Date: Thu, 19 Mar 2020 21:44:17 GMT +Content-Length: 51 + +{"id":0,"title":"A","content":"a","finished":false} +``` + +Получить все todo: +``` +✗ curl -i -X GET localhost:6029/todo +HTTP/1.1 200 OK +Content-Type: application/json +Date: Thu, 19 Mar 2020 21:44:37 GMT +Content-Length: 53 + +[{"id":0,"title":"A","content":"a","finished":false}] +``` \ No newline at end of file diff --git a/coverme/app/app.go b/coverme/app/app.go new file mode 100644 index 0000000..c28d216 --- /dev/null +++ b/coverme/app/app.go @@ -0,0 +1,99 @@ +// +build !change + +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + + "gitlab.com/slon/shad-go/todo/models" + "gitlab.com/slon/shad-go/todo/utils" +) + +type App struct { + router *mux.Router + db models.Storage +} + +func New(db models.Storage) *App { + return &App{db: db} +} + +func (app *App) Start(port int) { + app.initRoutes() + app.run(fmt.Sprintf(":%d", port)) +} + +func (app *App) initRoutes() { + app.router = mux.NewRouter() + app.router.HandleFunc("/", app.status).Methods("Get") + app.router.HandleFunc("/todo", app.list).Methods("Get") + app.router.HandleFunc("/todo/{id:[0-9]+}", app.getTodo).Methods("Get") + app.router.HandleFunc("/todo/create", app.addTodo).Methods("Post") +} + +func (app *App) run(addr string) { + loggedRouter := handlers.LoggingHandler(os.Stderr, app.router) + _ = http.ListenAndServe(addr, loggedRouter) +} + +func (app *App) list(w http.ResponseWriter, r *http.Request) { + todos, err := app.db.GetAll() + if err != nil { + utils.ServerError(w) + return + } + + _ = utils.RespondJSON(w, http.StatusOK, todos) +} + +func (app *App) addTodo(w http.ResponseWriter, r *http.Request) { + req := &models.AddRequest{} + + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&req); err != nil { + utils.BadRequest(w, "payload is required") + return + } + defer func() { _ = r.Body.Close() }() + + if req.Title == "" { + utils.BadRequest(w, "title is required") + return + } + + todo, err := app.db.AddTodo(req.Title, req.Content) + if err != nil { + utils.ServerError(w) + return + } + + _ = utils.RespondJSON(w, http.StatusCreated, todo) +} + +func (app *App) getTodo(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/todo/")) + if err != nil { + utils.BadRequest(w, "ID must be an int") + return + } + + todo, err := app.db.GetTodo(models.ID(id)) + if err != nil { + utils.ServerError(w) + return + } + + _ = utils.RespondJSON(w, http.StatusOK, todo) +} + +func (app *App) status(w http.ResponseWriter, r *http.Request) { + _ = utils.RespondJSON(w, http.StatusOK, "API is up and working!") +} diff --git a/coverme/app/coverage_test.go b/coverme/app/coverage_test.go new file mode 100644 index 0000000..11801c2 --- /dev/null +++ b/coverme/app/coverage_test.go @@ -0,0 +1,5 @@ +// +build !change + +package app + +// min coverage: 85% diff --git a/coverme/client/client.go b/coverme/client/client.go new file mode 100644 index 0000000..07df9a4 --- /dev/null +++ b/coverme/client/client.go @@ -0,0 +1,70 @@ +// +build !change + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "gitlab.com/slon/shad-go/todo/models" +) + +type Client struct { + addr string +} + +func New(addr string) *Client { + return &Client{addr: addr} +} + +func (c *Client) Add(r *models.AddRequest) (*models.Todo, error) { + data, _ := json.Marshal(r) + + resp, err := http.Post(c.addr+"/create", "application/json", bytes.NewReader(data)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + + var todo *models.Todo + err = json.NewDecoder(resp.Body).Decode(&todo) + return todo, err +} + +func (c *Client) Get(id models.ID) (*models.Todo, error) { + resp, err := http.Get(c.addr + fmt.Sprintf("/todo/%d", id)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + + var todo *models.Todo + err = json.NewDecoder(resp.Body).Decode(&todo) + return todo, err +} + +func (c *Client) List() ([]*models.Todo, error) { + resp, err := http.Get(c.addr + "/todo") + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + + var todos []*models.Todo + err = json.NewDecoder(resp.Body).Decode(&todos) + return todos, err +} diff --git a/coverme/main.go b/coverme/main.go new file mode 100644 index 0000000..76cf45b --- /dev/null +++ b/coverme/main.go @@ -0,0 +1,18 @@ +// +build !change + +package main + +import ( + "flag" + + "gitlab.com/slon/shad-go/todo/app" + "gitlab.com/slon/shad-go/todo/models" +) + +func main() { + port := flag.Int("port", 8080, "port to listen") + flag.Parse() + + db := models.NewInMemoryStorage() + app.New(db).Start(*port) +} diff --git a/coverme/models/storage.go b/coverme/models/storage.go new file mode 100644 index 0000000..59b029f --- /dev/null +++ b/coverme/models/storage.go @@ -0,0 +1,70 @@ +// +build !change + +package models + +import ( + "fmt" + "sync" +) + +type Storage interface { + AddTodo(string, string) (*Todo, error) + GetTodo(ID) (*Todo, error) + GetAll() ([]*Todo, error) +} + +type InMemoryStorage struct { + mu sync.RWMutex + todos map[ID]*Todo + + nextID ID +} + +func NewInMemoryStorage() *InMemoryStorage { + return &InMemoryStorage{ + todos: make(map[ID]*Todo), + } +} + +func (s *InMemoryStorage) AddTodo(title, content string) (*Todo, error) { + s.mu.Lock() + defer s.mu.Unlock() + + id := s.nextID + s.nextID++ + + todo := &Todo{ + ID: id, + Title: title, + Content: content, + Finished: false, + } + + s.todos[todo.ID] = todo + + return todo, nil +} + +func (s *InMemoryStorage) GetTodo(id ID) (*Todo, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + todo, ok := s.todos[id] + if !ok { + return nil, fmt.Errorf("todo %d not found", id) + } + + return todo, nil +} + +func (s *InMemoryStorage) GetAll() ([]*Todo, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var out []*Todo + for _, todo := range s.todos { + out = append(out, todo) + } + + return out, nil +} diff --git a/coverme/models/todo.go b/coverme/models/todo.go new file mode 100644 index 0000000..f65e8a3 --- /dev/null +++ b/coverme/models/todo.go @@ -0,0 +1,25 @@ +// +build !change + +package models + +type ID int + +type AddRequest struct { + Title string `json:"title"` + Content string `json:"content"` +} + +type Todo struct { + ID ID `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Finished bool `json:"finished"` +} + +func (t *Todo) MarkFinished() { + t.Finished = true +} + +func (t *Todo) MarkUnfished() { + t.Finished = false +} diff --git a/coverme/utils/httputils.go b/coverme/utils/httputils.go new file mode 100644 index 0000000..d1a7ede --- /dev/null +++ b/coverme/utils/httputils.go @@ -0,0 +1,32 @@ +// +build !change + +package utils + +import ( + "encoding/json" + "net/http" +) + +func RespondJSON(w http.ResponseWriter, status int, data interface{}) error { + response, err := json.Marshal(data) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + _, _ = w.Write(response) + + return nil +} + +func ServerError(w http.ResponseWriter) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Server encountered an error.")) +} + +func BadRequest(w http.ResponseWriter, message string) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(message)) +} diff --git a/go.mod b/go.mod index c00c043..a495ba3 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/go-resty/resty/v2 v2.1.0 github.com/gofrs/uuid v3.2.0+incompatible github.com/golang/mock v1.4.1 + github.com/gorilla/handlers v1.4.2 + github.com/gorilla/mux v1.7.4 github.com/spf13/cobra v0.0.5 github.com/stretchr/testify v1.4.0 go.uber.org/goleak v1.0.0 diff --git a/go.sum b/go.sum index c220d13..f68d302 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,10 @@ github.com/gonum/internal v0.0.0-20181124074243-f884aa714029/go.mod h1:Pu4dmpkhS github.com/gonum/lapack v0.0.0-20181123203213-e4cdc5a0bff9/go.mod h1:XA3DeT6rxh2EAE789SSiSJNqxPaC0aE9J8NTOI0Jo/A= github.com/gonum/matrix v0.0.0-20181209220409-c518dec07be9/go.mod h1:0EXg4mc1CNP0HCqCz+K4ts155PXIlUywf0wqN+GfPZw= github.com/googleapis/gax-go v0.0.0-20161107002406-da06d194a00e/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= +github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= +github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=