From fd340f976452273e7a6233fbb1b9fe45e65eb2db Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Mon, 20 Jan 2025 12:49:45 -0300 Subject: [PATCH 01/18] Set up database container --- .gitignore | 0 .vscode/launch.json | 15 +++++++++++++++ database.go | 2 ++ docker-compose.yaml | 13 +++++++++++++ go.mod | 3 +++ handlers/investor.go | 15 +++++++++++++++ main.go | 11 +++++++++++ setup.sql | 19 +++++++++++++++++++ 8 files changed, 78 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 database.go create mode 100644 docker-compose.yaml create mode 100644 go.mod create mode 100644 handlers/investor.go create mode 100644 main.go create mode 100644 setup.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..d98e72601 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceRoot}" + } + ] +} diff --git a/database.go b/database.go new file mode 100644 index 000000000..c9ecbf5e0 --- /dev/null +++ b/database.go @@ -0,0 +1,2 @@ +package main + diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..599f20a07 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,13 @@ +# Using root/example as user/password credentials +# (this is just an example, not intended to be a production configuration) + +services: + db: + image: mysql + restart: always + environment: + - MYSQL_ROOT_PASSWORD=example + ports: + - 3306:3306 + volumes: + - ./setup.sql:/docker-entrypoint-initdb.d/setup.sql diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..7ec9abc5e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module causeurgnocchi/backend-test + +go 1.23.1 diff --git a/handlers/investor.go b/handlers/investor.go new file mode 100644 index 000000000..e394f96b9 --- /dev/null +++ b/handlers/investor.go @@ -0,0 +1,15 @@ +package handlers + +import ( + "net/http" +) + +type InvestorHandler struct { +} + +func (i InvestorHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + switch (req.Method) { + case http.MethodPost: + + } +} diff --git a/main.go b/main.go new file mode 100644 index 000000000..c040bedfa --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "causeurgnocchi/backend-test/handlers" + "net/http" +) + +func main() { + http.Handle("/api/investors/", http.StripPrefix("/api/investors/", &handlers.InvestorHandler{})) + http.ListenAndServe(":8080", nil) +} \ No newline at end of file diff --git a/setup.sql b/setup.sql new file mode 100644 index 000000000..62b23126b --- /dev/null +++ b/setup.sql @@ -0,0 +1,19 @@ +drop database if exists investments; +create database investments; +use investments; + +create table investments ( + id int auto_increment, + amount decimal(15, 2) not null, + creation_date date not null, + owner_id int, + primary key (id), + foreign key (owner_id) references owner(id) +); + +create table owners { + id int auto_increment, + name text not null, + cpf text not null, + primary key (id) +} From 655d39e15a998194e92fe2a08e1bc65e8e06051a Mon Sep 17 00:00:00 2001 From: Gustavo Minoru Kussunoki <32077994+causeurgnocchi@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:19:23 +0000 Subject: [PATCH 02/18] Use functions instead of Handlers --- database.go | 1 - handlers/investor.go | 15 --------------- main.go | 3 +-- models/investor.go | 17 +++++++++++++++++ 4 files changed, 18 insertions(+), 18 deletions(-) delete mode 100644 handlers/investor.go create mode 100644 models/investor.go diff --git a/database.go b/database.go index c9ecbf5e0..06ab7d0f9 100644 --- a/database.go +++ b/database.go @@ -1,2 +1 @@ package main - diff --git a/handlers/investor.go b/handlers/investor.go deleted file mode 100644 index e394f96b9..000000000 --- a/handlers/investor.go +++ /dev/null @@ -1,15 +0,0 @@ -package handlers - -import ( - "net/http" -) - -type InvestorHandler struct { -} - -func (i InvestorHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { - switch (req.Method) { - case http.MethodPost: - - } -} diff --git a/main.go b/main.go index c040bedfa..3f3c8b18a 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,10 @@ package main import ( - "causeurgnocchi/backend-test/handlers" "net/http" ) func main() { - http.Handle("/api/investors/", http.StripPrefix("/api/investors/", &handlers.InvestorHandler{})) + // http.Handle("/api/investors/", http.StripPrefix("/api/investors/", TODO)) http.ListenAndServe(":8080", nil) } \ No newline at end of file diff --git a/models/investor.go b/models/investor.go new file mode 100644 index 000000000..aac347409 --- /dev/null +++ b/models/investor.go @@ -0,0 +1,17 @@ +package models + +import "database/sql" + +type Investor struct { + Id int + Name string + Cpf string +} + +type InvestorModel struct { + db *sql.DB +} + +func (m InvestorModel) ByCpf(cpf string) { + m.db.Query("SELECT * FROM investors where cpf = ?", cpf) +} From c16a6eeb970abdd399c23f07bb9991c68cde77b2 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Tue, 21 Jan 2025 10:42:41 -0300 Subject: [PATCH 03/18] Add endpoint for getting an investor by their cpf --- database.go | 1 - go.mod | 4 ++++ go.sum | 4 ++++ main.go | 46 ++++++++++++++++++++++++++++++++++++++++++++-- models/investor.go | 36 +++++++++++++++++++++++++++++------- setup.sql | 19 ++++++++++--------- 6 files changed, 91 insertions(+), 19 deletions(-) delete mode 100644 database.go create mode 100644 go.sum diff --git a/database.go b/database.go deleted file mode 100644 index 06ab7d0f9..000000000 --- a/database.go +++ /dev/null @@ -1 +0,0 @@ -package main diff --git a/go.mod b/go.mod index 7ec9abc5e..f21bdd22c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module causeurgnocchi/backend-test go 1.23.1 + +require github.com/go-sql-driver/mysql v1.8.1 + +require filippo.io/edwards25519 v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..19dbcece6 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= diff --git a/main.go b/main.go index 3f3c8b18a..ada1ca46d 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,52 @@ package main import ( + "database/sql" + "encoding/json" + "log" "net/http" + + "causeurgnocchi/backend-test/models" + + _ "github.com/go-sql-driver/mysql" ) func main() { - // http.Handle("/api/investors/", http.StripPrefix("/api/investors/", TODO)) + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + env := &Env{investors: &models.InvestorModel{DB: db}} + + http.HandleFunc("/api/investors/{cpf}", env.investorsByCpf) http.ListenAndServe(":8080", nil) -} \ No newline at end of file +} + +type Env struct { + investors interface { + ByCpf(cpf string) ([]models.Investor, error) + } +} + +func (env Env) investorsByCpf(w http.ResponseWriter, r *http.Request) { + cpf := r.PathValue("cpf") + + invstrs, err := env.investors.ByCpf(cpf) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(500), 500) + return + } + + invstrsJson, err := json.Marshal(invstrs) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(500), 500) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(invstrsJson) +} diff --git a/models/investor.go b/models/investor.go index aac347409..53f69c60b 100644 --- a/models/investor.go +++ b/models/investor.go @@ -1,17 +1,39 @@ package models -import "database/sql" +import ( + "database/sql" +) type Investor struct { - Id int - Name string - Cpf string + CPF string `json:"cpf"` + Name string `json:"name"` } type InvestorModel struct { - db *sql.DB + DB *sql.DB } -func (m InvestorModel) ByCpf(cpf string) { - m.db.Query("SELECT * FROM investors where cpf = ?", cpf) +func (m InvestorModel) ByCpf(cpf string) ([]Investor, error) { + rows, err := m.DB.Query("SELECT * FROM investors where cpf = ?", cpf) + if err != nil { + return nil, err + } + + var invstrs []Investor + + for rows.Next() { + var invstr Investor + + err := rows.Scan(&invstr.CPF, &invstr.Name) + if err != nil { + return nil, err + } + + invstrs = append(invstrs, invstr) + } + if err = rows.Err(); err != nil { + return nil, err + } + + return invstrs, nil } diff --git a/setup.sql b/setup.sql index 62b23126b..f92a28487 100644 --- a/setup.sql +++ b/setup.sql @@ -2,18 +2,19 @@ drop database if exists investments; create database investments; use investments; +create table investors ( + cpf varchar(11) not null, + name text not null, + + primary key (cpf) +); + create table investments ( id int auto_increment, amount decimal(15, 2) not null, creation_date date not null, - owner_id int, + investor_cpf varchar(11), + primary key (id), - foreign key (owner_id) references owner(id) + foreign key (investor_cpf) references investors(cpf) ); - -create table owners { - id int auto_increment, - name text not null, - cpf text not null, - primary key (id) -} From a847087aa6936b584285d6a1752b804e5a35f43c Mon Sep 17 00:00:00 2001 From: Gustavo Minoru Kussunoki <32077994+causeurgnocchi@users.noreply.github.com> Date: Wed, 22 Jan 2025 14:47:57 +0000 Subject: [PATCH 04/18] Add helper function that parses body --- helpers.go | 81 ++++++++++++++++++++++++++++++++++++++++++++++ main.go | 1 + models/investor.go | 9 ++++++ 3 files changed, 91 insertions(+) create mode 100644 helpers.go diff --git a/helpers.go b/helpers.go new file mode 100644 index 000000000..33191171e --- /dev/null +++ b/helpers.go @@ -0,0 +1,81 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) error { + ct := r.Header.Get("Content-Type") + + if ct != "" { + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])) + if mediaType != "application/json" { + msg := "Content-Type header is not application/json" + return &malformedRequest{status: http.StatusUnsupportedMediaType, msg: msg} + } + } + + r.Body = http.MaxBytesReader(w, r.Body, 1048576) + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := json.NewDecoder(r.Body).Decode(&dst) + + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + + switch { + case errors.As(err, &syntaxError): + msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset) + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + + case errors.Is(err, io.ErrUnexpectedEOF): + msg := "Request body contains badly-formed JSON" + http.Error(w, msg, http.StatusBadRequest) + + case errors.As(err, &unmarshalTypeError): + msg := fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset) + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field %s") + msg := fmt.Sprintf("Request body cpntains unknown field %s", fieldName) + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + + case errors.Is(err, io.EOF): + msg := "Request body must not be empty" + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + + case err.Error() == "http: request body too large": + msg := "Request body must not be larger than 1MB" + return &malformedRequest{status: http.StatusRequestEntityTooLarge, msg: msg} + + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if !errors.Is(err, io.EOF) { + msg := "Request body must only contain a single JSON object" + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + } + + return nil +} + +type malformedRequest struct { + status int + msg string +} + +func (mr * malformedRequest) Error() string { + return mr.msg +} \ No newline at end of file diff --git a/main.go b/main.go index ada1ca46d..3c784e9b1 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ func main() { type Env struct { investors interface { + Create(invstr models.Investor) error ByCpf(cpf string) ([]models.Investor, error) } } diff --git a/models/investor.go b/models/investor.go index 53f69c60b..2e0a4e533 100644 --- a/models/investor.go +++ b/models/investor.go @@ -13,6 +13,15 @@ type InvestorModel struct { DB *sql.DB } +func (m InvestorModel) Create(invstr Investor) error { + _, err := m.DB.Exec("INSERT INTO investors VALUES (?, ?)", invstr.CPF, invstr.Name); + if err != nil { + return err + } + + return nil +} + func (m InvestorModel) ByCpf(cpf string) ([]Investor, error) { rows, err := m.DB.Query("SELECT * FROM investors where cpf = ?", cpf) if err != nil { From 7647fada328f84f3c1382b722b81368d41b46951 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Wed, 22 Jan 2025 21:40:29 -0300 Subject: [PATCH 05/18] Add endpoint for investor creation --- helpers.go | 16 ++++++++-------- main.go | 39 +++++++++++++++++++++++++++++++++++++-- models/investor.go | 2 +- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/helpers.go b/helpers.go index 33191171e..680afff02 100644 --- a/helpers.go +++ b/helpers.go @@ -11,7 +11,7 @@ import ( func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) error { ct := r.Header.Get("Content-Type") - + if ct != "" { mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])) if mediaType != "application/json" { @@ -21,12 +21,12 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err } r.Body = http.MaxBytesReader(w, r.Body, 1048576) - + dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() err := json.NewDecoder(r.Body).Decode(&dst) - + if err != nil { var syntaxError *json.SyntaxError var unmarshalTypeError *json.UnmarshalTypeError @@ -43,7 +43,7 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err case errors.As(err, &unmarshalTypeError): msg := fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset) return &malformedRequest{status: http.StatusBadRequest, msg: msg} - + case strings.HasPrefix(err.Error(), "json: unknown field "): fieldName := strings.TrimPrefix(err.Error(), "json: unknown field %s") msg := fmt.Sprintf("Request body cpntains unknown field %s", fieldName) @@ -52,7 +52,7 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err case errors.Is(err, io.EOF): msg := "Request body must not be empty" return &malformedRequest{status: http.StatusBadRequest, msg: msg} - + case err.Error() == "http: request body too large": msg := "Request body must not be larger than 1MB" return &malformedRequest{status: http.StatusRequestEntityTooLarge, msg: msg} @@ -73,9 +73,9 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err type malformedRequest struct { status int - msg string + msg string } -func (mr * malformedRequest) Error() string { +func (mr *malformedRequest) Error() string { return mr.msg -} \ No newline at end of file +} diff --git a/main.go b/main.go index 3c784e9b1..af2d47741 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "encoding/json" + "errors" "log" "net/http" @@ -21,6 +22,7 @@ func main() { env := &Env{investors: &models.InvestorModel{DB: db}} http.HandleFunc("/api/investors/{cpf}", env.investorsByCpf) + http.HandleFunc("/api/investors", env.investorsCreate) http.ListenAndServe(":8080", nil) } @@ -31,20 +33,53 @@ type Env struct { } } +func (env Env) investorsCreate(w http.ResponseWriter, r *http.Request) { + var invstr models.Investor + + err := decodeJsonBody(w, r, invstr) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + + err = env.investors.Create(invstr) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + invstrJson, err := json.Marshal(invstr) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(invstrJson) +} + func (env Env) investorsByCpf(w http.ResponseWriter, r *http.Request) { cpf := r.PathValue("cpf") invstrs, err := env.investors.ByCpf(cpf) if err != nil { log.Print(err) - http.Error(w, http.StatusText(500), 500) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } invstrsJson, err := json.Marshal(invstrs) if err != nil { log.Print(err) - http.Error(w, http.StatusText(500), 500) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/models/investor.go b/models/investor.go index 2e0a4e533..f69d90ec4 100644 --- a/models/investor.go +++ b/models/investor.go @@ -14,7 +14,7 @@ type InvestorModel struct { } func (m InvestorModel) Create(invstr Investor) error { - _, err := m.DB.Exec("INSERT INTO investors VALUES (?, ?)", invstr.CPF, invstr.Name); + _, err := m.DB.Exec("INSERT INTO investors VALUES (?, ?)", invstr.CPF, invstr.Name) if err != nil { return err } From 081bc9371a394dc06a04fa08b080f703981b83d9 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Fri, 24 Jan 2025 11:05:52 -0300 Subject: [PATCH 06/18] Add some tests for the invstors endpoint --- .vscode/launch.json | 15 ------- docker-compose.yaml | 3 +- main.go | 96 +++++++++++++++++++++++---------------------- main_test.go | 91 ++++++++++++++++++++++++++++++++++++++++++ models/investor.go | 32 ++++++--------- 5 files changed, 155 insertions(+), 82 deletions(-) delete mode 100644 .vscode/launch.json create mode 100644 main_test.go diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index d98e72601..000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Debug", - "type": "go", - "request": "launch", - "mode": "debug", - "program": "${workspaceRoot}" - } - ] -} diff --git a/docker-compose.yaml b/docker-compose.yaml index 599f20a07..d25484421 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,4 @@ -# Using root/example as user/password credentials -# (this is just an example, not intended to be a production configuration) +# Using root/example as user/password credentials (not advised for production) services: db: diff --git a/main.go b/main.go index af2d47741..b8f273706 100644 --- a/main.go +++ b/main.go @@ -20,69 +20,73 @@ func main() { defer db.Close() env := &Env{investors: &models.InvestorModel{DB: db}} - - http.HandleFunc("/api/investors/{cpf}", env.investorsByCpf) - http.HandleFunc("/api/investors", env.investorsCreate) + + http.HandleFunc("/api/investors?", env.investorsIndex) http.ListenAndServe(":8080", nil) } type Env struct { investors interface { Create(invstr models.Investor) error - ByCpf(cpf string) ([]models.Investor, error) + ByCPF(cpf string) (*models.Investor, error) } } -func (env Env) investorsCreate(w http.ResponseWriter, r *http.Request) { - var invstr models.Investor +func (env Env) investorsIndex(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + var invstr models.Investor + + err := decodeJsonBody(w, r, &invstr) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } - err := decodeJsonBody(w, r, invstr) - if err != nil { - var mr *malformedRequest + err = env.investors.Create(invstr) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } - if errors.As(err, &mr) { - http.Error(w, mr.msg, mr.status) - } else { + invstrJson, err := json.Marshal(invstr) + if err != nil { + log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } - return - } - err = env.investors.Create(invstr) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + w.Header().Set("Content-Type", "application/json") + w.Write(invstrJson) - invstrJson, err := json.Marshal(invstr) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + case http.MethodGet: + cpf := r.URL.Query().Get("cpf") - w.Header().Set("Content-Type", "application/json") - w.Write(invstrJson) -} + invstrs, err := env.investors.ByCPF(cpf) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } -func (env Env) investorsByCpf(w http.ResponseWriter, r *http.Request) { - cpf := r.PathValue("cpf") + invstrsJson, err := json.Marshal(invstrs) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } - invstrs, err := env.investors.ByCpf(cpf) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + w.Header().Set("Content-Type", "application/json") + w.Write(invstrsJson) - invstrsJson, err := json.Marshal(invstrs) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return + default: + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) } - - w.Header().Set("Content-Type", "application/json") - w.Write(invstrsJson) -} +} \ No newline at end of file diff --git a/main_test.go b/main_test.go new file mode 100644 index 000000000..b7b7a1e1b --- /dev/null +++ b/main_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "causeurgnocchi/backend-test/models" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +var mux *http.ServeMux +var env *Env + +func ConfigTest(t *testing.T) { + t.Helper() + + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments") + if err != nil { + t.Fatal(err) + } + + db.SetMaxIdleConns(1) + db.SetMaxOpenConns(1) + + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + + env = &Env{investors: &models.InvestorModel{DB: tx}} + + mux = http.NewServeMux() + mux.HandleFunc("/api/investors", env.investorsIndex) + + t.Cleanup(func() { + tx.Rollback() + db.Close() + }) +} + +func TestInvestorsCreate(t *testing.T) { + ConfigTest(t) + + invstr := &models.Investor{ + CPF: "95130357000", + Name: "Lazlo Varga", + } + invstrJson, _ := json.Marshal(invstr) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/investors", bytes.NewBuffer(invstrJson)) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + if body := rec.Body.String(); body != string(invstrJson) { + t.Errorf("Expected investor that was created. Got %s", body) + } +} + +func TestInvestorsByCPF(t *testing.T) { + ConfigTest(t) + + const CPF = "95130357000" + + invstr := models.Investor{ + CPF: CPF, + Name: "Lazlo Varga", + } + + env.investors.Create(invstr) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/investors?cpf=" + CPF, nil) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + invstrJson, _ := json.Marshal(invstr) + if body := rec.Body.String(); body != string(invstrJson) { + t.Errorf("Expected investor of CPF %s. Got %s", CPF, body) + } +} \ No newline at end of file diff --git a/models/investor.go b/models/investor.go index f69d90ec4..8c9ee846b 100644 --- a/models/investor.go +++ b/models/investor.go @@ -10,7 +10,7 @@ type Investor struct { } type InvestorModel struct { - DB *sql.DB + DB Database } func (m InvestorModel) Create(invstr Investor) error { @@ -22,27 +22,21 @@ func (m InvestorModel) Create(invstr Investor) error { return nil } -func (m InvestorModel) ByCpf(cpf string) ([]Investor, error) { - rows, err := m.DB.Query("SELECT * FROM investors where cpf = ?", cpf) +func (m InvestorModel) ByCPF(cpf string) (*Investor, error) { + var invstr Investor + + err := m.DB.QueryRow("SELECT * FROM investors where cpf = ?", cpf).Scan(&invstr.CPF, &invstr.Name) if err != nil { return nil, err } - var invstrs []Investor - - for rows.Next() { - var invstr Investor - - err := rows.Scan(&invstr.CPF, &invstr.Name) - if err != nil { - return nil, err - } - - invstrs = append(invstrs, invstr) - } - if err = rows.Err(); err != nil { - return nil, err - } + return &invstr, nil +} - return invstrs, nil +type Database interface { + Query(query string, args ...interface{}) (*sql.Rows, error) + + QueryRow(query string, args ...interface{}) *sql.Row + + Exec(query string, args ...interface{}) (sql.Result, error) } From 68e3bd432ea32b9a048566dd6f2508e7c07a64d9 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Fri, 24 Jan 2025 16:17:26 -0300 Subject: [PATCH 07/18] Add CPF validation --- go.mod | 17 +++++++++++-- go.sum | 28 ++++++++++++++++++++++ main.go | 60 ++++++++++++++++++++++++++++++++++++++++++++-- main_test.go | 56 +++++++++++++++++++++---------------------- models/investor.go | 14 +++++------ 5 files changed, 136 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index f21bdd22c..4f9561f01 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,19 @@ module causeurgnocchi/backend-test go 1.23.1 -require github.com/go-sql-driver/mysql v1.8.1 +require ( + github.com/go-playground/validator/v10 v10.24.0 + github.com/go-sql-driver/mysql v1.8.1 +) -require filippo.io/edwards25519 v1.1.0 // indirect +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum index 19dbcece6..225fd53b2 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,32 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index b8f273706..ef75c1fa4 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,11 @@ import ( "errors" "log" "net/http" + "strconv" "causeurgnocchi/backend-test/models" + "github.com/go-playground/validator/v10" _ "github.com/go-sql-driver/mysql" ) @@ -20,7 +22,7 @@ func main() { defer db.Close() env := &Env{investors: &models.InvestorModel{DB: db}} - + http.HandleFunc("/api/investors?", env.investorsIndex) http.ListenAndServe(":8080", nil) } @@ -49,6 +51,15 @@ func (env Env) investorsIndex(w http.ResponseWriter, r *http.Request) { return } + v := validator.New() + v.RegisterValidation("cpf", validateCPF) + + err = v.Struct(invstr) + if err != nil { + log.Print(err) + return + } + err = env.investors.Create(invstr) if err != nil { log.Print(err) @@ -89,4 +100,49 @@ func (env Env) investorsIndex(w http.ResponseWriter, r *http.Request) { default: http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) } -} \ No newline at end of file +} + +func validateCPF(fl validator.FieldLevel) bool { + f := fl.Field().String() + + if len(f) != 11 { + return false + } + + var cpf [11]int + for i, c := range f { + n, err := strconv.Atoi(string(c)) + if err != nil { + log.Print(err.Error()) + } + cpf[i] = n + } + + sum1 := 0 + for i := 0; i < 9; i++ { + sum1 += cpf[i] * (10 - i) + } + + validation1 := (sum1 * 10) % 11 + if validation1 == 10 { + validation1 = 0 + } + if validation1 != cpf[9] { + return false + } + + sum2 := validation1 * 2 + for i := 0; i < 9; i++ { + sum2 += cpf[i] * (11 - i) + } + + validation2 := (sum2 * 10) % 11 + if validation2 == 10 { + validation2 = 0 + } + if validation2 != cpf[10] { + return false + } + + return true +} diff --git a/main_test.go b/main_test.go index b7b7a1e1b..ea65d302e 100644 --- a/main_test.go +++ b/main_test.go @@ -29,9 +29,9 @@ func ConfigTest(t *testing.T) { t.Fatal(err) } - env = &Env{investors: &models.InvestorModel{DB: tx}} + env = &Env{investors: &models.InvestorModel{DB: tx}} - mux = http.NewServeMux() + mux = http.NewServeMux() mux.HandleFunc("/api/investors", env.investorsIndex) t.Cleanup(func() { @@ -41,10 +41,10 @@ func ConfigTest(t *testing.T) { } func TestInvestorsCreate(t *testing.T) { - ConfigTest(t) - + ConfigTest(t) + invstr := &models.Investor{ - CPF: "95130357000", + CPF: "95130357000", Name: "Lazlo Varga", } invstrJson, _ := json.Marshal(invstr) @@ -52,40 +52,40 @@ func TestInvestorsCreate(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/investors", bytes.NewBuffer(invstrJson)) - mux.ServeHTTP(rec, req) + mux.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) - } + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } - if body := rec.Body.String(); body != string(invstrJson) { - t.Errorf("Expected investor that was created. Got %s", body) - } + if body := rec.Body.String(); body != string(invstrJson) { + t.Errorf("Expected investor that was created. Got %s", body) + } } func TestInvestorsByCPF(t *testing.T) { - ConfigTest(t) + ConfigTest(t) - const CPF = "95130357000" + const CPF = "95130357000" - invstr := models.Investor{ - CPF: CPF, + invstr := models.Investor{ + CPF: CPF, Name: "Lazlo Varga", } - - env.investors.Create(invstr) + + env.investors.Create(invstr) rec := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/api/investors?cpf=" + CPF, nil) + req := httptest.NewRequest("GET", "/api/investors?cpf="+CPF, nil) - mux.ServeHTTP(rec, req) + mux.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) - } + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } - invstrJson, _ := json.Marshal(invstr) - if body := rec.Body.String(); body != string(invstrJson) { - t.Errorf("Expected investor of CPF %s. Got %s", CPF, body) - } -} \ No newline at end of file + invstrJson, _ := json.Marshal(invstr) + if body := rec.Body.String(); body != string(invstrJson) { + t.Errorf("Expected investor of CPF %s. Got %s", CPF, body) + } +} diff --git a/models/investor.go b/models/investor.go index 8c9ee846b..220db7e20 100644 --- a/models/investor.go +++ b/models/investor.go @@ -5,8 +5,8 @@ import ( ) type Investor struct { - CPF string `json:"cpf"` - Name string `json:"name"` + CPF string `json:"cpf" validate:"cpf"` + Name string `json:"name" validate:"required"` } type InvestorModel struct { @@ -34,9 +34,9 @@ func (m InvestorModel) ByCPF(cpf string) (*Investor, error) { } type Database interface { - Query(query string, args ...interface{}) (*sql.Rows, error) - - QueryRow(query string, args ...interface{}) *sql.Row - - Exec(query string, args ...interface{}) (sql.Result, error) + Query(query string, args ...interface{}) (*sql.Rows, error) + + QueryRow(query string, args ...interface{}) *sql.Row + + Exec(query string, args ...interface{}) (sql.Result, error) } From 5ff3ba865e4125ff25655c2ec4053ff64134353d Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Sat, 25 Jan 2025 16:29:00 -0300 Subject: [PATCH 08/18] Add investment creation endpoint --- .gitignore | 0 main.go | 79 +++++++++++++++++++++++++++++++++++++------- main_test.go | 40 +++++++++++++++++++++- models/database.go | 11 ++++++ models/investment.go | 38 +++++++++++++++++++++ models/investor.go | 14 +------- setup.sql | 3 +- 7 files changed, 158 insertions(+), 27 deletions(-) delete mode 100644 .gitignore create mode 100644 models/database.go create mode 100644 models/investment.go diff --git a/.gitignore b/.gitignore deleted file mode 100644 index e69de29bb..000000000 diff --git a/main.go b/main.go index ef75c1fa4..86e03b82d 100644 --- a/main.go +++ b/main.go @@ -21,17 +21,24 @@ func main() { } defer db.Close() - env := &Env{investors: &models.InvestorModel{DB: db}} + env := &Env{ + investors: &models.InvestorModel{DB: db}, + investments: &models.InvestmentModel{DB: db}, + } - http.HandleFunc("/api/investors?", env.investorsIndex) + http.HandleFunc("/api/investors", env.investorsIndex) + http.HandleFunc("/api/investments", env.investmentsIndex) http.ListenAndServe(":8080", nil) } type Env struct { investors interface { - Create(invstr models.Investor) error + Create(i models.Investor) error ByCPF(cpf string) (*models.Investor, error) } + investments interface { + Create(i models.InvestmentCreationDTO) error + } } func (env Env) investorsIndex(w http.ResponseWriter, r *http.Request) { @@ -102,6 +109,54 @@ func (env Env) investorsIndex(w http.ResponseWriter, r *http.Request) { } } +func (env Env) investmentsIndex(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + var inv models.InvestmentCreationDTO + + err := decodeJsonBody(w, r, &inv) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + + v := validator.New() + v.RegisterValidation("cpf", validateCPF) + + err = v.Struct(inv) + if err != nil { + log.Print(err) + return + } + + err = env.investments.Create(inv) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + invJson, err := json.Marshal(inv) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(invJson) + + default: + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } +} + func validateCPF(fl validator.FieldLevel) bool { f := fl.Field().String() @@ -123,24 +178,24 @@ func validateCPF(fl validator.FieldLevel) bool { sum1 += cpf[i] * (10 - i) } - validation1 := (sum1 * 10) % 11 - if validation1 == 10 { - validation1 = 0 + validator1 := (sum1 * 10) % 11 + if validator1 == 10 { + validator1 = 0 } - if validation1 != cpf[9] { + if validator1 != cpf[9] { return false } - sum2 := validation1 * 2 + sum2 := validator1 * 2 for i := 0; i < 9; i++ { sum2 += cpf[i] * (11 - i) } - validation2 := (sum2 * 10) % 11 - if validation2 == 10 { - validation2 = 0 + validator2 := (sum2 * 10) % 11 + if validator2 == 10 { + validator2 = 0 } - if validation2 != cpf[10] { + if validator2 != cpf[10] { return false } diff --git a/main_test.go b/main_test.go index ea65d302e..d2eef7faf 100644 --- a/main_test.go +++ b/main_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) var mux *http.ServeMux @@ -29,10 +30,14 @@ func ConfigTest(t *testing.T) { t.Fatal(err) } - env = &Env{investors: &models.InvestorModel{DB: tx}} + env = &Env{ + investors: &models.InvestorModel{DB: tx}, + investments: &models.InvestmentModel{DB: tx}, + } mux = http.NewServeMux() mux.HandleFunc("/api/investors", env.investorsIndex) + mux.HandleFunc("/api/investments", env.investmentsIndex) t.Cleanup(func() { tx.Rollback() @@ -89,3 +94,36 @@ func TestInvestorsByCPF(t *testing.T) { t.Errorf("Expected investor of CPF %s. Got %s", CPF, body) } } + +func TestInvestmentsCreate(t *testing.T) { + ConfigTest(t) + + invstr := &models.Investor{ + CPF: "95130357000", + Name: "Lazlo Varga", + } + invstrJson, _ := json.Marshal(invstr) + + rec := httptest.NewRecorder() + invstrReq := httptest.NewRequest("POST", "/api/investors", bytes.NewBuffer(invstrJson)) + mux.ServeHTTP(rec, invstrReq) + + inv := &models.InvestmentCreationDTO{ + InitialAmount: 100000, + CreationDate: time.Now().AddDate(0, 0, -1).Format(time.DateOnly), + InvestorCPF: "95130357000", + } + invJson, _ := json.Marshal(inv) + + rec = httptest.NewRecorder() + invReq := httptest.NewRequest("POST", "/api/investments", bytes.NewBuffer(invJson)) + mux.ServeHTTP(rec, invReq) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + if body := rec.Body.String(); body != string(invJson) { + t.Errorf("Expected investment that was created. Got %s", body) + } +} diff --git a/models/database.go b/models/database.go new file mode 100644 index 000000000..442c8ecd1 --- /dev/null +++ b/models/database.go @@ -0,0 +1,11 @@ +package models + +import "database/sql" + +type Database interface { + Query(query string, args ...interface{}) (*sql.Rows, error) + + QueryRow(query string, args ...interface{}) *sql.Row + + Exec(query string, args ...interface{}) (sql.Result, error) +} diff --git a/models/investment.go b/models/investment.go new file mode 100644 index 000000000..72ed97453 --- /dev/null +++ b/models/investment.go @@ -0,0 +1,38 @@ +package models + +import ( + "time" +) + +type Investment struct { + Id int `json:"id"` + InitialAmount int `json:"amount" validate:"required,gt=0"` + Balance int `json:"balance"` + CreationDate time.Time `json:"creation_date" validate:"required"` + Investor Investor `json:"investor" validate:"required"` +} + +type InvestmentCreationDTO struct { + InitialAmount int `json:"amount" validate:"required,gt=0"` + CreationDate string `json:"creation_date" validate:"required,datetime=2006-01-02"` + InvestorCPF string `json:"investor_cpf" validate:"required,cpf"` +} + +type InvestmentModel struct { + DB Database +} + +func (m InvestmentModel) Create(inv InvestmentCreationDTO) error { + _, err := m.DB.Exec( + "INSERT INTO investments (initial_amount, balance, creation_date, investor_cpf) VALUES (?, ?, ?, ?)", + inv.InitialAmount, + inv.InitialAmount, + inv.CreationDate, + inv.InvestorCPF, + ) + if err != nil { + return err + } + + return nil +} diff --git a/models/investor.go b/models/investor.go index 220db7e20..be31b5365 100644 --- a/models/investor.go +++ b/models/investor.go @@ -1,11 +1,7 @@ package models -import ( - "database/sql" -) - type Investor struct { - CPF string `json:"cpf" validate:"cpf"` + CPF string `json:"cpf" validate:"required,cpf"` Name string `json:"name" validate:"required"` } @@ -32,11 +28,3 @@ func (m InvestorModel) ByCPF(cpf string) (*Investor, error) { return &invstr, nil } - -type Database interface { - Query(query string, args ...interface{}) (*sql.Rows, error) - - QueryRow(query string, args ...interface{}) *sql.Row - - Exec(query string, args ...interface{}) (sql.Result, error) -} diff --git a/setup.sql b/setup.sql index f92a28487..18abb870e 100644 --- a/setup.sql +++ b/setup.sql @@ -11,7 +11,8 @@ create table investors ( create table investments ( id int auto_increment, - amount decimal(15, 2) not null, + initial_amount int not null, + balance int not null, creation_date date not null, investor_cpf varchar(11), From ac2f5d096a5a3f2275fccde5ef1619316d10def7 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Sat, 25 Jan 2025 18:48:31 -0300 Subject: [PATCH 09/18] Add validation for creationDate of investment --- main.go | 14 +++++++++++++- main_test.go | 2 +- models/investment.go | 10 +++++----- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index 86e03b82d..3a8a5de51 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "strconv" + "time" "causeurgnocchi/backend-test/models" @@ -127,7 +128,7 @@ func (env Env) investmentsIndex(w http.ResponseWriter, r *http.Request) { } v := validator.New() - v.RegisterValidation("cpf", validateCPF) + v.RegisterValidation("notfuture", notFuture) err = v.Struct(inv) if err != nil { @@ -201,3 +202,14 @@ func validateCPF(fl validator.FieldLevel) bool { return true } + +func notFuture(fl validator.FieldLevel) bool { + today := time.Now().Truncate(24 * time.Hour) + creationDate, err := time.Parse(time.DateOnly, fl.Field().String()) + if err != nil { + log.Print(err) + return false + } + + return !creationDate.After(today) +} diff --git a/main_test.go b/main_test.go index d2eef7faf..27f224574 100644 --- a/main_test.go +++ b/main_test.go @@ -110,7 +110,7 @@ func TestInvestmentsCreate(t *testing.T) { inv := &models.InvestmentCreationDTO{ InitialAmount: 100000, - CreationDate: time.Now().AddDate(0, 0, -1).Format(time.DateOnly), + CreationDate: time.Now().Format(time.DateOnly), InvestorCPF: "95130357000", } invJson, _ := json.Marshal(inv) diff --git a/models/investment.go b/models/investment.go index 72ed97453..353334ee1 100644 --- a/models/investment.go +++ b/models/investment.go @@ -6,16 +6,16 @@ import ( type Investment struct { Id int `json:"id"` - InitialAmount int `json:"amount" validate:"required,gt=0"` + InitialAmount int `json:"amount"` Balance int `json:"balance"` - CreationDate time.Time `json:"creation_date" validate:"required"` - Investor Investor `json:"investor" validate:"required"` + CreationDate time.Time `json:"creation_date"` + Investor Investor `json:"investor"` } type InvestmentCreationDTO struct { InitialAmount int `json:"amount" validate:"required,gt=0"` - CreationDate string `json:"creation_date" validate:"required,datetime=2006-01-02"` - InvestorCPF string `json:"investor_cpf" validate:"required,cpf"` + CreationDate string `json:"creation_date" validate:"required,notfuture,datetime=2006-01-02"` + InvestorCPF string `json:"investor_cpf" validate:"required"` } type InvestmentModel struct { From 48a17663f64df4063ab33092094265199e1d75e0 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Sat, 25 Jan 2025 20:02:39 -0300 Subject: [PATCH 10/18] Schedule the application of interest via MySQL's setup script --- setup.sql | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/setup.sql b/setup.sql index 18abb870e..61c868100 100644 --- a/setup.sql +++ b/setup.sql @@ -1,21 +1,30 @@ -drop database if exists investments; -create database investments; -use investments; +DROP DATABASE IF EXISTS investments; +CREATE DATABASE investments; +USE investments; -create table investors ( - cpf varchar(11) not null, - name text not null, +CREATE TABLE investors ( + cpf VARCHAR(11) NOT NULL, + name TEXT NOT NULL, - primary key (cpf) + PRIMARY KEY (cpf) ); -create table investments ( - id int auto_increment, - initial_amount int not null, - balance int not null, - creation_date date not null, - investor_cpf varchar(11), +CREATE TABLE investments ( + id INT AUTO_INCREMENT, + initial_amount INT NOT NULL, + balance INT NOT NULL, + creation_date DATE NOT NULL, + investor_cpf VARCHAR(11), - primary key (id), - foreign key (investor_cpf) references investors(cpf) + PRIMARY KEY (id), + FOREIGN KEY (investor_cpf) REFERENCES investors(cpf) ); + +CREATE EVENT apply_interest + ON SCHEDULE + EVERY 1 DAY + STARTS (TIMESTAMP(CURRENT_DATE) + INTERVAL 1 DAY) + DO + UPDATE investments + SET balance = balance * 1.0052 + WHERE IF(DAY(creation_date) > LAST_DAY(CURRENT_DATE), LAST_DAY(CURRENT_DATE), DAY(creation_date)) = DAY(CURRENT_DATE); From 3e0f0221d087984450e22bf2f93deae43cc62838 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Wed, 29 Jan 2025 14:34:24 -0300 Subject: [PATCH 11/18] Create unit tests for data access layer --- docker-compose.yaml | 2 +- helpers.go | 3 +- main.go | 218 ++++++++++++++++++++++++++++++-------- main_test.go | 14 +-- models/investment.go | 53 +++++++-- models/investment_test.go | 48 +++++++++ models/investor.go | 18 ++-- models/investor_test.go | 39 +++++++ models/withdrawal.go | 85 +++++++++++++++ models/withdrawal_test.go | 57 ++++++++++ open-api.yaml | 188 ++++++++++++++++++++++++++++++++ setup.sql | 33 ++++-- 12 files changed, 679 insertions(+), 79 deletions(-) create mode 100644 models/investment_test.go create mode 100644 models/investor_test.go create mode 100644 models/withdrawal.go create mode 100644 models/withdrawal_test.go create mode 100644 open-api.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml index d25484421..07cde5ac7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,4 @@ -# Using root/example as user/password credentials (not advised for production) +# Using root/example as user/password credentials (not intended for production) services: db: diff --git a/helpers.go b/helpers.go index 680afff02..76e3dfacd 100644 --- a/helpers.go +++ b/helpers.go @@ -16,6 +16,7 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])) if mediaType != "application/json" { msg := "Content-Type header is not application/json" + return &malformedRequest{status: http.StatusUnsupportedMediaType, msg: msg} } } @@ -46,7 +47,7 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err case strings.HasPrefix(err.Error(), "json: unknown field "): fieldName := strings.TrimPrefix(err.Error(), "json: unknown field %s") - msg := fmt.Sprintf("Request body cpntains unknown field %s", fieldName) + msg := fmt.Sprintf("Request body contains unknown field %s", fieldName) return &malformedRequest{status: http.StatusBadRequest, msg: msg} case errors.Is(err, io.EOF): diff --git a/main.go b/main.go index 3a8a5de51..b081f7cf0 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "log" "net/http" "strconv" @@ -12,98 +13,131 @@ import ( "causeurgnocchi/backend-test/models" "github.com/go-playground/validator/v10" - _ "github.com/go-sql-driver/mysql" + "github.com/go-sql-driver/mysql" ) +const MYSQL_KEY_EXITS = 1062 + func main() { - db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments") + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") if err != nil { log.Fatal(err) } defer db.Close() env := &Env{ - investors: &models.InvestorModel{DB: db}, - investments: &models.InvestmentModel{DB: db}, + investors: &models.InvestorModel{Db: db}, + investments: &models.InvestmentModel{Db: db}, + withdrawals: &models.WithdrawalModel{Db: db}, } http.HandleFunc("/api/investors", env.investorsIndex) http.HandleFunc("/api/investments", env.investmentsIndex) + http.HandleFunc("/api/witdrawals", env.withdrawalsIndex) + http.ListenAndServe(":8080", nil) } type Env struct { investors interface { - Create(i models.Investor) error - ByCPF(cpf string) (*models.Investor, error) + Create(invstr models.Investor) error + ByCpf(cpf string) (*models.Investor, error) } + investments interface { - Create(i models.InvestmentCreationDTO) error + Create(inv models.InvestmentCreationDTO) (int, error) + ById(id int) (*models.Investment, error) + } + + withdrawals interface { + Create(w models.WithdrawalCreationDTO) (int, error) + ById(id int) (*models.Withdrawal, error) } } func (env Env) investorsIndex(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - var invstr models.Investor + var i models.Investor - err := decodeJsonBody(w, r, &invstr) + err := decodeJsonBody(w, r, &i) if err != nil { var mr *malformedRequest if errors.As(err, &mr) { http.Error(w, mr.msg, mr.status) } else { + log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } + return } v := validator.New() - v.RegisterValidation("cpf", validateCPF) + v.RegisterValidation("cpf", func(fl validator.FieldLevel) bool { + cpf := fl.Field().String() - err = v.Struct(invstr) + return validateCPF(cpf) + }) + + err = v.Struct(i) if err != nil { - log.Print(err) + errs := err.(validator.ValidationErrors) + msg := fmt.Sprintf("Invalid investor information:\n%s", errs) + http.Error(w, msg, http.StatusBadRequest) + return } - err = env.investors.Create(invstr) + err = env.investors.Create(i) if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + if err.(*mysql.MySQLError).Number == MYSQL_KEY_EXITS { + msg := "CPF provided is already being used" + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return } - invstrJson, err := json.Marshal(invstr) + iJson, err := json.Marshal(i) if err != nil { log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } w.Header().Set("Content-Type", "application/json") - w.Write(invstrJson) + w.Write(iJson) case http.MethodGet: cpf := r.URL.Query().Get("cpf") - invstrs, err := env.investors.ByCPF(cpf) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return + if !validateCPF(cpf) { + http.Error(w, "Invalid CPF", http.StatusBadRequest) } - invstrsJson, err := json.Marshal(invstrs) + i, err := env.investors.ByCpf(cpf) if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("No record of an investor with CPF %s has been found", cpf) + http.Error(w, msg, http.StatusNotFound) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return } + iJson, _ := json.Marshal(i) + w.Header().Set("Content-Type", "application/json") - w.Write(invstrsJson) + w.Write(iJson) default: http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) @@ -113,43 +147,91 @@ func (env Env) investorsIndex(w http.ResponseWriter, r *http.Request) { func (env Env) investmentsIndex(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - var inv models.InvestmentCreationDTO + var dto models.InvestmentCreationDTO - err := decodeJsonBody(w, r, &inv) + err := decodeJsonBody(w, r, &dto) if err != nil { var mr *malformedRequest if errors.As(err, &mr) { http.Error(w, mr.msg, mr.status) } else { + log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } + return } v := validator.New() v.RegisterValidation("notfuture", notFuture) - err = v.Struct(inv) + err = v.Struct(dto) if err != nil { - log.Print(err) + errs := err.(validator.ValidationErrors) + msg := fmt.Sprintf("Invalid investment information:\n%s", errs) + http.Error(w, msg, http.StatusBadRequest) + return } - err = env.investments.Create(inv) + id, err := env.investments.Create(dto) if err != nil { log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } - invJson, err := json.Marshal(inv) + i, err := env.investments.ById(id) if err != nil { log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } + iJson, _ := json.Marshal(i) + + w.Header().Set("Content-Type", "application/json") + w.Write(iJson) + + case http.MethodGet: + queryParam := r.URL.Query().Get("id") + + id, err := strconv.Atoi(queryParam) + if err != nil { + var msg string + + if errors.Is(err, strconv.ErrSyntax) { + msg = fmt.Sprintf("Investment ID of value %s has a syntax error", queryParam) + http.Error(w, msg, http.StatusBadRequest) + } else if errors.Is(err, strconv.ErrRange) { + msg = fmt.Sprintf("Investment ID of value %s is out of range", queryParam) + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + inv, err := env.investments.ById(id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("No record of an investor with id %d has been found", id) + http.Error(w, msg, http.StatusNotFound) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + invJson, _ := json.Marshal(inv) + w.Header().Set("Content-Type", "application/json") w.Write(invJson) @@ -158,45 +240,96 @@ func (env Env) investmentsIndex(w http.ResponseWriter, r *http.Request) { } } -func validateCPF(fl validator.FieldLevel) bool { - f := fl.Field().String() +func (env Env) withdrawalsIndex(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + var dto models.WithdrawalCreationDTO + + err := decodeJsonBody(w, r, &dto) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + v := validator.New() + + err = v.Struct(dto) + if err != nil { + errs := err.(validator.ValidationErrors) + msg := fmt.Sprintf("Invalid withdrawal information:\n%s", errs) + http.Error(w, msg, http.StatusBadRequest) - if len(f) != 11 { + return + } + + id, err := env.withdrawals.Create(dto) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + withdrawal, err := env.withdrawals.ById(id) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + withdrawalJson, _ := json.Marshal(withdrawal) + + w.Header().Set("Content-Type", "application/json") + w.Write(withdrawalJson) + } +} + +func validateCPF(cpf string) bool { + if len(cpf) != 11 { return false } - var cpf [11]int - for i, c := range f { + var cpfDigits [11]int + for i, c := range cpf { n, err := strconv.Atoi(string(c)) if err != nil { log.Print(err.Error()) } - cpf[i] = n + cpfDigits[i] = n } sum1 := 0 for i := 0; i < 9; i++ { - sum1 += cpf[i] * (10 - i) + sum1 += cpfDigits[i] * (10 - i) } validator1 := (sum1 * 10) % 11 if validator1 == 10 { validator1 = 0 } - if validator1 != cpf[9] { + if validator1 != cpfDigits[9] { return false } sum2 := validator1 * 2 for i := 0; i < 9; i++ { - sum2 += cpf[i] * (11 - i) + sum2 += cpfDigits[i] * (11 - i) } validator2 := (sum2 * 10) % 11 if validator2 == 10 { validator2 = 0 } - if validator2 != cpf[10] { + if validator2 != cpfDigits[10] { return false } @@ -205,6 +338,7 @@ func validateCPF(fl validator.FieldLevel) bool { func notFuture(fl validator.FieldLevel) bool { today := time.Now().Truncate(24 * time.Hour) + creationDate, err := time.Parse(time.DateOnly, fl.Field().String()) if err != nil { log.Print(err) diff --git a/main_test.go b/main_test.go index 27f224574..8e883b66b 100644 --- a/main_test.go +++ b/main_test.go @@ -17,7 +17,7 @@ var env *Env func ConfigTest(t *testing.T) { t.Helper() - db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments") + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") if err != nil { t.Fatal(err) } @@ -31,13 +31,15 @@ func ConfigTest(t *testing.T) { } env = &Env{ - investors: &models.InvestorModel{DB: tx}, - investments: &models.InvestmentModel{DB: tx}, + investors: &models.InvestorModel{Db: tx}, + investments: &models.InvestmentModel{Db: tx}, + withdrawals: &models.WithdrawalModel{Db: tx}, } mux = http.NewServeMux() mux.HandleFunc("/api/investors", env.investorsIndex) mux.HandleFunc("/api/investments", env.investmentsIndex) + mux.HandleFunc("/api/withdrawal", env.withdrawalsIndex) t.Cleanup(func() { tx.Rollback() @@ -49,7 +51,7 @@ func TestInvestorsCreate(t *testing.T) { ConfigTest(t) invstr := &models.Investor{ - CPF: "95130357000", + Cpf: "95130357000", Name: "Lazlo Varga", } invstrJson, _ := json.Marshal(invstr) @@ -74,7 +76,7 @@ func TestInvestorsByCPF(t *testing.T) { const CPF = "95130357000" invstr := models.Investor{ - CPF: CPF, + Cpf: CPF, Name: "Lazlo Varga", } @@ -99,7 +101,7 @@ func TestInvestmentsCreate(t *testing.T) { ConfigTest(t) invstr := &models.Investor{ - CPF: "95130357000", + Cpf: "95130357000", Name: "Lazlo Varga", } invstrJson, _ := json.Marshal(invstr) diff --git a/models/investment.go b/models/investment.go index 353334ee1..0334b2236 100644 --- a/models/investment.go +++ b/models/investment.go @@ -14,25 +14,58 @@ type Investment struct { type InvestmentCreationDTO struct { InitialAmount int `json:"amount" validate:"required,gt=0"` - CreationDate string `json:"creation_date" validate:"required,notfuture,datetime=2006-01-02"` + CreationDate string `json:"creation_date" validate:"required,datetime=2006-01-02,notfuture"` InvestorCPF string `json:"investor_cpf" validate:"required"` } type InvestmentModel struct { - DB Database + Db Database } -func (m InvestmentModel) Create(inv InvestmentCreationDTO) error { - _, err := m.DB.Exec( +func (m InvestmentModel) Create(dto InvestmentCreationDTO) (int, error) { + r, err := m.Db.Exec( "INSERT INTO investments (initial_amount, balance, creation_date, investor_cpf) VALUES (?, ?, ?, ?)", - inv.InitialAmount, - inv.InitialAmount, - inv.CreationDate, - inv.InvestorCPF, + dto.InitialAmount, + dto.InitialAmount, + dto.CreationDate, + dto.InvestorCPF, ) if err != nil { - return err + return -1, err } - return nil + id, err := r.LastInsertId() + if err != nil { + return -1, err + } + + return int(id), nil +} + +func (m InvestmentModel) ById(id int) (*Investment, error) { + var investment Investment + var cpf string + + r := m.Db.QueryRow("SELECT id, initial_amount, balance, creation_date, investor_cpf FROM investments WHERE id = ?", id) + + err := r.Scan(&investment.Id, &investment.InitialAmount, &investment.Balance, &investment.CreationDate, &cpf) + if err != nil { + return nil, err + } + + investorM := &InvestorModel{Db: m.Db} + + investor, err := investorM.ByCpf(cpf) + if err != nil { + return nil, err + } + + investment.Investor = *investor + return &investment, nil +} + +func (m InvestmentModel) RemoveBalance(id int) error { + _, err := m.Db.Exec("UPDATE investments SET balance = 0 WHERE id = ?", id) + + return err } diff --git a/models/investment_test.go b/models/investment_test.go new file mode 100644 index 000000000..4e66507da --- /dev/null +++ b/models/investment_test.go @@ -0,0 +1,48 @@ +package models + +import ( + "database/sql" + "testing" + + _ "github.com/go-sql-driver/mysql" +) + +func TestInvestments(t *testing.T) { + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + + investorM := InvestorModel{Db: tx} + investmentM := InvestmentModel{Db: tx} + + investor := Investor{ + Cpf: "95130357000", + Name: "Lazlo Varga", + } + + investorM.Create(investor) + + investment := InvestmentCreationDTO{ + InitialAmount: 1000000, + CreationDate: "2025-01-01", + InvestorCPF: "95130357000", + } + + id, err := investmentM.Create(investment) + if err != nil { + t.Errorf("Error creating investment:\n%s", err.Error()) + } + + _, err = investmentM.ById(id) + if err != nil { + t.Errorf("Error retrieving investment:\n%s", err.Error()) + } +} diff --git a/models/investor.go b/models/investor.go index be31b5365..a111794f6 100644 --- a/models/investor.go +++ b/models/investor.go @@ -1,16 +1,16 @@ package models type Investor struct { - CPF string `json:"cpf" validate:"required,cpf"` + Cpf string `json:"cpf" validate:"required,cpf"` Name string `json:"name" validate:"required"` } type InvestorModel struct { - DB Database + Db Database } -func (m InvestorModel) Create(invstr Investor) error { - _, err := m.DB.Exec("INSERT INTO investors VALUES (?, ?)", invstr.CPF, invstr.Name) +func (m InvestorModel) Create(i Investor) error { + _, err := m.Db.Exec("INSERT INTO investors (cpf, name) VALUES (?, ?)", i.Cpf, i.Name) if err != nil { return err } @@ -18,13 +18,15 @@ func (m InvestorModel) Create(invstr Investor) error { return nil } -func (m InvestorModel) ByCPF(cpf string) (*Investor, error) { - var invstr Investor +func (m InvestorModel) ByCpf(cpf string) (*Investor, error) { + r := m.Db.QueryRow("SELECT * FROM investors where cpf = ?", cpf) - err := m.DB.QueryRow("SELECT * FROM investors where cpf = ?", cpf).Scan(&invstr.CPF, &invstr.Name) + var i Investor + + err := r.Scan(&i.Cpf, &i.Name) if err != nil { return nil, err } - return &invstr, nil + return &i, nil } diff --git a/models/investor_test.go b/models/investor_test.go new file mode 100644 index 000000000..71a6f3dc0 --- /dev/null +++ b/models/investor_test.go @@ -0,0 +1,39 @@ +package models + +import ( + "database/sql" + "testing" + + _ "github.com/go-sql-driver/mysql" +) + +func TestInvestors(t *testing.T) { + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + + m := &InvestorModel{Db: tx} + + i := Investor{ + Cpf: "95130357000", + Name: "Lazlo Varga", + } + + err = m.Create(i) + if err != nil { + t.Errorf("Error creating investor:\n%s", err.Error()) + } + + _, err = m.ByCpf(i.Cpf) + if err != nil { + t.Errorf("Error retrieving investor:\n%s", err.Error()) + } +} diff --git a/models/withdrawal.go b/models/withdrawal.go new file mode 100644 index 000000000..977013dfb --- /dev/null +++ b/models/withdrawal.go @@ -0,0 +1,85 @@ +package models + +import ( + "math" + "strconv" + "time" +) + +type Withdrawal struct { + Id int `json:"id"` + GrossAmount int `json:"gross_amount"` + NetAmount int `json:"net_amount"` + Date time.Time `json:"date"` + Investment Investment `json:"investment"` +} + +type WithdrawalCreationDTO struct { + Date time.Time `validate:"required,datetime=2006-01-02"` + InvestmentId int +} + +type WithdrawalModel struct { + Db Database +} + +func (m WithdrawalModel) Create(dto WithdrawalCreationDTO) (int, error) { + im := &InvestmentModel{Db: m.Db} + + inv, err := im.ById(dto.InvestmentId) + if err != nil { + return -1, err + } + + var taxes int + gain := inv.Balance - inv.InitialAmount + + if inv.CreationDate.Before(inv.CreationDate.AddDate(1, 0, 0)) { + taxes = int(math.Floor(float64(gain) * 0.225)) + } else if inv.CreationDate.Before(inv.CreationDate.AddDate(2, 0, 0)) { + taxes = int(math.Floor(float64(gain) * 0.185)) + } else { + taxes = int(math.Floor(float64(gain) * 0.15)) + } + + r, err := m.Db.Exec( + "INSERT INTO withdrawals (gross_amount, net_amount, date, investment_id) VALUES (?, ?, ?, ?)", + inv.Balance, + inv.Balance-taxes, + dto.Date, + dto.InvestmentId, + ) + if err != nil { + return -1, err + } + + id, err := r.LastInsertId() + if err != nil { + return -1, err + } + + return int(id), nil +} + +func (m WithdrawalModel) ById(id int) (*Withdrawal, error) { + var w Withdrawal + var investmentIdStr string + + r := m.Db.QueryRow("SELECT id, gross_amount, net_amount, date, investment_id FROM withdrawals WHERE id = ?", id) + + err := r.Scan(&w.Id, &w.GrossAmount, &w.NetAmount, &w.Date, &investmentIdStr) + if err != nil { + return nil, err + } + + investmentM := &InvestmentModel{Db: m.Db} + investmentId, _ := strconv.Atoi(investmentIdStr) + + investment, err := investmentM.ById(investmentId) + if err != nil { + return nil, err + } + + w.Investment = *investment + return &w, nil +} diff --git a/models/withdrawal_test.go b/models/withdrawal_test.go new file mode 100644 index 000000000..e2e460a89 --- /dev/null +++ b/models/withdrawal_test.go @@ -0,0 +1,57 @@ +package models + +import ( + "database/sql" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +func TestWithdrawalsCreate(t *testing.T) { + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + + investorM := InvestorModel{Db: tx} + investmentM := InvestmentModel{Db: tx} + withdrawalM := WithdrawalModel{Db: tx} + + investor := Investor{ + Cpf: "95130357000", + Name: "Lazlo Varga", + } + + investorM.Create(investor) + + investment := InvestmentCreationDTO{ + InitialAmount: 1000000, + CreationDate: "2025-01-01", + InvestorCPF: "95130357000", + } + + investmentId, _ := investmentM.Create(investment) + + w := WithdrawalCreationDTO{ + Date: time.Now(), + InvestmentId: investmentId, + } + + withdrawalId, err := withdrawalM.Create(w) + if err != nil { + t.Errorf("Error creating withdrawal:\n%s", err.Error()) + } + + _, err = withdrawalM.ById(withdrawalId) + if err != nil { + t.Errorf("Error getting withdrawal:\n%s", err.Error()) + } +} diff --git a/open-api.yaml b/open-api.yaml new file mode 100644 index 000000000..40dbdab05 --- /dev/null +++ b/open-api.yaml @@ -0,0 +1,188 @@ +openapi: 3.0.3 + +info: + title: Coderockr Backend Test - OpenAPI 3.0 + description: |- + A submission for Coderockr's backend development test. + version: 1.0.0 + +servers: + - url: http://localhost:8080/api + +tags: + - name: investors + - name: investments + - name: withdrawals + +paths: + /investors: + post: + tags: + - investors + summary: Add a new investor + operationId: addInvestor + requestBody: + description: Create a new investor + content: + application/json: + schema: + $ref: "#/components/schemas/Investor" + required: true + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Investor" + "400": + description: Invalid input + "500": + description: Internal server error + + get: + tags: + - investors + summary: Find investor by CPF + description: Returns a single investor + operationId: getInvestorByCpf + parameters: + - name: cpf + in: query + description: CPF of investor to return + required: true + schema: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Investor" + "400": + description: Invalid CPF supplied + "404": + description: Investor not found + "500": + description: Internal server error + + /investments: + post: + tags: + - investments + summary: Add a new investment + operationId: addInvestment + requestBody: + description: Create a new investment + content: + application/json: + schema: + $ref: "#/components/schemas/NewInvestment" + required: true + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Investment" + "400": + description: Invalid input + "500": + description: Internal server error + + /withdrawals: + post: + tags: + - withdrawals + summary: Add a new withdrawal + operationId: addWithdrawal + requestBody: + description: Create a new withdrawal + content: + application/json: + schema: + $ref: "#/components/schemas/NewWithdrawal" + required: true + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Investment" + "400": + description: Invalid input + "500": + description: Internal server error + +components: + schemas: + Investor: + properties: + cpf: + type: string + example: 30361099002 + name: + type: string + example: Lazlo Varga + + Investment: + properties: + initialAmount: + type: integer + example: 1000000 + description: Amount of money to be invested expressed in the smallest unit in the respective monetary system + creationDate: + type: string + format: datetime + example: 2024-01-20 + investor: + $ref: "#/components/schemas/Investor" + + NewInvestment: + required: + - initialAmount + - creationDate + properties: + initialAmount: + type: integer + example: 1000000 + description: Amount of money to be invested, expressed in the smallest unit of the respective monetary system + creationDate: + type: string + format: datetime + example: 2024-01-20 + investorId: + type: integer + + Pet: + allOf: + - $ref: "#/components/schemas/NewPet" + - required: + - id + properties: + id: + type: integer + format: int64 + description: Unique id of the pet + + NewPet: + required: + - name + properties: + name: + type: string + description: Name of the pet + tag: + type: string + description: Type of the pet + + requestBodies: + Pet: + description: Investor object that needs to be added + content: + application/json: + schema: + $ref: "#/components/schemas/Investor" diff --git a/setup.sql b/setup.sql index 61c868100..ad7fae95f 100644 --- a/setup.sql +++ b/setup.sql @@ -3,21 +3,32 @@ CREATE DATABASE investments; USE investments; CREATE TABLE investors ( - cpf VARCHAR(11) NOT NULL, - name TEXT NOT NULL, + cpf VARCHAR(11) NOT NULL, + name TEXT NOT NULL, - PRIMARY KEY (cpf) + PRIMARY KEY (cpf) ); CREATE TABLE investments ( - id INT AUTO_INCREMENT, - initial_amount INT NOT NULL, - balance INT NOT NULL, - creation_date DATE NOT NULL, - investor_cpf VARCHAR(11), - - PRIMARY KEY (id), - FOREIGN KEY (investor_cpf) REFERENCES investors(cpf) + id INT AUTO_INCREMENT, + initial_amount INT NOT NULL, + balance INT NOT NULL, + creation_date DATE NOT NULL, + investor_cpf VARCHAR(11), + + PRIMARY KEY (id), + FOREIGN KEY (investor_cpf) REFERENCES investors(cpf) +); + +CREATE TABLE withdrawals ( + id INT AUTO_INCREMENT, + gross_amount INT NOT NULL, + net_amount INT NOT NULL, + date DATE NOT NULL, + investment_id INT, + + PRIMARY KEY (id), + FOREIGN KEY (investment_id) REFERENCES investments(id) ); CREATE EVENT apply_interest From 9e7d47f0f622c26b24f4b3eb53d6462f01580a3e Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Fri, 31 Jan 2025 05:45:22 -0300 Subject: [PATCH 12/18] Add unit testing --- helpers.go => handlers/helpers.go | 20 +- handlers/investment.go | 128 +++++++++++ handlers/investment_test.go | 87 ++++++++ handlers/investor.go | 171 +++++++++++++++ handlers/investor_test.go | 70 ++++++ handlers/withdrawal.go | 123 +++++++++++ handlers/withdrawal_test.go | 85 ++++++++ main.go | 336 ++--------------------------- main_test.go | 131 ----------- models/{database.go => helpers.go} | 2 +- models/investment.go | 8 +- models/investment_test.go | 5 +- models/investor.go | 4 +- models/withdrawal.go | 23 +- models/withdrawal_test.go | 4 +- 15 files changed, 715 insertions(+), 482 deletions(-) rename helpers.go => handlers/helpers.go (99%) create mode 100644 handlers/investment.go create mode 100644 handlers/investment_test.go create mode 100644 handlers/investor.go create mode 100644 handlers/investor_test.go create mode 100644 handlers/withdrawal.go create mode 100644 handlers/withdrawal_test.go delete mode 100644 main_test.go rename models/{database.go => helpers.go} (89%) diff --git a/helpers.go b/handlers/helpers.go similarity index 99% rename from helpers.go rename to handlers/helpers.go index 76e3dfacd..50ba97fbc 100644 --- a/helpers.go +++ b/handlers/helpers.go @@ -1,4 +1,4 @@ -package main +package handlers import ( "encoding/json" @@ -9,6 +9,15 @@ import ( "strings" ) +type malformedRequest struct { + status int + msg string +} + +func (mr *malformedRequest) Error() string { + return mr.msg +} + func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) error { ct := r.Header.Get("Content-Type") @@ -71,12 +80,3 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err return nil } - -type malformedRequest struct { - status int - msg string -} - -func (mr *malformedRequest) Error() string { - return mr.msg -} diff --git a/handlers/investment.go b/handlers/investment.go new file mode 100644 index 000000000..e86c19628 --- /dev/null +++ b/handlers/investment.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "causeurgnocchi/backend-test/models" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "time" + + "github.com/go-playground/validator/v10" +) + +type InvestmentHandler struct { + Investments interface { + Create(inv models.InvestmentCreationDTO) (int, error) + ById(id int) (*models.Investment, error) + } +} + +func (h InvestmentHandler) CreateInvestment(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + + var dto models.InvestmentCreationDTO + + err := decodeJsonBody(w, r, &dto) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + v := validator.New() + v.RegisterValidation("notfuture", notFuture) + + err = v.Struct(dto) + if err != nil { + errs := err.(validator.ValidationErrors) + msg := fmt.Sprintf("Invalid investment information:\n%s", errs) + http.Error(w, msg, http.StatusBadRequest) + + return + } + + id, err := h.Investments.Create(dto) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + i, err := h.Investments.ById(id) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + iJson, _ := json.Marshal(i) + + w.Header().Set("Content-Type", "application/json") + w.Write(iJson) +} + +func (h InvestmentHandler) FindInvestmentById(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + + pv := r.PathValue("id") + + id, err := strconv.Atoi(pv) + if err != nil { + var msg string + + if errors.Is(err, strconv.ErrSyntax) { + msg = fmt.Sprintf("Investment ID of value %s has a syntax error", pv) + http.Error(w, msg, http.StatusBadRequest) + } else if errors.Is(err, strconv.ErrRange) { + msg = fmt.Sprintf("Investment ID of value %s is out of range", pv) + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + i, err := h.Investments.ById(id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("No record of an investment with id %d has been found", id) + http.Error(w, msg, http.StatusNotFound) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + iJson, _ := json.Marshal(i) + + w.Header().Set("Content-Type", "application/json") + w.Write(iJson) +} + +func notFuture(fl validator.FieldLevel) bool { + today := time.Now() + creationDate := fl.Field().Interface().(time.Time) + + return !creationDate.After(today) +} diff --git a/handlers/investment_test.go b/handlers/investment_test.go new file mode 100644 index 000000000..a5b149918 --- /dev/null +++ b/handlers/investment_test.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "bytes" + "causeurgnocchi/backend-test/models" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +var investment = &models.Investment{ + Id: 1, + InitialAmount: 1000000, + Balance: 1000000, + CreationDate: time.Now(), + Investor: *investor, +} + +type mockInvestmentModel struct { +} + +func (m mockInvestmentModel) Create(models.InvestmentCreationDTO) (int, error) { + return 1, nil +} + +func (m mockInvestmentModel) ById(id int) (*models.Investment, error) { + return investment, nil +} + +func configInvestmentTest(t *testing.T) { + t.Helper() + + h := InvestmentHandler{Investments: mockInvestmentModel{}} + + mux = http.NewServeMux() + + mux.HandleFunc("/api/investments", h.CreateInvestment) + mux.HandleFunc("/api/investments/{id}", h.FindInvestmentById) +} + +func TestInvestmentsCreate(t *testing.T) { + configInvestmentTest(t) + + dto := &models.InvestmentCreationDTO{ + InitialAmount: 100000, + CreationDate: time.Now(), + InvestorCPF: "95130357000", + } + + dtoJson, _ := json.Marshal(dto) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/investments", bytes.NewBuffer(dtoJson)) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + investmentJson, _ := json.Marshal(investment) + + if body := rec.Body.String(); body != string(investmentJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investmentJson), body) + } +} + +func TestInvestmentsById(t *testing.T) { + configInvestmentTest(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/investments/1", nil) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + investmentJson, _ := json.Marshal(investment) + + if body := rec.Body.String(); body != string(investmentJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investmentJson), body) + } +} diff --git a/handlers/investor.go b/handlers/investor.go new file mode 100644 index 000000000..e16c0d8f7 --- /dev/null +++ b/handlers/investor.go @@ -0,0 +1,171 @@ +package handlers + +import ( + "causeurgnocchi/backend-test/models" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/go-playground/validator/v10" + "github.com/go-sql-driver/mysql" +) + +const ( + cpf = "95130357000" + mySqlKeyExists = 1062 +) + +var ( + mux *http.ServeMux + + investor = &models.Investor{ + Cpf: cpf, + Name: "Lazlo Varga", + } +) + +type InvestorHandler struct { + Investors interface { + Create(invstr models.Investor) error + ByCpf(cpf string) (*models.Investor, error) + } +} + +func (h InvestorHandler) CreateInvestor(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + + var i models.Investor + + err := decodeJsonBody(w, r, &i) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + v := validator.New() + v.RegisterValidation("cpf", func(fl validator.FieldLevel) bool { + cpf := fl.Field().String() + + return validateCPF(cpf) + }) + + err = v.Struct(i) + if err != nil { + errs := err.(validator.ValidationErrors) + msg := fmt.Sprintf("Invalid investor information:\n%s", errs) + http.Error(w, msg, http.StatusBadRequest) + + return + } + + err = h.Investors.Create(i) + if err != nil { + if err.(*mysql.MySQLError).Number == mySqlKeyExists { + msg := "CPF provided is already being used" + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + iJson, err := json.Marshal(i) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(iJson) +} + +func (h InvestorHandler) FindInvestorByCpf(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + + cpf := r.PathValue("cpf") + + if !validateCPF(cpf) { + http.Error(w, "Invalid CPF", http.StatusBadRequest) + } + + i, err := h.Investors.ByCpf(cpf) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("No record of an investor with CPF %s has been found", cpf) + http.Error(w, msg, http.StatusNotFound) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + iJson, _ := json.Marshal(i) + + w.Header().Set("Content-Type", "application/json") + w.Write(iJson) +} + +func validateCPF(cpf string) bool { + if len(cpf) != 11 { + return false + } + + var cpfDigits [11]int + for i, c := range cpf { + n, err := strconv.Atoi(string(c)) + if err != nil { + log.Print(err.Error()) + } + cpfDigits[i] = n + } + + sum1 := 0 + for i := 0; i < 9; i++ { + sum1 += cpfDigits[i] * (10 - i) + } + + validator1 := (sum1 * 10) % 11 + if validator1 == 10 { + validator1 = 0 + } + if validator1 != cpfDigits[9] { + return false + } + + sum2 := validator1 * 2 + for i := 0; i < 9; i++ { + sum2 += cpfDigits[i] * (11 - i) + } + + validator2 := (sum2 * 10) % 11 + if validator2 == 10 { + validator2 = 0 + } + if validator2 != cpfDigits[10] { + return false + } + + return true +} diff --git a/handlers/investor_test.go b/handlers/investor_test.go new file mode 100644 index 000000000..ce34915be --- /dev/null +++ b/handlers/investor_test.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "bytes" + "causeurgnocchi/backend-test/models" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +var investorJson []byte + +type mockInvestorModel struct { +} + +func (m mockInvestorModel) Create(models.Investor) error { + return nil +} + +func (m mockInvestorModel) ByCpf(cpf string) (*models.Investor, error) { + return investor, nil +} + +func configInvestorTest(t *testing.T) { + t.Helper() + + investorJson, _ = json.Marshal(investor) + + h := InvestorHandler{Investors: mockInvestorModel{}} + + mux = http.NewServeMux() + + mux.HandleFunc("/api/investors", h.CreateInvestor) + mux.HandleFunc("/api/investors/{cpf}", h.FindInvestorByCpf) +} + +func TestInvestorsCreate(t *testing.T) { + configInvestorTest(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/investors", bytes.NewBuffer(investorJson)) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d.\nGot %d", http.StatusOK, rec.Code) + } + + if body := rec.Body.String(); body != string(investorJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investorJson), body) + } +} + +func TestInvestorsByCPF(t *testing.T) { + configInvestorTest(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/investors/"+cpf, nil) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d.\nGot %d", http.StatusOK, rec.Code) + } + + if body := rec.Body.String(); body != string(investorJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investorJson), body) + } +} diff --git a/handlers/withdrawal.go b/handlers/withdrawal.go new file mode 100644 index 000000000..0df5ab0c7 --- /dev/null +++ b/handlers/withdrawal.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "causeurgnocchi/backend-test/models" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/go-playground/validator/v10" +) + +type WithdrawalHandler struct { + Withdrawals interface { + Create(w models.WithdrawalCreationDTO) (int, error) + ById(id int) (*models.Withdrawal, error) + } +} + +func (h WithdrawalHandler) CreateWithdrawal(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + + var dto models.WithdrawalCreationDTO + + err := decodeJsonBody(w, r, &dto) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + v := validator.New() + + err = v.Struct(dto) + if err != nil { + errs := err.(validator.ValidationErrors) + msg := fmt.Sprintf("Invalid withdrawal information:\n%s", errs) + http.Error(w, msg, http.StatusBadRequest) + + return + } + + id, err := h.Withdrawals.Create(dto) + if err != nil { + if errors.Is(err, models.InvalidWithdrawalDate) { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + withdrawal, err := h.Withdrawals.ById(id) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + withdrawalJson, _ := json.Marshal(withdrawal) + + w.Header().Set("Content-Type", "application/json") + w.Write(withdrawalJson) +} + +func (h WithdrawalHandler) FindWithdrawalById(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + + pv := r.PathValue("id") + + id, err := strconv.Atoi(pv) + if err != nil { + var msg string + + if errors.Is(err, strconv.ErrSyntax) { + msg = fmt.Sprintf("Withdrawal ID of value %s has a syntax error", pv) + http.Error(w, msg, http.StatusBadRequest) + } else if errors.Is(err, strconv.ErrRange) { + msg = fmt.Sprintf("Withdrawal ID of value %s is out of range", pv) + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + withdrawal, err := h.Withdrawals.ById(id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("No record of a withdrawal with id %d has been found", id) + http.Error(w, msg, http.StatusNotFound) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + withdrawalJson, _ := json.Marshal(withdrawal) + + w.Header().Set("Content-Type", "application/json") + w.Write(withdrawalJson) +} diff --git a/handlers/withdrawal_test.go b/handlers/withdrawal_test.go new file mode 100644 index 000000000..c9c2bba78 --- /dev/null +++ b/handlers/withdrawal_test.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "bytes" + "causeurgnocchi/backend-test/models" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +var withdrawal = &models.Withdrawal{ + Id: 1, + GrossAmount: 1000000, + NetAmount: 1000000, + Date: time.Now(), + Investment: *investment, +} + +type mockWithdrawalModel struct { +} + +func (m mockWithdrawalModel) Create(models.WithdrawalCreationDTO) (int, error) { + return 1, nil +} + +func (m mockWithdrawalModel) ById(id int) (*models.Withdrawal, error) { + return withdrawal, nil +} + +func configWithdrawalTest(t *testing.T) { + t.Helper() + + h := WithdrawalHandler{Withdrawals: mockWithdrawalModel{}} + + mux = http.NewServeMux() + + mux.HandleFunc("/api/withdrawals", h.CreateWithdrawal) + mux.HandleFunc("/api/withdrawals/{id}", h.FindWithdrawalById) +} + +func TestWithdrawalsCreate(t *testing.T) { + configWithdrawalTest(t) + + dto := &models.WithdrawalCreationDTO{ + InvestmentId: 1, + Date: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + } + + dtoJson, _ := json.Marshal(dto) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/withdrawals", bytes.NewBuffer(dtoJson)) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + withdrawalJson, _ := json.Marshal(withdrawal) + + if body := rec.Body.String(); body != string(withdrawalJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(withdrawalJson), body) + } +} + +func TestWithdrawalsById(t *testing.T) { + configWithdrawalTest(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/withdrawals/1", nil) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + withdrawalJson, _ := json.Marshal(withdrawal) + + if body := rec.Body.String(); body != string(withdrawalJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(withdrawalJson), body) + } +} diff --git a/main.go b/main.go index b081f7cf0..5f9f53905 100644 --- a/main.go +++ b/main.go @@ -2,22 +2,13 @@ package main import ( "database/sql" - "encoding/json" - "errors" - "fmt" "log" "net/http" - "strconv" - "time" + "causeurgnocchi/backend-test/handlers" "causeurgnocchi/backend-test/models" - - "github.com/go-playground/validator/v10" - "github.com/go-sql-driver/mysql" ) -const MYSQL_KEY_EXITS = 1062 - func main() { db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") if err != nil { @@ -25,325 +16,26 @@ func main() { } defer db.Close() - env := &Env{ - investors: &models.InvestorModel{Db: db}, - investments: &models.InvestmentModel{Db: db}, - withdrawals: &models.WithdrawalModel{Db: db}, - } - - http.HandleFunc("/api/investors", env.investorsIndex) - http.HandleFunc("/api/investments", env.investmentsIndex) - http.HandleFunc("/api/witdrawals", env.withdrawalsIndex) - - http.ListenAndServe(":8080", nil) -} - -type Env struct { - investors interface { - Create(invstr models.Investor) error - ByCpf(cpf string) (*models.Investor, error) - } - - investments interface { - Create(inv models.InvestmentCreationDTO) (int, error) - ById(id int) (*models.Investment, error) - } - - withdrawals interface { - Create(w models.WithdrawalCreationDTO) (int, error) - ById(id int) (*models.Withdrawal, error) - } -} - -func (env Env) investorsIndex(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - var i models.Investor - - err := decodeJsonBody(w, r, &i) - if err != nil { - var mr *malformedRequest - - if errors.As(err, &mr) { - http.Error(w, mr.msg, mr.status) - } else { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - return - } - - v := validator.New() - v.RegisterValidation("cpf", func(fl validator.FieldLevel) bool { - cpf := fl.Field().String() - - return validateCPF(cpf) - }) - - err = v.Struct(i) - if err != nil { - errs := err.(validator.ValidationErrors) - msg := fmt.Sprintf("Invalid investor information:\n%s", errs) - http.Error(w, msg, http.StatusBadRequest) - - return - } - - err = env.investors.Create(i) - if err != nil { - if err.(*mysql.MySQLError).Number == MYSQL_KEY_EXITS { - msg := "CPF provided is already being used" - http.Error(w, msg, http.StatusBadRequest) - } else { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - return - } - - iJson, err := json.Marshal(i) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(iJson) - - case http.MethodGet: - cpf := r.URL.Query().Get("cpf") - - if !validateCPF(cpf) { - http.Error(w, "Invalid CPF", http.StatusBadRequest) - } - - i, err := env.investors.ByCpf(cpf) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - msg := fmt.Sprintf("No record of an investor with CPF %s has been found", cpf) - http.Error(w, msg, http.StatusNotFound) - } else { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - return - } - - iJson, _ := json.Marshal(i) - - w.Header().Set("Content-Type", "application/json") - w.Write(iJson) - - default: - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - } -} - -func (env Env) investmentsIndex(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - var dto models.InvestmentCreationDTO - - err := decodeJsonBody(w, r, &dto) - if err != nil { - var mr *malformedRequest - - if errors.As(err, &mr) { - http.Error(w, mr.msg, mr.status) - } else { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - return - } - - v := validator.New() - v.RegisterValidation("notfuture", notFuture) - - err = v.Struct(dto) - if err != nil { - errs := err.(validator.ValidationErrors) - msg := fmt.Sprintf("Invalid investment information:\n%s", errs) - http.Error(w, msg, http.StatusBadRequest) - - return - } - - id, err := env.investments.Create(dto) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - i, err := env.investments.ById(id) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - iJson, _ := json.Marshal(i) - - w.Header().Set("Content-Type", "application/json") - w.Write(iJson) - - case http.MethodGet: - queryParam := r.URL.Query().Get("id") - - id, err := strconv.Atoi(queryParam) - if err != nil { - var msg string - - if errors.Is(err, strconv.ErrSyntax) { - msg = fmt.Sprintf("Investment ID of value %s has a syntax error", queryParam) - http.Error(w, msg, http.StatusBadRequest) - } else if errors.Is(err, strconv.ErrRange) { - msg = fmt.Sprintf("Investment ID of value %s is out of range", queryParam) - http.Error(w, msg, http.StatusBadRequest) - } else { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - return - } - - inv, err := env.investments.ById(id) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - msg := fmt.Sprintf("No record of an investor with id %d has been found", id) - http.Error(w, msg, http.StatusNotFound) - } else { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - return - } - - invJson, _ := json.Marshal(inv) - - w.Header().Set("Content-Type", "application/json") - w.Write(invJson) - - default: - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - } -} - -func (env Env) withdrawalsIndex(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - var dto models.WithdrawalCreationDTO - - err := decodeJsonBody(w, r, &dto) - if err != nil { - var mr *malformedRequest - - if errors.As(err, &mr) { - http.Error(w, mr.msg, mr.status) - } else { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - return - } - - v := validator.New() - - err = v.Struct(dto) - if err != nil { - errs := err.(validator.ValidationErrors) - msg := fmt.Sprintf("Invalid withdrawal information:\n%s", errs) - http.Error(w, msg, http.StatusBadRequest) - - return - } - - id, err := env.withdrawals.Create(dto) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - withdrawal, err := env.withdrawals.ById(id) - if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - withdrawalJson, _ := json.Marshal(withdrawal) - - w.Header().Set("Content-Type", "application/json") - w.Write(withdrawalJson) - } -} - -func validateCPF(cpf string) bool { - if len(cpf) != 11 { - return false - } - - var cpfDigits [11]int - for i, c := range cpf { - n, err := strconv.Atoi(string(c)) - if err != nil { - log.Print(err.Error()) - } - cpfDigits[i] = n + investorH := handlers.InvestorHandler{ + Investors: models.InvestorModel{Db: db}, } - sum1 := 0 - for i := 0; i < 9; i++ { - sum1 += cpfDigits[i] * (10 - i) + investmentH := handlers.InvestmentHandler{ + Investments: models.InvestmentModel{Db: db}, } - validator1 := (sum1 * 10) % 11 - if validator1 == 10 { - validator1 = 0 - } - if validator1 != cpfDigits[9] { - return false + withdrawalH := handlers.WithdrawalHandler{ + Withdrawals: models.WithdrawalModel{Db: db}, } - sum2 := validator1 * 2 - for i := 0; i < 9; i++ { - sum2 += cpfDigits[i] * (11 - i) - } + http.HandleFunc("/api/investors", investorH.CreateInvestor) + http.HandleFunc("/api/investors/cpf/{cpf}", investorH.FindInvestorByCpf) - validator2 := (sum2 * 10) % 11 - if validator2 == 10 { - validator2 = 0 - } - if validator2 != cpfDigits[10] { - return false - } + http.HandleFunc("/api/investments", investmentH.CreateInvestment) + http.HandleFunc("/api/investments/{id}", investmentH.FindInvestmentById) - return true -} + http.HandleFunc("/api/witdrawals", withdrawalH.CreateWithdrawal) + http.HandleFunc("/api/witdrawals/{id}", withdrawalH.FindWithdrawalById) -func notFuture(fl validator.FieldLevel) bool { - today := time.Now().Truncate(24 * time.Hour) - - creationDate, err := time.Parse(time.DateOnly, fl.Field().String()) - if err != nil { - log.Print(err) - return false - } - - return !creationDate.After(today) + http.ListenAndServe(":8080", nil) } diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 8e883b66b..000000000 --- a/main_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package main - -import ( - "bytes" - "causeurgnocchi/backend-test/models" - "database/sql" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -var mux *http.ServeMux -var env *Env - -func ConfigTest(t *testing.T) { - t.Helper() - - db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") - if err != nil { - t.Fatal(err) - } - - db.SetMaxIdleConns(1) - db.SetMaxOpenConns(1) - - tx, err := db.Begin() - if err != nil { - t.Fatal(err) - } - - env = &Env{ - investors: &models.InvestorModel{Db: tx}, - investments: &models.InvestmentModel{Db: tx}, - withdrawals: &models.WithdrawalModel{Db: tx}, - } - - mux = http.NewServeMux() - mux.HandleFunc("/api/investors", env.investorsIndex) - mux.HandleFunc("/api/investments", env.investmentsIndex) - mux.HandleFunc("/api/withdrawal", env.withdrawalsIndex) - - t.Cleanup(func() { - tx.Rollback() - db.Close() - }) -} - -func TestInvestorsCreate(t *testing.T) { - ConfigTest(t) - - invstr := &models.Investor{ - Cpf: "95130357000", - Name: "Lazlo Varga", - } - invstrJson, _ := json.Marshal(invstr) - - rec := httptest.NewRecorder() - req := httptest.NewRequest("POST", "/api/investors", bytes.NewBuffer(invstrJson)) - - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) - } - - if body := rec.Body.String(); body != string(invstrJson) { - t.Errorf("Expected investor that was created. Got %s", body) - } -} - -func TestInvestorsByCPF(t *testing.T) { - ConfigTest(t) - - const CPF = "95130357000" - - invstr := models.Investor{ - Cpf: CPF, - Name: "Lazlo Varga", - } - - env.investors.Create(invstr) - - rec := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/api/investors?cpf="+CPF, nil) - - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) - } - - invstrJson, _ := json.Marshal(invstr) - if body := rec.Body.String(); body != string(invstrJson) { - t.Errorf("Expected investor of CPF %s. Got %s", CPF, body) - } -} - -func TestInvestmentsCreate(t *testing.T) { - ConfigTest(t) - - invstr := &models.Investor{ - Cpf: "95130357000", - Name: "Lazlo Varga", - } - invstrJson, _ := json.Marshal(invstr) - - rec := httptest.NewRecorder() - invstrReq := httptest.NewRequest("POST", "/api/investors", bytes.NewBuffer(invstrJson)) - mux.ServeHTTP(rec, invstrReq) - - inv := &models.InvestmentCreationDTO{ - InitialAmount: 100000, - CreationDate: time.Now().Format(time.DateOnly), - InvestorCPF: "95130357000", - } - invJson, _ := json.Marshal(inv) - - rec = httptest.NewRecorder() - invReq := httptest.NewRequest("POST", "/api/investments", bytes.NewBuffer(invJson)) - mux.ServeHTTP(rec, invReq) - - if rec.Code != http.StatusOK { - t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) - } - - if body := rec.Body.String(); body != string(invJson) { - t.Errorf("Expected investment that was created. Got %s", body) - } -} diff --git a/models/database.go b/models/helpers.go similarity index 89% rename from models/database.go rename to models/helpers.go index 442c8ecd1..0384133cb 100644 --- a/models/database.go +++ b/models/helpers.go @@ -2,7 +2,7 @@ package models import "database/sql" -type Database interface { +type database interface { Query(query string, args ...interface{}) (*sql.Rows, error) QueryRow(query string, args ...interface{}) *sql.Row diff --git a/models/investment.go b/models/investment.go index 0334b2236..14a14f04e 100644 --- a/models/investment.go +++ b/models/investment.go @@ -13,13 +13,13 @@ type Investment struct { } type InvestmentCreationDTO struct { - InitialAmount int `json:"amount" validate:"required,gt=0"` - CreationDate string `json:"creation_date" validate:"required,datetime=2006-01-02,notfuture"` - InvestorCPF string `json:"investor_cpf" validate:"required"` + InitialAmount int `json:"amount" validate:"required,gt=0"` + CreationDate time.Time `json:"creation_date" validate:"required,notfuture"` + InvestorCPF string `json:"investor_cpf" validate:"required"` } type InvestmentModel struct { - Db Database + Db database } func (m InvestmentModel) Create(dto InvestmentCreationDTO) (int, error) { diff --git a/models/investment_test.go b/models/investment_test.go index 4e66507da..102d54ae8 100644 --- a/models/investment_test.go +++ b/models/investment_test.go @@ -3,6 +3,7 @@ package models import ( "database/sql" "testing" + "time" _ "github.com/go-sql-driver/mysql" ) @@ -22,7 +23,7 @@ func TestInvestments(t *testing.T) { investorM := InvestorModel{Db: tx} investmentM := InvestmentModel{Db: tx} - + investor := Investor{ Cpf: "95130357000", Name: "Lazlo Varga", @@ -32,7 +33,7 @@ func TestInvestments(t *testing.T) { investment := InvestmentCreationDTO{ InitialAmount: 1000000, - CreationDate: "2025-01-01", + CreationDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), InvestorCPF: "95130357000", } diff --git a/models/investor.go b/models/investor.go index a111794f6..edba2e086 100644 --- a/models/investor.go +++ b/models/investor.go @@ -6,7 +6,7 @@ type Investor struct { } type InvestorModel struct { - Db Database + Db database } func (m InvestorModel) Create(i Investor) error { @@ -19,7 +19,7 @@ func (m InvestorModel) Create(i Investor) error { } func (m InvestorModel) ByCpf(cpf string) (*Investor, error) { - r := m.Db.QueryRow("SELECT * FROM investors where cpf = ?", cpf) + r := m.Db.QueryRow("SELECT cpf, name FROM investors where cpf = ?", cpf) var i Investor diff --git a/models/withdrawal.go b/models/withdrawal.go index 977013dfb..cffa857d6 100644 --- a/models/withdrawal.go +++ b/models/withdrawal.go @@ -1,6 +1,7 @@ package models import ( + "errors" "math" "strconv" "time" @@ -15,28 +16,34 @@ type Withdrawal struct { } type WithdrawalCreationDTO struct { - Date time.Time `validate:"required,datetime=2006-01-02"` + Date time.Time `validate:"required"` InvestmentId int } type WithdrawalModel struct { - Db Database + Db database } +var InvalidWithdrawalDate = errors.New("Withdrawal date preceeds investment's creation") + func (m WithdrawalModel) Create(dto WithdrawalCreationDTO) (int, error) { im := &InvestmentModel{Db: m.Db} - inv, err := im.ById(dto.InvestmentId) + i, err := im.ById(dto.InvestmentId) if err != nil { return -1, err } + if dto.Date.Before(i.CreationDate) { + return -1, InvalidWithdrawalDate + } + var taxes int - gain := inv.Balance - inv.InitialAmount + gain := i.Balance - i.InitialAmount - if inv.CreationDate.Before(inv.CreationDate.AddDate(1, 0, 0)) { + if i.CreationDate.Before(i.CreationDate.AddDate(1, 0, 0)) { taxes = int(math.Floor(float64(gain) * 0.225)) - } else if inv.CreationDate.Before(inv.CreationDate.AddDate(2, 0, 0)) { + } else if i.CreationDate.Before(i.CreationDate.AddDate(2, 0, 0)) { taxes = int(math.Floor(float64(gain) * 0.185)) } else { taxes = int(math.Floor(float64(gain) * 0.15)) @@ -44,8 +51,8 @@ func (m WithdrawalModel) Create(dto WithdrawalCreationDTO) (int, error) { r, err := m.Db.Exec( "INSERT INTO withdrawals (gross_amount, net_amount, date, investment_id) VALUES (?, ?, ?, ?)", - inv.Balance, - inv.Balance-taxes, + i.Balance, + i.Balance-taxes, dto.Date, dto.InvestmentId, ) diff --git a/models/withdrawal_test.go b/models/withdrawal_test.go index e2e460a89..a4016d5ca 100644 --- a/models/withdrawal_test.go +++ b/models/withdrawal_test.go @@ -34,14 +34,14 @@ func TestWithdrawalsCreate(t *testing.T) { investment := InvestmentCreationDTO{ InitialAmount: 1000000, - CreationDate: "2025-01-01", + CreationDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), InvestorCPF: "95130357000", } investmentId, _ := investmentM.Create(investment) w := WithdrawalCreationDTO{ - Date: time.Now(), + Date: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), InvestmentId: investmentId, } From fe398d380798022f3a753276c79af598f7de3a22 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Fri, 31 Jan 2025 14:35:32 -0300 Subject: [PATCH 13/18] Add Date type --- handlers/investment.go | 2 +- handlers/investment_test.go | 10 +- handlers/investor.go | 21 +--- handlers/investor_test.go | 28 ++++-- handlers/withdrawal_test.go | 10 +- main.go | 2 +- models/date.go | 25 +++++ models/investment.go | 20 ++-- models/investment_test.go | 2 +- models/withdrawal.go | 9 +- models/withdrawal_test.go | 4 +- open-api.yaml | 196 +++++++++++++++++++++++++++--------- 12 files changed, 222 insertions(+), 107 deletions(-) create mode 100644 models/date.go diff --git a/handlers/investment.go b/handlers/investment.go index e86c19628..10e8023ee 100644 --- a/handlers/investment.go +++ b/handlers/investment.go @@ -122,7 +122,7 @@ func (h InvestmentHandler) FindInvestmentById(w http.ResponseWriter, r *http.Req func notFuture(fl validator.FieldLevel) bool { today := time.Now() - creationDate := fl.Field().Interface().(time.Time) + creationDate := fl.Field().Interface().(models.Date) return !creationDate.After(today) } diff --git a/handlers/investment_test.go b/handlers/investment_test.go index a5b149918..8c6a348de 100644 --- a/handlers/investment_test.go +++ b/handlers/investment_test.go @@ -14,7 +14,7 @@ var investment = &models.Investment{ Id: 1, InitialAmount: 1000000, Balance: 1000000, - CreationDate: time.Now(), + CreationDate: models.Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, Investor: *investor, } @@ -43,13 +43,7 @@ func configInvestmentTest(t *testing.T) { func TestInvestmentsCreate(t *testing.T) { configInvestmentTest(t) - dto := &models.InvestmentCreationDTO{ - InitialAmount: 100000, - CreationDate: time.Now(), - InvestorCPF: "95130357000", - } - - dtoJson, _ := json.Marshal(dto) + dtoJson := []byte(`{"initial_amount":1000000,"creation_date":"2025-01-01","investor_cpf":"95130357000"}`) rec := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/investments", bytes.NewBuffer(dtoJson)) diff --git a/handlers/investor.go b/handlers/investor.go index e16c0d8f7..8a48b01bf 100644 --- a/handlers/investor.go +++ b/handlers/investor.go @@ -14,19 +14,7 @@ import ( "github.com/go-sql-driver/mysql" ) -const ( - cpf = "95130357000" - mySqlKeyExists = 1062 -) - -var ( - mux *http.ServeMux - - investor = &models.Investor{ - Cpf: cpf, - Name: "Lazlo Varga", - } -) +const mySqlKeyExists = 1062 type InvestorHandler struct { Investors interface { @@ -60,7 +48,7 @@ func (h InvestorHandler) CreateInvestor(w http.ResponseWriter, r *http.Request) v.RegisterValidation("cpf", func(fl validator.FieldLevel) bool { cpf := fl.Field().String() - return validateCPF(cpf) + return cpfIsValid(cpf) }) err = v.Struct(i) @@ -104,8 +92,9 @@ func (h InvestorHandler) FindInvestorByCpf(w http.ResponseWriter, r *http.Reques cpf := r.PathValue("cpf") - if !validateCPF(cpf) { + if !cpfIsValid(cpf) { http.Error(w, "Invalid CPF", http.StatusBadRequest) + return } i, err := h.Investors.ByCpf(cpf) @@ -127,7 +116,7 @@ func (h InvestorHandler) FindInvestorByCpf(w http.ResponseWriter, r *http.Reques w.Write(iJson) } -func validateCPF(cpf string) bool { +func cpfIsValid(cpf string) bool { if len(cpf) != 11 { return false } diff --git a/handlers/investor_test.go b/handlers/investor_test.go index ce34915be..9fa85ff40 100644 --- a/handlers/investor_test.go +++ b/handlers/investor_test.go @@ -4,12 +4,20 @@ import ( "bytes" "causeurgnocchi/backend-test/models" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" ) -var investorJson []byte +var ( + mux *http.ServeMux + + investor = &models.Investor{ + Cpf: "95130357000", + Name: "Lazlo Varga", + } +) type mockInvestorModel struct { } @@ -25,8 +33,6 @@ func (m mockInvestorModel) ByCpf(cpf string) (*models.Investor, error) { func configInvestorTest(t *testing.T) { t.Helper() - investorJson, _ = json.Marshal(investor) - h := InvestorHandler{Investors: mockInvestorModel{}} mux = http.NewServeMux() @@ -38,8 +44,10 @@ func configInvestorTest(t *testing.T) { func TestInvestorsCreate(t *testing.T) { configInvestorTest(t) + reqBody := []byte(fmt.Sprintf(`{"cpf":"%s","name":"%s"}`, investor.Cpf, investor.Name)) + rec := httptest.NewRecorder() - req := httptest.NewRequest("POST", "/api/investors", bytes.NewBuffer(investorJson)) + req := httptest.NewRequest("POST", "/api/investors", bytes.NewBuffer(reqBody)) mux.ServeHTTP(rec, req) @@ -47,8 +55,8 @@ func TestInvestorsCreate(t *testing.T) { t.Errorf("Expected response code %d.\nGot %d", http.StatusOK, rec.Code) } - if body := rec.Body.String(); body != string(investorJson) { - t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investorJson), body) + if b := rec.Body.String(); b != string(reqBody) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(reqBody), b) } } @@ -56,7 +64,7 @@ func TestInvestorsByCPF(t *testing.T) { configInvestorTest(t) rec := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/api/investors/"+cpf, nil) + req := httptest.NewRequest("GET", "/api/investors/"+investor.Cpf, nil) mux.ServeHTTP(rec, req) @@ -64,7 +72,9 @@ func TestInvestorsByCPF(t *testing.T) { t.Errorf("Expected response code %d.\nGot %d", http.StatusOK, rec.Code) } - if body := rec.Body.String(); body != string(investorJson) { - t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investorJson), body) + expected, _ := json.Marshal(investor) + + if b := rec.Body.String(); b != string(expected) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(expected), b) } } diff --git a/handlers/withdrawal_test.go b/handlers/withdrawal_test.go index c9c2bba78..f10e6cdda 100644 --- a/handlers/withdrawal_test.go +++ b/handlers/withdrawal_test.go @@ -14,7 +14,7 @@ var withdrawal = &models.Withdrawal{ Id: 1, GrossAmount: 1000000, NetAmount: 1000000, - Date: time.Now(), + Date: models.Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, Investment: *investment, } @@ -43,15 +43,11 @@ func configWithdrawalTest(t *testing.T) { func TestWithdrawalsCreate(t *testing.T) { configWithdrawalTest(t) - dto := &models.WithdrawalCreationDTO{ - InvestmentId: 1, - Date: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - } - - dtoJson, _ := json.Marshal(dto) + dtoJson := []byte(`{"date":"2025-01-01","investment_id":1}`) rec := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/withdrawals", bytes.NewBuffer(dtoJson)) + mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { diff --git a/main.go b/main.go index 5f9f53905..b1d1a7ef2 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,7 @@ func main() { } http.HandleFunc("/api/investors", investorH.CreateInvestor) - http.HandleFunc("/api/investors/cpf/{cpf}", investorH.FindInvestorByCpf) + http.HandleFunc("/api/investors/{cpf}", investorH.FindInvestorByCpf) http.HandleFunc("/api/investments", investmentH.CreateInvestment) http.HandleFunc("/api/investments/{id}", investmentH.FindInvestmentById) diff --git a/models/date.go b/models/date.go new file mode 100644 index 000000000..f5eafa188 --- /dev/null +++ b/models/date.go @@ -0,0 +1,25 @@ +package models + +import ( + "encoding/json" + "strings" + "time" +) + +type Date struct{ time.Time } + +func (t Date) MarshalJSON() ([]byte, error) { + return json.Marshal(t.Time) +} + +func (t *Date) UnmarshalJSON(b []byte) error { + err := json.Unmarshal(b, &t.Time) + if err != nil { + bstr := strings.Trim(string(b), `"`) + t.Time, err = time.Parse("2006-01-02", bstr) + if err != nil { + return err + } + } + return nil +} diff --git a/models/investment.go b/models/investment.go index 14a14f04e..15f1df054 100644 --- a/models/investment.go +++ b/models/investment.go @@ -1,21 +1,17 @@ package models -import ( - "time" -) - type Investment struct { - Id int `json:"id"` - InitialAmount int `json:"amount"` - Balance int `json:"balance"` - CreationDate time.Time `json:"creation_date"` - Investor Investor `json:"investor"` + Id int `json:"id"` + InitialAmount int `json:"initial_amount"` + Balance int `json:"balance"` + CreationDate Date `json:"creation_date"` + Investor Investor `json:"investor"` } type InvestmentCreationDTO struct { - InitialAmount int `json:"amount" validate:"required,gt=0"` - CreationDate time.Time `json:"creation_date" validate:"required,notfuture"` - InvestorCPF string `json:"investor_cpf" validate:"required"` + InitialAmount int `json:"initial_amount" validate:"required,gt=0"` + CreationDate Date `json:"creation_date" validate:"required,notfuture"` + InvestorCPF string `json:"investor_cpf" validate:"required"` } type InvestmentModel struct { diff --git a/models/investment_test.go b/models/investment_test.go index 102d54ae8..299cbe99d 100644 --- a/models/investment_test.go +++ b/models/investment_test.go @@ -33,7 +33,7 @@ func TestInvestments(t *testing.T) { investment := InvestmentCreationDTO{ InitialAmount: 1000000, - CreationDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + CreationDate: Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, InvestorCPF: "95130357000", } diff --git a/models/withdrawal.go b/models/withdrawal.go index cffa857d6..e73f300cd 100644 --- a/models/withdrawal.go +++ b/models/withdrawal.go @@ -4,20 +4,19 @@ import ( "errors" "math" "strconv" - "time" ) type Withdrawal struct { Id int `json:"id"` GrossAmount int `json:"gross_amount"` NetAmount int `json:"net_amount"` - Date time.Time `json:"date"` + Date Date `json:"date"` Investment Investment `json:"investment"` } type WithdrawalCreationDTO struct { - Date time.Time `validate:"required"` - InvestmentId int + Date Date `json:"date" validate:"required"` + InvestmentId int `json:"investment_id" validate:"required"` } type WithdrawalModel struct { @@ -34,7 +33,7 @@ func (m WithdrawalModel) Create(dto WithdrawalCreationDTO) (int, error) { return -1, err } - if dto.Date.Before(i.CreationDate) { + if dto.Date.Before(i.CreationDate.Time) { return -1, InvalidWithdrawalDate } diff --git a/models/withdrawal_test.go b/models/withdrawal_test.go index a4016d5ca..2f257020b 100644 --- a/models/withdrawal_test.go +++ b/models/withdrawal_test.go @@ -34,14 +34,14 @@ func TestWithdrawalsCreate(t *testing.T) { investment := InvestmentCreationDTO{ InitialAmount: 1000000, - CreationDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + CreationDate: Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, InvestorCPF: "95130357000", } investmentId, _ := investmentM.Create(investment) w := WithdrawalCreationDTO{ - Date: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + Date: Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, InvestmentId: investmentId, } diff --git a/open-api.yaml b/open-api.yaml index 40dbdab05..3bd85a1a0 100644 --- a/open-api.yaml +++ b/open-api.yaml @@ -4,6 +4,16 @@ info: title: Coderockr Backend Test - OpenAPI 3.0 description: |- A submission for Coderockr's backend development test. +
+
+ An investment managment application exposed through a REST API that deals with 3 entities: +
+ - Investors +
+ - Investments +
+ - Withdrawals + version: 1.0.0 servers: @@ -20,7 +30,7 @@ paths: tags: - investors summary: Add a new investor - operationId: addInvestor + operationId: createInvestor requestBody: description: Create a new investor content: @@ -36,32 +46,33 @@ paths: schema: $ref: "#/components/schemas/Investor" "400": - description: Invalid input + description: Bad request "500": description: Internal server error + /investors/{cpf}: get: tags: - investors summary: Find investor by CPF - description: Returns a single investor - operationId: getInvestorByCpf + description: Returns a single investor based on their CPF + operationId: findInvestorByCpf parameters: - name: cpf - in: query - description: CPF of investor to return + in: path + description: CPF of the investor to return required: true schema: type: string responses: "200": - description: successful operation + description: Successful operation content: application/json: schema: $ref: "#/components/schemas/Investor" "400": - description: Invalid CPF supplied + description: Bad request "404": description: Investor not found "500": @@ -71,8 +82,8 @@ paths: post: tags: - investments - summary: Add a new investment - operationId: addInvestment + summary: Create a new investment + operationId: createInvestment requestBody: description: Create a new investment content: @@ -88,7 +99,35 @@ paths: schema: $ref: "#/components/schemas/Investment" "400": - description: Invalid input + description: Bad request + "500": + description: Internal server error + + /investments/{id}: + get: + tags: + - investments + summary: Find investment by ID + description: Returns a single investment based on its ID + operationId: findInvestmentById + parameters: + - name: id + in: path + description: ID of the investment to return + required: true + schema: + type: string + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Investment" + "400": + description: Bad request + "404": + description: Investor not found "500": description: Internal server error @@ -111,9 +150,37 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Investment" + $ref: "#/components/schemas/Withdrawal" "400": - description: Invalid input + description: Bad request + "500": + description: Internal server error + + /withdrawals/{id}: + get: + tags: + - withdrawals + summary: Find withdrawal by ID + description: Returns a single withdrawal based on its ID + operationId: findWithdrawalById + parameters: + - name: id + in: path + description: ID of the withdrawal to return + required: true + schema: + type: string + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Withdrawal" + "400": + description: Bad request + "404": + description: Investor not found "500": description: Internal server error @@ -123,66 +190,105 @@ components: properties: cpf: type: string - example: 30361099002 + example: 95130357000 name: type: string example: Lazlo Varga Investment: - properties: - initialAmount: - type: integer - example: 1000000 - description: Amount of money to be invested expressed in the smallest unit in the respective monetary system - creationDate: - type: string - format: datetime - example: 2024-01-20 - investor: - $ref: "#/components/schemas/Investor" + allOf: + - $ref: "#/components/schemas/BaseInvestment" + - required: + - id + - balance + - investor + properties: + id: + type: integer + example: 1 + description: Investment ID + balance: + type: integer + example: 1000000 + description: Sum of the initial amount and the gains of the investment, represented in the smallest unit of the respective monetary system + investor: + $ref: "#/components/schemas/Investor" NewInvestment: + allOf: + - $ref: "#/components/schemas/BaseInvestment" + - required: + - investor_cpf + properties: + investor_cpf: + type: string + description: CPF of the investor + + BaseInvestment: required: - initialAmount - creationDate properties: - initialAmount: + initial_amount: type: integer example: 1000000 - description: Amount of money to be invested, expressed in the smallest unit of the respective monetary system - creationDate: + description: Amount of money that was invested initially, represented in the smallest unit of the respective monetary system + creation_date: type: string - format: datetime - example: 2024-01-20 - investorId: - type: integer + format: date + example: 2025-01-01 + description: Date of creation of the investment - Pet: + Withdrawal: allOf: - - $ref: "#/components/schemas/NewPet" + - $ref: "#/components/schemas/NewWithdrawal" - required: - id + - gross_amount + - net_amount properties: id: type: integer - format: int64 - description: Unique id of the pet + example: 1 + description: Withdrawal ID + gross_amount: + type: integer + example: 1000000 + description: The investment balance at the moment the withdrawal was made, represented in the smallest unit of the respective monetary system + net_amount: + type: integer + example: 1000000 + description: The investment balance minus appliable taxes, represented in the smallest unit of the respective monetary system - NewPet: + NewWithdrawal: required: - - name + - date + - investment_id properties: - name: + date: type: string - description: Name of the pet - tag: - type: string - description: Type of the pet + format: date + example: 2025-01-01 + description: Date of creation of the withdrawal requestBodies: - Pet: - description: Investor object that needs to be added + Investor: + description: Investor object that needs to be created content: application/json: schema: $ref: "#/components/schemas/Investor" + + NewInvestment: + description: Investment object that needs to be created + content: + application/json: + schema: + $ref: "#/components/schemas/Investment" + + NewWithdrawal: + description: Withdrawal object that needs to be created + content: + application/json: + schema: + $ref: "#/components/schemas/Withdrawal" From 84cc8e0dab414cd1ba9bf955d1b5dc6da7a8d0cf Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Fri, 31 Jan 2025 18:03:18 -0300 Subject: [PATCH 14/18] Return status code 400 when format of date supplied is invalid --- handlers/helpers.go | 5 +++++ main.go | 4 ++-- models/investment.go | 4 ++-- models/withdrawal.go | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/handlers/helpers.go b/handlers/helpers.go index 50ba97fbc..8914538ee 100644 --- a/handlers/helpers.go +++ b/handlers/helpers.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strings" + "time" ) type malformedRequest struct { @@ -40,6 +41,7 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err if err != nil { var syntaxError *json.SyntaxError var unmarshalTypeError *json.UnmarshalTypeError + var timeParseError *time.ParseError switch { case errors.As(err, &syntaxError): @@ -54,6 +56,9 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err msg := fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset) return &malformedRequest{status: http.StatusBadRequest, msg: msg} + case errors.As(err, &timeParseError): + return &malformedRequest{status: http.StatusBadRequest, msg: err.Error()} + case strings.HasPrefix(err.Error(), "json: unknown field "): fieldName := strings.TrimPrefix(err.Error(), "json: unknown field %s") msg := fmt.Sprintf("Request body contains unknown field %s", fieldName) diff --git a/main.go b/main.go index b1d1a7ef2..a755456e4 100644 --- a/main.go +++ b/main.go @@ -34,8 +34,8 @@ func main() { http.HandleFunc("/api/investments", investmentH.CreateInvestment) http.HandleFunc("/api/investments/{id}", investmentH.FindInvestmentById) - http.HandleFunc("/api/witdrawals", withdrawalH.CreateWithdrawal) - http.HandleFunc("/api/witdrawals/{id}", withdrawalH.FindWithdrawalById) + http.HandleFunc("/api/withdrawals", withdrawalH.CreateWithdrawal) + http.HandleFunc("/api/withdrawals/{id}", withdrawalH.FindWithdrawalById) http.ListenAndServe(":8080", nil) } diff --git a/models/investment.go b/models/investment.go index 15f1df054..0d5835155 100644 --- a/models/investment.go +++ b/models/investment.go @@ -23,7 +23,7 @@ func (m InvestmentModel) Create(dto InvestmentCreationDTO) (int, error) { "INSERT INTO investments (initial_amount, balance, creation_date, investor_cpf) VALUES (?, ?, ?, ?)", dto.InitialAmount, dto.InitialAmount, - dto.CreationDate, + dto.CreationDate.Time, dto.InvestorCPF, ) if err != nil { @@ -44,7 +44,7 @@ func (m InvestmentModel) ById(id int) (*Investment, error) { r := m.Db.QueryRow("SELECT id, initial_amount, balance, creation_date, investor_cpf FROM investments WHERE id = ?", id) - err := r.Scan(&investment.Id, &investment.InitialAmount, &investment.Balance, &investment.CreationDate, &cpf) + err := r.Scan(&investment.Id, &investment.InitialAmount, &investment.Balance, &investment.CreationDate.Time, &cpf) if err != nil { return nil, err } diff --git a/models/withdrawal.go b/models/withdrawal.go index e73f300cd..7434838ff 100644 --- a/models/withdrawal.go +++ b/models/withdrawal.go @@ -52,7 +52,7 @@ func (m WithdrawalModel) Create(dto WithdrawalCreationDTO) (int, error) { "INSERT INTO withdrawals (gross_amount, net_amount, date, investment_id) VALUES (?, ?, ?, ?)", i.Balance, i.Balance-taxes, - dto.Date, + dto.Date.Time, dto.InvestmentId, ) if err != nil { @@ -73,7 +73,7 @@ func (m WithdrawalModel) ById(id int) (*Withdrawal, error) { r := m.Db.QueryRow("SELECT id, gross_amount, net_amount, date, investment_id FROM withdrawals WHERE id = ?", id) - err := r.Scan(&w.Id, &w.GrossAmount, &w.NetAmount, &w.Date, &investmentIdStr) + err := r.Scan(&w.Id, &w.GrossAmount, &w.NetAmount, &w.Date.Time, &investmentIdStr) if err != nil { return nil, err } From 9d5ebbaf74a01c5666e5bef450a480d1c4b43184 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Sat, 1 Feb 2025 11:25:48 -0300 Subject: [PATCH 15/18] Add endpoint that lists the investments of an investor --- ...rockr Backend Test.postman_collection.json | 1383 +++++++++++++++++ handlers/helpers.go | 45 + handlers/investment.go | 49 +- handlers/investment_test.go | 36 +- handlers/investor.go | 56 +- handlers/investor_test.go | 8 +- handlers/withdrawal.go | 27 +- handlers/withdrawal_test.go | 9 +- main.go | 14 +- models/investment.go | 31 + models/investment_test.go | 16 +- models/investor_test.go | 4 +- models/withdrawal.go | 4 +- models/withdrawal_test.go | 6 +- open-api.yaml | 294 ---- 15 files changed, 1586 insertions(+), 396 deletions(-) create mode 100644 Coderockr Backend Test.postman_collection.json delete mode 100644 open-api.yaml diff --git a/Coderockr Backend Test.postman_collection.json b/Coderockr Backend Test.postman_collection.json new file mode 100644 index 000000000..cf4322a3e --- /dev/null +++ b/Coderockr Backend Test.postman_collection.json @@ -0,0 +1,1383 @@ +{ + "info": { + "_postman_id": "f4c7ad08-fc49-4caa-b9d3-0695ee64a35c", + "name": "Coderockr Backend Test", + "description": "A submission for Coderockr's backend development test.\n\nAn investment managment application exposed through a REST API that deals with 3 entities:\n\n- Investors\n \n- Investments\n \n- Withdrawals", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "11087918" + }, + "item": [ + { + "name": "Investors", + "item": [ + { + "name": "Create a new investor", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cpf\": \"95130357000\", // CPF of the investor to be created\n \"name\": \"Lazlo Varga\" // Name of the investor to be created\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investors", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors" + ] + } + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cpf\": \"95130357000\", // CPF of the investor to be created\n \"name\": \"Lazlo Varga\" // Name of the investor to be created\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investors", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:51:07 GMT" + }, + { + "key": "Content-Length", + "value": "42" + } + ], + "cookie": [], + "body": "{\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n}" + }, + { + "name": "Invalid CPF", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cpf\": \"12345678900\", // CPF of the investor to be created\n \"name\": \"Lazlo Varga\" // Name of the investor to be created\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investors", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:51:59 GMT" + }, + { + "key": "Content-Length", + "value": "107" + } + ], + "cookie": [], + "body": "Invalid investor information:\nKey: 'Investor.Cpf' Error:Field validation for 'Cpf' failed on the 'cpf' tag\n" + } + ] + }, + { + "name": "Find investor by CPF", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/investors/:cpf", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors", + ":cpf" + ], + "variable": [ + { + "key": "cpf", + "value": "95130357000", + "description": "(Required) CPF of the investor to return" + } + ] + }, + "description": "Returns a single investor based on their CPF" + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/investors/:cpf", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors", + ":cpf" + ], + "variable": [ + { + "key": "cpf", + "value": "95130357000", + "description": "(Required) CPF of the investor to return" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:53:54 GMT" + }, + { + "key": "Content-Length", + "value": "42" + } + ], + "cookie": [], + "body": "{\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n}" + }, + { + "name": "Invalid CPF", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/investors/:cpf", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors", + ":cpf" + ], + "variable": [ + { + "key": "cpf", + "value": "lorem123", + "description": "(Required) CPF of the investor to return" + } + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:57:04 GMT" + }, + { + "key": "Content-Length", + "value": "12" + } + ], + "cookie": [], + "body": "Invalid CPF\n" + }, + { + "name": "Not found", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/investors/:cpf", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors", + ":cpf" + ], + "variable": [ + { + "key": "cpf", + "value": "92087347069", + "description": "(Required) CPF of the investor to return" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:56:57 GMT" + }, + { + "key": "Content-Length", + "value": "61" + } + ], + "cookie": [], + "body": "No record of an investor with CPF 92087347069 has been found\n" + } + ] + } + ], + "description": "The `/investors` endpoints let you manage information about investors." + }, + { + "name": "Investments", + "item": [ + { + "name": "Create a new investment", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"investor_cpf\": \"95130357000\", // CPF of the investor who owns the investment\n \"initial_amount\": 1000000, // amount of money to be invested, represented in the smallest unit of the respective monetary system\n \"creation_date\": \"2025-01-01\" // date of creation of the investment\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ] + } + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"investor_cpf\": \"95130357000\", // CPF of the investor who owns the investment\n \"initial_amount\": 1000000, // amount of money to be invested, represented in the smallest unit of the respective monetary system\n \"creation_date\": \"2025-01-01\" // date of creation of the investment\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:58:00 GMT" + }, + { + "key": "Content-Length", + "value": "144" + } + ], + "cookie": [], + "body": "{\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 1000000,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n}" + }, + { + "name": "Invalid initial amount", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"investor_cpf\": \"95130357000\", // CPF of the investor who owns the investment\n \"initial_amount\": \"one thousand bucks\", // amount of money to be invested, represented in the smallest unit of the respective monetary system\n \"creation_date\": \"2025-01-01\" // date of creation of the investment\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:58:36 GMT" + }, + { + "key": "Content-Length", + "value": "87" + } + ], + "cookie": [], + "body": "Request body contains an invalid value for the \"initial_amount\" field (at position 80)\n" + }, + { + "name": "Non-existing investor", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"investor_cpf\": \"92087347069\", // CPF of the investor who owns the investment\n \"initial_amount\": 1000000, // amount of money to be invested, represented in the smallest unit of the respective monetary system\n \"creation_date\": \"2025-01-01\" // date of creation of the investment\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:08:16 GMT" + }, + { + "key": "Content-Length", + "value": "60" + } + ], + "cookie": [], + "body": "There are no records of an investor with CPF of 92087347069\n" + } + ] + }, + { + "name": "Find investment by ID", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1", + "description": "ID of the investment to be retrieved" + } + ] + }, + "description": "Returns a single investment based on its ID" + }, + "response": [ + { + "name": "Sucessful operation", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1", + "description": "ID of the investment to be retrieved" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:10:26 GMT" + }, + { + "key": "Content-Length", + "value": "144" + } + ], + "cookie": [], + "body": "{\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 1000000,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n}" + }, + { + "name": "Not found", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "2", + "description": "ID of the investment to be retrieved" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:11:00 GMT" + }, + { + "key": "Content-Length", + "value": "52" + } + ], + "cookie": [], + "body": "No record of an investment with id 2 has been found\n" + }, + { + "name": "Invalid ID", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "one", + "description": "ID of the investment to be retrieved" + } + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:12:52 GMT" + }, + { + "key": "Content-Length", + "value": "46" + } + ], + "cookie": [], + "body": "Investment ID of value one has a syntax error\n" + } + ] + }, + { + "name": "Filter by investor CPF", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments?investor_cpf=17988930028", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ], + "query": [ + { + "key": "investor_cpf", + "value": "17988930028" + } + ] + }, + "description": "Returns a single investment based on its ID" + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments?investor_cpf=95130357000", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ], + "query": [ + { + "key": "investor_cpf", + "value": "95130357000" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:14:32 GMT" + }, + { + "key": "Content-Length", + "value": "291" + } + ], + "cookie": [], + "body": "[\n {\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 1000000,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n },\n {\n \"id\": 4,\n \"initial_amount\": 1000000,\n \"balance\": 1000000,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n }\n]" + }, + { + "name": "Investor not found", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments?investor_cpf=17988930028", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ], + "query": [ + { + "key": "investor_cpf", + "value": "17988930028" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:45:23 GMT" + }, + { + "key": "Content-Length", + "value": "38" + } + ], + "cookie": [], + "body": "Investor of CPF 17988930028 not found\n" + }, + { + "name": "Investor has no investment", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments?investor_cpf=92087347069", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ], + "query": [ + { + "key": "investor_cpf", + "value": "92087347069" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:46:27 GMT" + }, + { + "key": "Content-Length", + "value": "56" + } + ], + "cookie": [], + "body": "Investor of CPF 92087347069 doesn't have any investment\n" + } + ] + } + ], + "description": "The `/investments` endpoints let you manage information about investments." + }, + { + "name": "Withdrawals", + "item": [ + { + "name": "Create a new withdrawal", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"date\": \"2025-01-01\", // creation date of the withdrawal\n \"investment_id\": 1 // ID of the investment from which the withdrawal is being made\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/withdrawals", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals" + ] + } + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"date\": \"2025-01-01\", // creation date of the withdrawal\n \"investment_id\": 1 // ID of the investment from which the withdrawal is being made\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/withdrawals", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:15:29 GMT" + }, + { + "key": "Content-Length", + "value": "234" + } + ], + "cookie": [], + "body": "{\n \"id\": 4,\n \"gross_amount\": 1000000,\n \"net_amount\": 1000000,\n \"date\": \"2025-01-01T00:00:00Z\",\n \"investment\": {\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 0,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n }\n}" + }, + { + "name": "Invalid withdrawal date", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"date\": \"2024-01-01\", // creation date of the withdrawal\n \"investment_id\": 1 // ID of the investment from which the withdrawal is being made\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/withdrawals", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:18:20 GMT" + }, + { + "key": "Content-Length", + "value": "47" + } + ], + "cookie": [], + "body": "Withdrawal date preceeds investment's creation\n" + } + ] + }, + { + "name": "Find withdrawal by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/withdrawals/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1", + "description": "(Required) ID of the withdrawal to return" + } + ] + }, + "description": "Returns a single withdrawal based on its ID" + }, + "response": [ + { + "name": "Successful response", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/withdrawals/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1", + "description": "(Required) ID of the withdrawal to return" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:21:00 GMT" + }, + { + "key": "Content-Length", + "value": "234" + } + ], + "cookie": [], + "body": "{\n \"id\": 1,\n \"gross_amount\": 1000000,\n \"net_amount\": 1000000,\n \"date\": \"2025-01-01T00:00:00Z\",\n \"investment\": {\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 0,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n }\n}" + }, + { + "name": "Not found", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/withdrawals/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "5", + "description": "(Required) ID of the withdrawal to return" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:21:48 GMT" + }, + { + "key": "Content-Length", + "value": "51" + } + ], + "cookie": [], + "body": "No record of a withdrawal with id 5 has been found\n" + }, + { + "name": "Invalid ID", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/withdrawals/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "five", + "description": "(Required) ID of the withdrawal to return" + } + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:22:24 GMT" + }, + { + "key": "Content-Length", + "value": "47" + } + ], + "cookie": [], + "body": "Withdrawal ID of value five has a syntax error\n" + } + ] + } + ], + "description": "The `/withdrawals` endpoints lets you manage information about withdrawals." + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080/api" + }, + { + "key": "investor", + "value": "{\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n}", + "type": "string" + }, + { + "key": "investment", + "value": "{\n \"investor_cpf\": \"95130357000\",\n \"initial_amount\": 1000000,\n \"creation_date\": \"2025-01-01\",\n}", + "type": "string" + }, + { + "key": "investorCpf", + "value": "95130357000", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/handlers/helpers.go b/handlers/helpers.go index 8914538ee..e7a936fe2 100644 --- a/handlers/helpers.go +++ b/handlers/helpers.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "io" + "log" "net/http" + "strconv" "strings" "time" ) @@ -85,3 +87,46 @@ func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) err return nil } + +func cpfIsValid(cpf string) bool { + if len(cpf) != 11 { + return false + } + + var cpfDigits [11]int + for i, c := range cpf { + n, err := strconv.Atoi(string(c)) + if err != nil { + log.Print(err.Error()) + } + cpfDigits[i] = n + } + + sum1 := 0 + for i := 0; i < 9; i++ { + sum1 += cpfDigits[i] * (10 - i) + } + + validator1 := (sum1 * 10) % 11 + if validator1 == 10 { + validator1 = 0 + } + if validator1 != cpfDigits[9] { + return false + } + + sum2 := validator1 * 2 + for i := 0; i < 9; i++ { + sum2 += cpfDigits[i] * (11 - i) + } + + validator2 := (sum2 * 10) % 11 + if validator2 == 10 { + validator2 = 0 + } + if validator2 != cpfDigits[10] { + return false + } + + return true +} diff --git a/handlers/investment.go b/handlers/investment.go index 10e8023ee..a7fe47937 100644 --- a/handlers/investment.go +++ b/handlers/investment.go @@ -12,12 +12,14 @@ import ( "time" "github.com/go-playground/validator/v10" + "github.com/go-sql-driver/mysql" ) type InvestmentHandler struct { Investments interface { Create(inv models.InvestmentCreationDTO) (int, error) ById(id int) (*models.Investment, error) + ByInvestorCpf(cpf string) ([]models.Investment, error) } } @@ -56,8 +58,13 @@ func (h InvestmentHandler) CreateInvestment(w http.ResponseWriter, r *http.Reque id, err := h.Investments.Create(dto) if err != nil { - log.Print(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + if err.(*mysql.MySQLError).Number == 1452 { + msg := fmt.Sprintf("There are no records of an investor with CPF of %s", dto.InvestorCPF) + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } return } @@ -77,10 +84,6 @@ func (h InvestmentHandler) CreateInvestment(w http.ResponseWriter, r *http.Reque } func (h InvestmentHandler) FindInvestmentById(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - } - pv := r.PathValue("id") id, err := strconv.Atoi(pv) @@ -120,9 +123,41 @@ func (h InvestmentHandler) FindInvestmentById(w http.ResponseWriter, r *http.Req w.Write(iJson) } +func (h InvestmentHandler) FilterByInvestorCpf(w http.ResponseWriter, r *http.Request) { + cpf := r.URL.Query().Get("investor_cpf") + + if !cpfIsValid(cpf) { + msg := "CPF provided is invalid" + http.Error(w, msg, http.StatusBadRequest) + return + } + + investments, err := h.Investments.ByInvestorCpf(cpf) + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("Investor of CPF %s not found", cpf) + http.Error(w, msg, http.StatusNotFound) + return + } + + if len(investments) == 0 { + msg := fmt.Sprintf("Investor of CPF %s doesn't have any investment", cpf) + http.Error(w, msg, http.StatusNotFound) + return + } + + investmentsJson, err := json.Marshal(investments) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + log.Print(err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(investmentsJson) +} + func notFuture(fl validator.FieldLevel) bool { today := time.Now() creationDate := fl.Field().Interface().(models.Date) - return !creationDate.After(today) } diff --git a/handlers/investment_test.go b/handlers/investment_test.go index 8c6a348de..3dacf0e2e 100644 --- a/handlers/investment_test.go +++ b/handlers/investment_test.go @@ -4,6 +4,7 @@ import ( "bytes" "causeurgnocchi/backend-test/models" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -29,6 +30,14 @@ func (m mockInvestmentModel) ById(id int) (*models.Investment, error) { return investment, nil } +func (m mockInvestmentModel) ByInvestorCpf(cpf string) ([]models.Investment, error) { + return []models.Investment{*investment}, nil +} + +func (m mockInvestmentModel) RemoveBalance(id int) error { + return nil +} + func configInvestmentTest(t *testing.T) { t.Helper() @@ -36,14 +45,15 @@ func configInvestmentTest(t *testing.T) { mux = http.NewServeMux() - mux.HandleFunc("/api/investments", h.CreateInvestment) - mux.HandleFunc("/api/investments/{id}", h.FindInvestmentById) + mux.HandleFunc("GET /api/investments/{id}", h.FindInvestmentById) + mux.HandleFunc("GET /api/investments", h.FilterByInvestorCpf) + mux.HandleFunc("POST /api/investments", h.CreateInvestment) } func TestInvestmentsCreate(t *testing.T) { configInvestmentTest(t) - dtoJson := []byte(`{"initial_amount":1000000,"creation_date":"2025-01-01","investor_cpf":"95130357000"}`) + dtoJson := []byte(fmt.Sprintf(`{"initial_amount":1000000,"creation_date":"2025-01-01","investor_cpf":"%s"}`, investor.Cpf)) rec := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/investments", bytes.NewBuffer(dtoJson)) @@ -79,3 +89,23 @@ func TestInvestmentsById(t *testing.T) { t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investmentJson), body) } } + +func TestInvestmentsByInvestorCpf(t *testing.T) { + configInvestmentTest(t) + + rec := httptest.NewRecorder() + reqUrl := fmt.Sprintf("/api/investments?investor_cpf=%s", investor.Cpf) + req := httptest.NewRequest("GET", reqUrl, nil) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + investmentsJson, _ := json.Marshal([]models.Investment{*investment}) + + if body := rec.Body.String(); body != string(investmentsJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investmentsJson), body) + } +} diff --git a/handlers/investor.go b/handlers/investor.go index 8a48b01bf..bab29ac4a 100644 --- a/handlers/investor.go +++ b/handlers/investor.go @@ -8,14 +8,11 @@ import ( "fmt" "log" "net/http" - "strconv" "github.com/go-playground/validator/v10" "github.com/go-sql-driver/mysql" ) -const mySqlKeyExists = 1062 - type InvestorHandler struct { Investors interface { Create(invstr models.Investor) error @@ -24,10 +21,6 @@ type InvestorHandler struct { } func (h InvestorHandler) CreateInvestor(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - } - var i models.Investor err := decodeJsonBody(w, r, &i) @@ -62,7 +55,7 @@ func (h InvestorHandler) CreateInvestor(w http.ResponseWriter, r *http.Request) err = h.Investors.Create(i) if err != nil { - if err.(*mysql.MySQLError).Number == mySqlKeyExists { + if err.(*mysql.MySQLError).Number == 1062 { msg := "CPF provided is already being used" http.Error(w, msg, http.StatusBadRequest) } else { @@ -86,10 +79,6 @@ func (h InvestorHandler) CreateInvestor(w http.ResponseWriter, r *http.Request) } func (h InvestorHandler) FindInvestorByCpf(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - } - cpf := r.PathValue("cpf") if !cpfIsValid(cpf) { @@ -115,46 +104,3 @@ func (h InvestorHandler) FindInvestorByCpf(w http.ResponseWriter, r *http.Reques w.Header().Set("Content-Type", "application/json") w.Write(iJson) } - -func cpfIsValid(cpf string) bool { - if len(cpf) != 11 { - return false - } - - var cpfDigits [11]int - for i, c := range cpf { - n, err := strconv.Atoi(string(c)) - if err != nil { - log.Print(err.Error()) - } - cpfDigits[i] = n - } - - sum1 := 0 - for i := 0; i < 9; i++ { - sum1 += cpfDigits[i] * (10 - i) - } - - validator1 := (sum1 * 10) % 11 - if validator1 == 10 { - validator1 = 0 - } - if validator1 != cpfDigits[9] { - return false - } - - sum2 := validator1 * 2 - for i := 0; i < 9; i++ { - sum2 += cpfDigits[i] * (11 - i) - } - - validator2 := (sum2 * 10) % 11 - if validator2 == 10 { - validator2 = 0 - } - if validator2 != cpfDigits[10] { - return false - } - - return true -} diff --git a/handlers/investor_test.go b/handlers/investor_test.go index 9fa85ff40..fdbb3146f 100644 --- a/handlers/investor_test.go +++ b/handlers/investor_test.go @@ -14,8 +14,8 @@ var ( mux *http.ServeMux investor = &models.Investor{ - Cpf: "95130357000", - Name: "Lazlo Varga", + Cpf: "92087347069", + Name: "Lazlo Varga Jr", } ) @@ -37,8 +37,8 @@ func configInvestorTest(t *testing.T) { mux = http.NewServeMux() - mux.HandleFunc("/api/investors", h.CreateInvestor) - mux.HandleFunc("/api/investors/{cpf}", h.FindInvestorByCpf) + mux.HandleFunc("GET /api/investors/{cpf}", h.FindInvestorByCpf) + mux.HandleFunc("POST /api/investors", h.CreateInvestor) } func TestInvestorsCreate(t *testing.T) { diff --git a/handlers/withdrawal.go b/handlers/withdrawal.go index 0df5ab0c7..c891c5773 100644 --- a/handlers/withdrawal.go +++ b/handlers/withdrawal.go @@ -18,13 +18,13 @@ type WithdrawalHandler struct { Create(w models.WithdrawalCreationDTO) (int, error) ById(id int) (*models.Withdrawal, error) } -} -func (h WithdrawalHandler) CreateWithdrawal(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + Investments interface { + RemoveBalance(id int) error } +} +func (h WithdrawalHandler) CreateWithdrawal(w http.ResponseWriter, r *http.Request) { var dto models.WithdrawalCreationDTO err := decodeJsonBody(w, r, &dto) @@ -48,14 +48,13 @@ func (h WithdrawalHandler) CreateWithdrawal(w http.ResponseWriter, r *http.Reque errs := err.(validator.ValidationErrors) msg := fmt.Sprintf("Invalid withdrawal information:\n%s", errs) http.Error(w, msg, http.StatusBadRequest) - return } id, err := h.Withdrawals.Create(dto) if err != nil { - if errors.Is(err, models.InvalidWithdrawalDate) { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + if errors.Is(err, models.ErrInvalidWithdrawalDate) { + http.Error(w, err.Error(), http.StatusBadRequest) } else { log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -64,11 +63,17 @@ func (h WithdrawalHandler) CreateWithdrawal(w http.ResponseWriter, r *http.Reque return } - withdrawal, err := h.Withdrawals.ById(id) + err = h.Investments.RemoveBalance(dto.InvestmentId) if err != nil { log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + withdrawal, err := h.Withdrawals.ById(id) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -79,10 +84,6 @@ func (h WithdrawalHandler) CreateWithdrawal(w http.ResponseWriter, r *http.Reque } func (h WithdrawalHandler) FindWithdrawalById(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - } - pv := r.PathValue("id") id, err := strconv.Atoi(pv) @@ -99,7 +100,6 @@ func (h WithdrawalHandler) FindWithdrawalById(w http.ResponseWriter, r *http.Req log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - return } @@ -112,7 +112,6 @@ func (h WithdrawalHandler) FindWithdrawalById(w http.ResponseWriter, r *http.Req log.Print(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - return } diff --git a/handlers/withdrawal_test.go b/handlers/withdrawal_test.go index f10e6cdda..01038af2d 100644 --- a/handlers/withdrawal_test.go +++ b/handlers/withdrawal_test.go @@ -32,12 +32,15 @@ func (m mockWithdrawalModel) ById(id int) (*models.Withdrawal, error) { func configWithdrawalTest(t *testing.T) { t.Helper() - h := WithdrawalHandler{Withdrawals: mockWithdrawalModel{}} + h := WithdrawalHandler{ + Withdrawals: mockWithdrawalModel{}, + Investments: mockInvestmentModel{}, + } mux = http.NewServeMux() - mux.HandleFunc("/api/withdrawals", h.CreateWithdrawal) - mux.HandleFunc("/api/withdrawals/{id}", h.FindWithdrawalById) + mux.HandleFunc("GET /api/withdrawals/{id}", h.FindWithdrawalById) + mux.HandleFunc("POST /api/withdrawals", h.CreateWithdrawal) } func TestWithdrawalsCreate(t *testing.T) { diff --git a/main.go b/main.go index a755456e4..0981424e6 100644 --- a/main.go +++ b/main.go @@ -26,16 +26,18 @@ func main() { withdrawalH := handlers.WithdrawalHandler{ Withdrawals: models.WithdrawalModel{Db: db}, + Investments: models.InvestmentModel{Db: db}, } - http.HandleFunc("/api/investors", investorH.CreateInvestor) - http.HandleFunc("/api/investors/{cpf}", investorH.FindInvestorByCpf) + http.HandleFunc("GET /api/investors/{cpf}", investorH.FindInvestorByCpf) + http.HandleFunc("POST /api/investors", investorH.CreateInvestor) - http.HandleFunc("/api/investments", investmentH.CreateInvestment) - http.HandleFunc("/api/investments/{id}", investmentH.FindInvestmentById) + http.HandleFunc("GET /api/investments", investmentH.FilterByInvestorCpf) + http.HandleFunc("GET /api/investments/{id}", investmentH.FindInvestmentById) + http.HandleFunc("POST /api/investments", investmentH.CreateInvestment) - http.HandleFunc("/api/withdrawals", withdrawalH.CreateWithdrawal) - http.HandleFunc("/api/withdrawals/{id}", withdrawalH.FindWithdrawalById) + http.HandleFunc("GET /api/withdrawals/{id}", withdrawalH.FindWithdrawalById) + http.HandleFunc("POST /api/withdrawals", withdrawalH.CreateWithdrawal) http.ListenAndServe(":8080", nil) } diff --git a/models/investment.go b/models/investment.go index 0d5835155..289fe0aa7 100644 --- a/models/investment.go +++ b/models/investment.go @@ -60,6 +60,37 @@ func (m InvestmentModel) ById(id int) (*Investment, error) { return &investment, nil } +func (m InvestmentModel) ByInvestorCpf(cpf string) ([]Investment, error) { + investorM := &InvestorModel{Db: m.Db} + + investor, err := investorM.ByCpf(cpf) + if err != nil { + return nil, err + } + + r, err := m.Db.Query("SELECT id, initial_amount, balance, creation_date FROM investments WHERE investor_cpf = ?", cpf) + if err != nil { + return nil, err + } + + var investements []Investment + + for r.Next() { + var i Investment + + err = r.Scan(&i.Id, &i.InitialAmount, &i.Balance, &i.CreationDate.Time) + if err != nil { + return nil, err + } + + i.Investor = *investor + + investements = append(investements, i) + } + + return investements, nil +} + func (m InvestmentModel) RemoveBalance(id int) error { _, err := m.Db.Exec("UPDATE investments SET balance = 0 WHERE id = ?", id) diff --git a/models/investment_test.go b/models/investment_test.go index 299cbe99d..7557e2aae 100644 --- a/models/investment_test.go +++ b/models/investment_test.go @@ -25,8 +25,8 @@ func TestInvestments(t *testing.T) { investmentM := InvestmentModel{Db: tx} investor := Investor{ - Cpf: "95130357000", - Name: "Lazlo Varga", + Cpf: "92087347069", + Name: "Lazlo Varga Jr", } investorM.Create(investor) @@ -34,7 +34,7 @@ func TestInvestments(t *testing.T) { investment := InvestmentCreationDTO{ InitialAmount: 1000000, CreationDate: Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, - InvestorCPF: "95130357000", + InvestorCPF: "92087347069", } id, err := investmentM.Create(investment) @@ -46,4 +46,14 @@ func TestInvestments(t *testing.T) { if err != nil { t.Errorf("Error retrieving investment:\n%s", err.Error()) } + + _, err = investmentM.ByInvestorCpf(investor.Cpf) + if err != nil { + t.Errorf("Error retrieving investments belonging to investor of CPF %s:\n%s", investor.Cpf, err.Error()) + } + + err = investmentM.RemoveBalance(id) + if err != nil { + t.Errorf("Error removing investment's balance:\n%s", err.Error()) + } } diff --git a/models/investor_test.go b/models/investor_test.go index 71a6f3dc0..5fece16c4 100644 --- a/models/investor_test.go +++ b/models/investor_test.go @@ -23,8 +23,8 @@ func TestInvestors(t *testing.T) { m := &InvestorModel{Db: tx} i := Investor{ - Cpf: "95130357000", - Name: "Lazlo Varga", + Cpf: "92087347069", + Name: "Lazlo Varga Jr", } err = m.Create(i) diff --git a/models/withdrawal.go b/models/withdrawal.go index 7434838ff..cb51b53b7 100644 --- a/models/withdrawal.go +++ b/models/withdrawal.go @@ -23,7 +23,7 @@ type WithdrawalModel struct { Db database } -var InvalidWithdrawalDate = errors.New("Withdrawal date preceeds investment's creation") +var ErrInvalidWithdrawalDate = errors.New("Withdrawal date precedes investment's creation") func (m WithdrawalModel) Create(dto WithdrawalCreationDTO) (int, error) { im := &InvestmentModel{Db: m.Db} @@ -34,7 +34,7 @@ func (m WithdrawalModel) Create(dto WithdrawalCreationDTO) (int, error) { } if dto.Date.Before(i.CreationDate.Time) { - return -1, InvalidWithdrawalDate + return -1, ErrInvalidWithdrawalDate } var taxes int diff --git a/models/withdrawal_test.go b/models/withdrawal_test.go index 2f257020b..f5ae2be31 100644 --- a/models/withdrawal_test.go +++ b/models/withdrawal_test.go @@ -26,8 +26,8 @@ func TestWithdrawalsCreate(t *testing.T) { withdrawalM := WithdrawalModel{Db: tx} investor := Investor{ - Cpf: "95130357000", - Name: "Lazlo Varga", + Cpf: "92087347069", + Name: "Lazlo Varga Jr", } investorM.Create(investor) @@ -35,7 +35,7 @@ func TestWithdrawalsCreate(t *testing.T) { investment := InvestmentCreationDTO{ InitialAmount: 1000000, CreationDate: Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, - InvestorCPF: "95130357000", + InvestorCPF: "92087347069", } investmentId, _ := investmentM.Create(investment) diff --git a/open-api.yaml b/open-api.yaml deleted file mode 100644 index 3bd85a1a0..000000000 --- a/open-api.yaml +++ /dev/null @@ -1,294 +0,0 @@ -openapi: 3.0.3 - -info: - title: Coderockr Backend Test - OpenAPI 3.0 - description: |- - A submission for Coderockr's backend development test. -
-
- An investment managment application exposed through a REST API that deals with 3 entities: -
- - Investors -
- - Investments -
- - Withdrawals - - version: 1.0.0 - -servers: - - url: http://localhost:8080/api - -tags: - - name: investors - - name: investments - - name: withdrawals - -paths: - /investors: - post: - tags: - - investors - summary: Add a new investor - operationId: createInvestor - requestBody: - description: Create a new investor - content: - application/json: - schema: - $ref: "#/components/schemas/Investor" - required: true - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Investor" - "400": - description: Bad request - "500": - description: Internal server error - - /investors/{cpf}: - get: - tags: - - investors - summary: Find investor by CPF - description: Returns a single investor based on their CPF - operationId: findInvestorByCpf - parameters: - - name: cpf - in: path - description: CPF of the investor to return - required: true - schema: - type: string - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Investor" - "400": - description: Bad request - "404": - description: Investor not found - "500": - description: Internal server error - - /investments: - post: - tags: - - investments - summary: Create a new investment - operationId: createInvestment - requestBody: - description: Create a new investment - content: - application/json: - schema: - $ref: "#/components/schemas/NewInvestment" - required: true - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Investment" - "400": - description: Bad request - "500": - description: Internal server error - - /investments/{id}: - get: - tags: - - investments - summary: Find investment by ID - description: Returns a single investment based on its ID - operationId: findInvestmentById - parameters: - - name: id - in: path - description: ID of the investment to return - required: true - schema: - type: string - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Investment" - "400": - description: Bad request - "404": - description: Investor not found - "500": - description: Internal server error - - /withdrawals: - post: - tags: - - withdrawals - summary: Add a new withdrawal - operationId: addWithdrawal - requestBody: - description: Create a new withdrawal - content: - application/json: - schema: - $ref: "#/components/schemas/NewWithdrawal" - required: true - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Withdrawal" - "400": - description: Bad request - "500": - description: Internal server error - - /withdrawals/{id}: - get: - tags: - - withdrawals - summary: Find withdrawal by ID - description: Returns a single withdrawal based on its ID - operationId: findWithdrawalById - parameters: - - name: id - in: path - description: ID of the withdrawal to return - required: true - schema: - type: string - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Withdrawal" - "400": - description: Bad request - "404": - description: Investor not found - "500": - description: Internal server error - -components: - schemas: - Investor: - properties: - cpf: - type: string - example: 95130357000 - name: - type: string - example: Lazlo Varga - - Investment: - allOf: - - $ref: "#/components/schemas/BaseInvestment" - - required: - - id - - balance - - investor - properties: - id: - type: integer - example: 1 - description: Investment ID - balance: - type: integer - example: 1000000 - description: Sum of the initial amount and the gains of the investment, represented in the smallest unit of the respective monetary system - investor: - $ref: "#/components/schemas/Investor" - - NewInvestment: - allOf: - - $ref: "#/components/schemas/BaseInvestment" - - required: - - investor_cpf - properties: - investor_cpf: - type: string - description: CPF of the investor - - BaseInvestment: - required: - - initialAmount - - creationDate - properties: - initial_amount: - type: integer - example: 1000000 - description: Amount of money that was invested initially, represented in the smallest unit of the respective monetary system - creation_date: - type: string - format: date - example: 2025-01-01 - description: Date of creation of the investment - - Withdrawal: - allOf: - - $ref: "#/components/schemas/NewWithdrawal" - - required: - - id - - gross_amount - - net_amount - properties: - id: - type: integer - example: 1 - description: Withdrawal ID - gross_amount: - type: integer - example: 1000000 - description: The investment balance at the moment the withdrawal was made, represented in the smallest unit of the respective monetary system - net_amount: - type: integer - example: 1000000 - description: The investment balance minus appliable taxes, represented in the smallest unit of the respective monetary system - - NewWithdrawal: - required: - - date - - investment_id - properties: - date: - type: string - format: date - example: 2025-01-01 - description: Date of creation of the withdrawal - - requestBodies: - Investor: - description: Investor object that needs to be created - content: - application/json: - schema: - $ref: "#/components/schemas/Investor" - - NewInvestment: - description: Investment object that needs to be created - content: - application/json: - schema: - $ref: "#/components/schemas/Investment" - - NewWithdrawal: - description: Withdrawal object that needs to be created - content: - application/json: - schema: - $ref: "#/components/schemas/Withdrawal" From 5430c25ae1e26ef55b4ffea6a208498ac56ff595 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Sat, 1 Feb 2025 14:34:22 -0300 Subject: [PATCH 16/18] Rename Postman collection file --- ...lection.json => coderockr_backend_test.postman_collection.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Coderockr Backend Test.postman_collection.json => coderockr_backend_test.postman_collection.json (100%) diff --git a/Coderockr Backend Test.postman_collection.json b/coderockr_backend_test.postman_collection.json similarity index 100% rename from Coderockr Backend Test.postman_collection.json rename to coderockr_backend_test.postman_collection.json From b527adb8de80904eef5c62421dad9ea9fa2b4777 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Sat, 1 Feb 2025 19:16:50 -0300 Subject: [PATCH 17/18] Add README --- README.md | 133 +++++++++++++++++++++++------------------------------- 1 file changed, 57 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index ea8115e67..7c67575a4 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,69 @@ -# Back End Test Project +# Coderockr Backend Test -You should see this challenge as an opportunity to create an application following modern development best practices (given the stack of your choice), but also feel free to use your own architecture preferences (coding standards, code organization, third-party libraries, etc). It’s perfectly fine to use vanilla code or any framework or libraries. +A submission for Coderockr's backend development test. -## Scope +## Description -In this challenge you should build an API for an application that stores and manages investments, it should have the following features: +An investment managment application exposed through a REST API that deals with 3 entities: -1. __Creation__ of an investment with an owner, a creation date and an amount. - 1. The creation date of an investment can be today or a date in the past. - 2. An investment should not be or become negative. -2. __View__ of an investment with its initial amount and expected balance. - 1. Expected balance should be the sum of the invested amount and the [gains][]. - 2. If an investment was already withdrawn then the balance must reflect the gains of that investment -3. __Withdrawal__ of a investment. - 1. The withdraw will always be the sum of the initial amount and its gains, - partial withdrawn is not supported. - 2. Withdrawals can happen in the past or today, but can't happen before the investment creation or the future. - 3. [Taxes][taxes] need to be applied to the withdrawals before showing the final value. -4. __List__ of a person's investments - 1. This list should have pagination. +- Investors +- Investments +- Withdrawals -__NOTE:__ the implementation of an interface will not be evaluated. +The system was programmed in Go, and uses the default library's http server. -### Gain Calculation +Unit tests were implemented for the data access layer and for the requisition handlers, which was made possible through dependency injection. -The investment will pay 0.52% every month in the same day of the investment creation. +It uses 2 third-party packages: -Given that the gain is paid every month, it should be treated as [compound gain][], which means that every new period (month) the amount gained will become part of the investment balance for the next payment. +- github.com/go-sql-driver/mysql +- github.com/go-playground/validator/v10 -### Taxation +There's a setup.sql file in the project's root. It is used in the database container to create the relevant tables and a scheduled event that applies the investments' interest monthly. -When money is withdrawn, tax is triggered. Taxes apply only to the profit/gain portion of the money withdrawn. For example, if the initial investment was 1000.00, the current balance is 1200.00, then the taxes will be applied to the 200.00. +## Requirements -The tax percentage changes according to the age of the investment: -* If it is less than one year old, the percentage will be 22.5% (tax = 45.00). -* If it is between one and two years old, the percentage will be 18.5% (tax = 37.00). -* If older than two years, the percentage will be 15% (tax = 30.00). +In order to run the application, you'll need the following: -## Requirements -1. Create project using any technology of your preference. It’s perfectly OK to use vanilla code or any framework or libraries; -2. Although you can use as many dependencies as you want, you should manage them wisely; -3. It is not necessary to send the notification emails, however, the code required for that would be welcome; -4. The API must be documented in some way. - -## Deliverables -The project source code and dependencies should be made available in GitHub. Here are the steps you should follow: -1. Fork this repository to your GitHub account (create an account if you don't have one, you will need it working with us). -2. Create a "development" branch and commit the code to it. Do not push the code to the main branch. -3. Include a README file that describes: - - Special build instructions, if any - - List of third-party libraries used and short description of why/how they were used - - A link to the API documentation. -4. Once the work is complete, create a pull request from "development" into "main" and send us the link. -5. Avoid using huge commits hiding your progress. Feel free to work on a branch and use `git rebase` to adjust your commits before submitting the final version. - -## Coding Standards -When working on the project be as clean and consistent as possible. - -## Project Deadline -Ideally you'd finish the test project in 5 days. It shouldn't take you longer than a entire week. - -## Quality Assurance -Use the following checklist to ensure high quality of the project. - -### General -- First of all, the application should run without errors. -- Are all requirements set above met? -- Is coding style consistent? -- The API is well documented? -- The API has unit tests? - -## Submission -1. A link to the Github repository. -2. Briefly describe how you decided on the tools that you used. - -## Have Fun Coding 🤘 -- This challenge description is intentionally vague in some aspects, but if you need assistance feel free to ask for help. -- If any of the seems out of your current level, you may skip it, but remember to tell us about it in the pull request. - -## Credits - -This coding challenge was inspired on [kinvoapp/kinvo-back-end-test](https://github.com/kinvoapp/kinvo-back-end-test/blob/2f17d713de739e309d17a1a74a82c3fd0e66d128/README.md) - -[gains]: #gain-calculation -[taxes]: #taxation -[interest]: #interest-calculation -[compound gain]: https://www.investopedia.com/terms/g/gain.asp +- Go +- Docker + +Also, to view the documentation you'll need Postman. + +## Run Locally + +Go to the project directory + +```bash + cd backend-test +``` + +Start the database + +```bash + docker compose up -d +``` + +Start the server (Available at port 8080 by default) + +```bash + go run main.go +``` + +## API Documentation + +I have not served the API documentation via http due to time constraints. + +Instead, I documented the API in Postman and exported the result to the coderockr_backend_test.postman_collection.json file which is located in the projects's root. + +To use it you'll have to import it in Postman. + +I apologize for the inconveninence. + +## Closing Remarks + +I'd like to thank the Coderockr team for the opportunity, this was definitely a great learning experience. + +Wish you guys the best. + +Take care! From 1a0bed9c3bdb757455d976de7127ac9c9aafe268 Mon Sep 17 00:00:00 2001 From: causeUrGnocchi Date: Sat, 1 Feb 2025 19:43:54 -0300 Subject: [PATCH 18/18] Edit README --- README.md | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7c67575a4..609004b63 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A submission for Coderockr's backend development test. ## Description -An investment managment application exposed through a REST API that deals with 3 entities: +An investment management application exposed through a REST API that deals with 3 entities: - Investors - Investments @@ -12,14 +12,13 @@ An investment managment application exposed through a REST API that deals with 3 The system was programmed in Go, and uses the default library's http server. -Unit tests were implemented for the data access layer and for the requisition handlers, which was made possible through dependency injection. +It has the following features: -It uses 2 third-party packages: - -- github.com/go-sql-driver/mysql -- github.com/go-playground/validator/v10 - -There's a setup.sql file in the project's root. It is used in the database container to create the relevant tables and a scheduled event that applies the investments' interest monthly. +- HTTP endpoints for creating and retrieving the entities mentioned above. +- An interest applying functionality that works on a timely basis implemented through MySQL's event scheduler. +- Unit tests implemented via dependency injection +- A low number of third party libraries (i.e. github.com/go-sql-driver/mysql and github.com/go-playground/validator/v10) +- Somewhat thorough error handling, which allows for informative error messages to the end-user. ## Requirements @@ -32,19 +31,19 @@ Also, to view the documentation you'll need Postman. ## Run Locally -Go to the project directory +1. Go to the project directory ```bash cd backend-test ``` -Start the database +2. Start the database ```bash docker compose up -d ``` -Start the server (Available at port 8080 by default) +3. Start the server (Available at port 8080 by default) ```bash go run main.go @@ -62,8 +61,16 @@ I apologize for the inconveninence. ## Closing Remarks -I'd like to thank the Coderockr team for the opportunity, this was definitely a great learning experience. +I'd like to point out that this projects still lack MANY features to even be considered a first prototype. Stuff like: + +- Authentication +- Endpoints for updating and deleting entities +- Email notifications + +Although I have developed REST APIs for some of my personal projects, I had yet to tackle this matter in such a intricate manner, and so it took quite a considerable amount of time for me to finally be able to show you something. + +I'd like to thank the Coderockr team for the opportunity. This was definitely a great learning experience for me. -Wish you guys the best. +Wish you guys a good one. Take care!