diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/SimpleBank.iml b/.idea/SimpleBank.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/SimpleBank.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..fa865b5 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/api/account.go b/api/account.go index 0869f93..df48396 100644 --- a/api/account.go +++ b/api/account.go @@ -2,6 +2,7 @@ package api import ( "database/sql" + "errors" "net/http" db "github.com/cukhoaimon/SimpleBank/db/sqlc" @@ -10,7 +11,7 @@ import ( type createAccountRequest struct { Owner string `json:"owner" binding:"required"` - Currency string `json:"currency" binding:"required,oneof=USD EUR VND"` + Currency string `json:"currency" binding:"required,currency"` } func (server *Server) createAccount(ctx *gin.Context) { @@ -50,7 +51,7 @@ func (server *Server) getAccount(ctx *gin.Context) { account, err := server.store.GetAccount(ctx, req.ID) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { ctx.JSON(http.StatusNotFound, errorResponse(err)) return } diff --git a/api/server.go b/api/server.go index 85e542e..1fe27a3 100644 --- a/api/server.go +++ b/api/server.go @@ -1,8 +1,11 @@ package api import ( + "fmt" db "github.com/cukhoaimon/SimpleBank/db/sqlc" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" ) type Server struct { @@ -15,11 +18,21 @@ func NewServer(store db.Store) *Server { server := &Server{store: store} router := gin.Default() + if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + err := v.RegisterValidation("currency", validCurrency) + if err != nil { + fmt.Printf("err %v", err.Error()) + return nil + } + } + // routing here router.GET("/api/v1/account", server.listAccount) router.POST("/api/v1/account", server.createAccount) router.GET("/api/v1/account/:id", server.getAccount) + router.POST("/api/v1/transfer", server.createTransfer) + server.router = router return server } diff --git a/api/transfer.go b/api/transfer.go new file mode 100644 index 0000000..20616da --- /dev/null +++ b/api/transfer.go @@ -0,0 +1,69 @@ +package api + +import ( + "database/sql" + "errors" + "fmt" + db "github.com/cukhoaimon/SimpleBank/db/sqlc" + "github.com/gin-gonic/gin" + "net/http" +) + +type transferRequest struct { + FromAccountID int64 `json:"from_account_id" binding:"required,min=1"` + ToAccountID int64 `json:"to_account_id" binding:"required,min=1"` + Amount int64 `json:"amount" binding:"required,min=1"` + Currency string `json:"currency" binding:"required,currency"` +} + +func (server *Server) createTransfer(ctx *gin.Context) { + var req transferRequest + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + if !server.validAccount(ctx, req.FromAccountID, req.Currency) { + return + } + + if !server.validAccount(ctx, req.ToAccountID, req.Currency) { + return + } + + arg := db.TransferTxParams{ + FromAccountID: req.FromAccountID, + ToAccountID: req.ToAccountID, + Amount: req.Amount, + } + + account, err := server.store.TransferTxAccount(ctx, arg) + if err != nil { + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + ctx.JSON(http.StatusCreated, account) +} + +func (server *Server) validAccount(ctx *gin.Context, accountID int64, currency string) bool { + account, err := server.store.GetAccount(ctx, accountID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + ctx.JSON(http.StatusNotFound, errorResponse(err)) + return false + } + + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return false + } + + if account.Currency != currency { + err := fmt.Errorf("account [%d] mismatch: %s vs %s ", accountID, account.Currency, currency) + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return false + } + + return true +} diff --git a/api/transfer_test.go b/api/transfer_test.go new file mode 100644 index 0000000..076f580 --- /dev/null +++ b/api/transfer_test.go @@ -0,0 +1,289 @@ +package api + +import ( + "bytes" + "database/sql" + "encoding/json" + mockdb "github.com/cukhoaimon/SimpleBank/db/mock" + db "github.com/cukhoaimon/SimpleBank/db/sqlc" + "github.com/cukhoaimon/SimpleBank/utils" + "github.com/gin-gonic/gin" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestServer_createTransfer(t *testing.T) { + fromAccount := db.Account{ + ID: utils.RandomInt(0, 100), + Owner: utils.RandomOwner(), + Balance: utils.RandomInt(100, 500), + + Currency: utils.VND, + } + + toAccount := db.Account{ + ID: utils.RandomInt(0, 100), + Owner: utils.RandomOwner(), + Balance: utils.RandomMoney(), + Currency: utils.VND, + } + + thirdAccount := db.Account{ + ID: utils.RandomInt(0, 100), + Owner: utils.RandomOwner(), + Balance: utils.RandomMoney(), + Currency: utils.EUR, + } + + currency := utils.VND + amount := int64(10) + + transferResult := db.TransferTxResult{ + Transfer: db.Transfer{ + ID: utils.RandomInt(0, 100), + FromAccountID: fromAccount.ID, + ToAccountID: toAccount.ID, + Amount: amount, + }, + FromAccount: fromAccount, + ToAccount: toAccount, + FromEntry: db.Entry{ + ID: utils.RandomInt(0, 100), + AccountID: fromAccount.ID, + Amount: -amount, + }, + ToEntry: db.Entry{ + ID: utils.RandomInt(0, 100), + AccountID: toAccount.ID, + Amount: amount, + }, + } + + tests := []struct { + name string + body gin.H + buildStubs func(store *mockdb.MockStore) + checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder) + }{ + { + name: "201 created", + body: gin.H{ + "from_account_id": fromAccount.ID, + "to_account_id": toAccount.ID, + "amount": amount, + "currency": currency, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)). + Times(1). + Return(fromAccount, nil) + + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(toAccount.ID)). + Times(1). + Return(toAccount, nil) + + store.EXPECT(). + TransferTxAccount(gomock.Any(), gomock.Eq(db.TransferTxParams{ + FromAccountID: fromAccount.ID, + ToAccountID: toAccount.ID, + Amount: amount, + })).Times(1). + Return(transferResult, nil) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusCreated, recorder.Code) + var haveResultTx db.TransferTxResult + + data, err := io.ReadAll(recorder.Body) + require.Nil(t, err) + + err = json.Unmarshal(data, &haveResultTx) + + require.Equal(t, transferResult, haveResultTx) + }, + }, + { + name: "400 Bad request - binding json error", + body: gin.H{ + "tu_tai_khoan_id": fromAccount.ID, + "den_tai_khoan_id": toAccount.ID, + "so_luong": amount, + "don_vi": currency, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + TransferTxAccount(gomock.Any(), gomock.Any()). + Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + name: "404 Not found - Missing record from the result set", + body: gin.H{ + "from_account_id": 5000, + "to_account_id": toAccount.ID, + "amount": amount, + "currency": currency, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Any()). + Times(1). + Return(db.Account{}, sql.ErrNoRows) + + store.EXPECT(). + TransferTxAccount(gomock.Any(), gomock.Any()). + Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusNotFound, recorder.Code) + }, + }, + { + name: "400 Bad request - Mismatch from the first account currency", + body: gin.H{ + "from_account_id": fromAccount.ID, // currency is VND + "to_account_id": toAccount.ID, + "amount": amount, + "currency": utils.EUR, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)). + Times(1). + Return(fromAccount, nil) + + store.EXPECT(). + TransferTxAccount(gomock.Any(), gomock.Any()). + Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + name: "400 Bad request - Mismatch from the second account currency", + body: gin.H{ + "from_account_id": fromAccount.ID, // currency is VND + "to_account_id": thirdAccount.ID, // currency is EUR + "amount": amount, + "currency": utils.VND, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)). + Times(1). + Return(fromAccount, nil) + + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(thirdAccount.ID)). + Times(1). + Return(thirdAccount, nil) + + store.EXPECT(). + TransferTxAccount(gomock.Any(), gomock.Any()). + Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + name: "500 Internal server error from GetAccount", + body: gin.H{ + "from_account_id": fromAccount.ID, + "to_account_id": toAccount.ID, + "amount": amount, + "currency": currency, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)). + Times(1). + Return(db.Account{}, sql.ErrConnDone) + + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(toAccount.ID)). + Times(0) + + store.EXPECT(). + TransferTxAccount(gomock.Any(), gomock.Eq(db.TransferTxParams{ + FromAccountID: fromAccount.ID, + ToAccountID: toAccount.ID, + Amount: amount, + })).Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusInternalServerError, recorder.Code) + }, + }, + { + name: "500 Internal server error from TransferTxAccount", + body: gin.H{ + "from_account_id": fromAccount.ID, + "to_account_id": toAccount.ID, + "amount": amount, + "currency": currency, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)). + Times(1). + Return(fromAccount, nil) + + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(toAccount.ID)). + Times(1). + Return(toAccount, nil) + + store.EXPECT(). + TransferTxAccount(gomock.Any(), gomock.Eq(db.TransferTxParams{ + FromAccountID: fromAccount.ID, + ToAccountID: toAccount.ID, + Amount: amount, + })).Times(1). + Return(db.TransferTxResult{}, sql.ErrConnDone) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusInternalServerError, recorder.Code) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + store := mockdb.NewMockStore(ctrl) + + // build stubs + tc.buildStubs(store) + + // start test server and send request + server := NewServer(store) + recorder := httptest.NewRecorder() + + url := "/api/v1/transfer" + + data, err := json.Marshal(tc.body) + require.Nil(t, err) + + // send request + request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(data)) + require.Nil(t, err) + + server.router.ServeHTTP(recorder, request) + + // check response + tc.checkResponse(t, recorder) + }) + } +} diff --git a/api/validator.go b/api/validator.go new file mode 100644 index 0000000..737c676 --- /dev/null +++ b/api/validator.go @@ -0,0 +1,14 @@ +package api + +import ( + "github.com/cukhoaimon/SimpleBank/utils" + "github.com/go-playground/validator/v10" +) + +var validCurrency validator.Func = func(fieldLevel validator.FieldLevel) bool { + if currency, ok := fieldLevel.Field().Interface().(string); ok { + // check if currency is supported + return utils.IsSupportedCurrency(currency) + } + return false +} diff --git a/db/sqlc/store.go b/db/sqlc/store.go index 6201510..683ef88 100644 --- a/db/sqlc/store.go +++ b/db/sqlc/store.go @@ -24,7 +24,7 @@ func NewStore(db *sql.DB) *SQLStore { } func (store *SQLStore) execTx(ctx context.Context, fn func(*Queries) error) error { tx, err := store.db.BeginTx(ctx, &sql.TxOptions{ - Isolation: sql.LevelReadCommitted, // defaul + Isolation: sql.LevelReadCommitted, // default }) if err != nil { @@ -47,7 +47,7 @@ func (store *SQLStore) execTx(ctx context.Context, fn func(*Queries) error) erro type TransferTxParams struct { FromAccountID int64 `json:"from_account_id"` - ToAccountId int64 `json:"to_account_id"` + ToAccountID int64 `json:"to_account_id"` Amount int64 `json:"amount"` } @@ -59,7 +59,7 @@ type TransferTxResult struct { ToEntry Entry `json:"to_entry"` } -// Transaction perform an transfer between two account +// TransferTxAccount perform a transfer between two account // It create transfer record, add account entries, update account balance func (store *SQLStore) TransferTxAccount(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) { result := TransferTxResult{} @@ -69,7 +69,7 @@ func (store *SQLStore) TransferTxAccount(ctx context.Context, arg TransferTxPara result.Transfer, err = q.CreateTransfer(ctx, CreateTransferParams{ FromAccountID: arg.FromAccountID, - ToAccountID: arg.ToAccountId, + ToAccountID: arg.ToAccountID, Amount: arg.Amount, }) if err != nil { @@ -85,18 +85,18 @@ func (store *SQLStore) TransferTxAccount(ctx context.Context, arg TransferTxPara } result.ToEntry, err = q.CreateEntry(ctx, CreateEntryParams{ - AccountID: arg.ToAccountId, + AccountID: arg.ToAccountID, Amount: arg.Amount, }) if err != nil { return err } - if arg.FromAccountID < arg.ToAccountId { - result.FromAccount, result.ToAccount, err = transferMoney(ctx, q, arg.FromAccountID, -arg.Amount, arg.ToAccountId, arg.Amount) + if arg.FromAccountID < arg.ToAccountID { + result.FromAccount, result.ToAccount, err = transferMoney(ctx, q, arg.FromAccountID, -arg.Amount, arg.ToAccountID, arg.Amount) } else { - result.ToAccount, result.FromAccount, err = transferMoney(ctx, q, arg.ToAccountId, arg.Amount, arg.FromAccountID, -arg.Amount) + result.ToAccount, result.FromAccount, err = transferMoney(ctx, q, arg.ToAccountID, arg.Amount, arg.FromAccountID, -arg.Amount) } if err != nil { diff --git a/utils/currency.go b/utils/currency.go new file mode 100644 index 0000000..97d5bfc --- /dev/null +++ b/utils/currency.go @@ -0,0 +1,18 @@ +package utils + +const ( + USD = "USD" + CAD = "CAD" + EUR = "EUR" + VND = "VND" +) + +// IsSupportedCurrency return true if the currency is support +// otherwise is false +func IsSupportedCurrency(currency string) bool { + switch currency { + case USD, CAD, EUR, VND: + return true + } + return false +}