diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 28e7587..30c617c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -73,6 +73,21 @@ paths: $ref: "#/components/schemas/Transaction" "400": description: Transaction not created + put: + summary: Update an existing transaction + operationId: updateTransaction + requestBody: + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Transaction" + - required: + - id + + responses: + "204": + description: Transaction updated successfully /transaction/{transactionId}: get: diff --git a/internal/api/impl.go b/internal/api/impl.go index 7484a84..657e89f 100644 --- a/internal/api/impl.go +++ b/internal/api/impl.go @@ -83,11 +83,39 @@ func (server *ServerImpl) CreateTransaction(ctx echo.Context) error { return ctx.NoContent(http.StatusBadRequest) } - ans, err := server.Dal.InsertTransaction(transaction2entity(*t)) + transaction, err := server.Dal.InsertTransaction(transaction2entity(*t)) if err != nil { log.Printf("%v", err) return ctx.NoContent(http.StatusInternalServerError) } - return ctx.JSON(http.StatusCreated, entity2transaction(ans)) + return ctx.JSON(http.StatusCreated, entity2transaction(transaction)) +} + +func (server *ServerImpl) UpdateTransaction(ctx echo.Context) error { + t := new(Transaction) + if err := ctx.Bind(t); err != nil { + log.Printf("%v", err) + return ctx.NoContent(http.StatusBadRequest) + } + + transaction := transaction2entity(*t) + + exists, err := server.Dal.TransactionExists(transaction.Id) + if err != nil { + log.Printf("%v", err) + return ctx.NoContent(http.StatusInternalServerError) + } + + if exists { + transaction, err := server.Dal.UpdateTransaction(transaction) + if err != nil { + log.Printf("%v", err) + return ctx.NoContent(http.StatusInternalServerError) + } + + return ctx.JSON(http.StatusOK, entity2transaction(transaction)) + } else { + return ctx.NoContent(http.StatusNotFound) + } } diff --git a/internal/api/impl_test.go b/internal/api/impl_test.go index 367303e..939178f 100644 --- a/internal/api/impl_test.go +++ b/internal/api/impl_test.go @@ -286,3 +286,80 @@ func TestServerImpl_CreateTransaction(t *testing.T) { }) } } + +func TestServerImpl_UpdateTransaction(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + date, _ := time.Parse(time.DateOnly, "1974-04-25") + + m := mock.NewMockDAL(ctrl) + e := m.EXPECT() + + type fields struct { + // Dal dal.DAL + fn func(any) *gomock.Call + args any + returns any + err error + } + type args struct { + request string + } + type want struct { + status int + body string + } + tests := []struct { + name string + fields []fields + args args + want want + wantErr bool + }{ + { + "UpdateExisting", + []fields{ + {e.TransactionExists, gomock.Any(), true, nil}, + {e.UpdateTransaction, gomock.Any(), entity.Transaction{Id: 1, Date: date, Description: "freedom", Value: decimal.New(9000, 0)}, nil}, + }, + args{`{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`}, + want{200, `{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`}, + false, + }, + { + "NotExisting", + []fields{ + {e.TransactionExists, gomock.Any(), false, nil}, + }, + args{`{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`}, + want{404, ""}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &ServerImpl{ + Dal: m, + } + for _, expect := range tt.fields { + expect.fn(expect.args).Return(expect.returns, expect.err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.args.request)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + ctx := echo.New().NewContext(req, rec) + + if err := server.UpdateTransaction(ctx); (err != nil) != tt.wantErr { + t.Errorf("ServerImpl.UpdateTransaction() error = %v, wantErr %v", err, tt.wantErr) + } + if got := rec.Code; !reflect.DeepEqual(got, tt.want.status) { + t.Errorf("ServerImpl.UpdateTransaction() = %v, want %v", got, tt.name) + } + if got := strings.TrimSpace(rec.Body.String()); !reflect.DeepEqual(got, tt.want.body) { + t.Errorf("ServerImpl.UpdateTransaction() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/api/server.gen.go b/internal/api/server.gen.go index 927fd40..0670ce5 100644 --- a/internal/api/server.gen.go +++ b/internal/api/server.gen.go @@ -59,9 +59,21 @@ type GetTransactionsParams struct { Sort *string `form:"sort,omitempty" json:"sort,omitempty"` } +// UpdateTransactionJSONBody defines parameters for UpdateTransaction. +type UpdateTransactionJSONBody struct { + Category *string `json:"category,omitempty"` + Date openapi_types.Date `json:"date"` + Description string `json:"description"` + Id int64 `json:"id"` + Value float32 `json:"value"` +} + // CreateTransactionJSONRequestBody defines body for CreateTransaction for application/json ContentType. type CreateTransactionJSONRequestBody = Transaction +// UpdateTransactionJSONRequestBody defines body for UpdateTransaction for application/json ContentType. +type UpdateTransactionJSONRequestBody UpdateTransactionJSONBody + // ServerInterface represents all server handlers. type ServerInterface interface { // Find bank by ID @@ -79,6 +91,9 @@ type ServerInterface interface { // Create a new transaction // (POST /transactions) CreateTransaction(ctx echo.Context) error + // Update an existing transaction + // (PUT /transactions) + UpdateTransaction(ctx echo.Context) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -182,6 +197,15 @@ func (w *ServerInterfaceWrapper) CreateTransaction(ctx echo.Context) error { return err } +// UpdateTransaction converts echo context to params. +func (w *ServerInterfaceWrapper) UpdateTransaction(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.UpdateTransaction(ctx) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -215,28 +239,30 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/transaction/:transactionId", wrapper.GetTransactionById) router.GET(baseURL+"/transactions", wrapper.GetTransactions) router.POST(baseURL+"/transactions", wrapper.CreateTransaction) + router.PUT(baseURL+"/transactions", wrapper.UpdateTransaction) } // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/8RWTW/jNhD9KwTboyIpcdCDTm0abGCg2Abd9rTdAy2NbO5SpJYc2jUM//diKMWmPhKn", - "QLN7cSRxOPPmzeNjDrw0TWs0aHS8OHBXbqAR4fFO6C/0t7WmBYsSwldZ0S/uW+AFd2ilXvNjwrVoYH7B", - "2EquQS/DvtrYRiAvuPey4sk4/JhwC1+9tFDx4iMPISHzp1OoWX2GEikz4esgITTh4UcLNS/4D9m5p6xv", - "KAvdHE9phLViT+9/WqGdKFEaPW22FAhrY/eznVUCYdBT+JDMBIIrrWyfSkzW5ZAaqfGnW57wRmrZ+IYX", - "+Smn1AhrsLRpK5Qflq+VEXiur32zotARpz3IGNNTsjmSI3pez3XM6YRyAiR1bQK/RqMokR6hEVJRF1IL", - "XcLPjbco0gq2Ewb5I1hntFDsXRfLPoDdgv2bOlGyBO0CMZ0k+cP7v9gDaLBCsUe/UrJkv3VBbLtIc2Ys", - "UwLB8oR7Swg2iK0rsmy326Vr7VNj11mf1mXrVl0t0vzKodCVUEZDusFGhTYlKpiDd8V+b0H/8rhkizQn", - "tsG6rpM8zdNr2mta0KKVvOCLNE8XPOGtwE0gOlsJ/SU70O+yOtKXNQTGSKiCKKGjxR8ASeJ3+2UVdlvR", - "AIJ1vPh4GPG3vGemZpSQoWEW0FuiTtIalX06dAXvivJYQWg9JL1NzKj5+ImCXWuILVq/yfOnSYMOuEXb", - "KlkG5Nln152Jc77LZ/g4EcQHX5bgXO0VO5FCrN52tUfd661QsmLLe+Y8QYGqi72dxlJBpg2y2nhdhbPk", - "fNMIcgT+TuqqY3G1Z8v7sBqG5S4NyfE3Jsn9F5Zu5jp/b9iqTxT3/AeglbAFBv9Ih1Kvo6gMz8c+O0Qv", - "F2QbucXr1RulvyTiAZIXtTyx4LHtvqm6B675DUUe1X1Z6zHnkeRxdEW8Ys7u0pBrqRAsVYmLnq7jfs5f", - "PYSXftDR8plx7ZUSKzLmbtqT/zjGpbt7c6QxNxXZqLiSjcRB5Qpq4RXy4jrPk4G2FjcXrvcpKlPXDpDV", - "1jRstwELPaDuNOKQ2jl8XYJ5gFfXM/guY+pP4gaCCTxTt196/sJIprMHVTHaTvPfbWS5oWadsfhMjX7p", - "+1xKA1n/D7aLw3wvu+8wOOGtcTOH71cLAiH2l84FweGdqfZv5WDHCevX38osY08rQ/fVs1459r9T/ID7", - "jkMmmIZdTHto9N8AAAD//+YRCdlGDQAA", + "H4sIAAAAAAAC/8RXTW/jNhD9KwTboyIpcdCDTm0abGCg2A263dM2B1oc2dylSC05tGsY/u/FUIqjr8Qp", + "sNleZEkczsebN4/ygZe2bqwBg54XB+7LDdQi3t4I85V+G2cbcKggvlWSrrhvgBfco1NmzY8JN6KG+QXr", + "pFqDWcZ9lXW1QF7wEJTkydj8mHAH34JyIHnxmUeT6PnhZGpXX6BE8kz5tSkh1PHmZwcVL/hP2VNNWVdQ", + "Fqs5ntwI58Senv9ywnhRorJmWmwpENbW7WcrkwJhUFN8kcwYgi+dah5DTNbVEBpl8JdrnvBaGVWHmhf5", + "yacyCGtwtGkrdBiGr7QV+BTfhHpFpiNMuyT7OT06mwO5B8/rse5jOoGcElKmshFfa1CUSLdQC6WpCmWE", + "KeHXOjgUqYTtBEF+D85bIzR719qyj+C24P6mSrQqwfgITEtJfvf+E7sDA05odh9WWpXsj9aIbRdpzqxj", + "WiA4nvDgKIMNYuOLLNvtdunahNS6dda59dm60ReLNL/wKIwU2hpIN1jrWKZCDXPpXbAPDZjf7pdskeaE", + "NjjfVpKneXpJe20DRjSKF3yR5umCJ7wRuIlAZythvmYHui7lkd6sISJGRBUECY0WvwMkit/slzLudqIG", + "BOd58fkwwm95y2zFyCFDyxxgcASdojUK+zh0BW+D8j6D0AVIOpmYYfPxgYx9YwktWr/K88dOg4l5i6bR", + "qoyZZ198OxNP/s7P8HFCiI+hLMH7Kmh2AoVQvW5jj6o3W6GVZMtb5gOlArK1vZ7aUkBmLLLKBiPjLPlQ", + "14IUgb9TRrYorvZseRtXY7P8uSZ5/sYg+f+C0tVc5e8tW3WO+jX/CegUbIHBP8qjMuueVYZPY58deg9n", + "aNtTi9ezt+f+HIkHmbzI5YkEj2X3Tdk9UM0fSPJe3Je53se8R3kcHRGv6LM/1+RKaQRHUfpBT8dx1+dv", + "AeJD1+je8hPiJmgtViTMbbcnXxzj0O25OeKYn5JsFFyrWuEgsoRKBI28uMzzZMCtxdWZ432ala0qD8gq", + "Z2u224CDLqF2GnEI7Vx+rYP5BC8uZ/I7n1M3iRuIIvBM3G7p+QMjmfYetGS0nfq/26hyQ8V66/CZGN3S", + "/3MoDWj9HWQXh/5eVt+hccIb62eG73cHAqGvL60KgscbK/dvpWDHCeqXP0os+5pWxurls1o51r+T/QD7", + "FkMmmIFdH/aIepgB/VMjvxvoQusPVZTJ1392H8Z/pR6OD7NNOXMkhFiHZP7EY70fYdOWyoSZJWakwr8B", + "AAD//5ap8eBoDgAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/dal/dal.go b/internal/dal/dal.go index fdea783..e2d747e 100644 --- a/internal/dal/dal.go +++ b/internal/dal/dal.go @@ -6,6 +6,8 @@ type DAL interface { Transaction(transactionId int64) (*entity.Transaction, error) Transactions() (entity.Transactions, error) InsertTransaction(entity.Transaction) (entity.Transaction, error) + UpdateTransaction(entity.Transaction) (entity.Transaction, error) + TransactionExists(uint64) (bool, error) Bank(bankId string) (*entity.Bank, error) Banks() (entity.Banks, error) } diff --git a/internal/dal/impl.go b/internal/dal/impl.go index 418f368..48b5348 100644 --- a/internal/dal/impl.go +++ b/internal/dal/impl.go @@ -2,6 +2,7 @@ package dal import ( "database/sql" + "errors" "log" "git.rosemyrtle.work/personal-finance/server/internal/entity" @@ -68,6 +69,60 @@ func (dal *DalImpl) InsertTransaction(t entity.Transaction) (entity.Transaction, return t, nil } +func (dal *DalImpl) UpdateTransaction(t entity.Transaction) (entity.Transaction, error) { + log.Print("DAL::UpdateTransaction") + + if dal.Db == nil { + log.Panic("database not available") + } + + stmt := ` + UPDATE pfbudget.transactions + SET date = $2, description = $3, amount = $4 + WHERE id = $1 + ` + + result, err := dal.Db.Exec(stmt, t.Id, t.Date, t.Description, t.Value) + if err != nil { + return entity.Transaction{}, err + } + + nAffected, err := result.RowsAffected() + if err != nil { + return entity.Transaction{}, err + } + + if nAffected != 1 { + return t, errors.New("more than 1 row affected") + } + + return t, nil +} + +func (dal *DalImpl) TransactionExists(id uint64) (bool, error) { + log.Print("DAL::TransactionExists") + + if dal.Db == nil { + log.Panic("database not available") + } + + stmt := ` + SELECT EXISTS( + SELECT 1 + FROM pfbudget.transactions + WHERE id = $1 + ) + ` + + exists := new(bool) + err := dal.Db.QueryRow(stmt, id).Scan(&exists) + if err != nil { + return false, err + } + + return *exists, nil +} + func (dal *DalImpl) Bank(bankId string) (*entity.Bank, error) { log.Printf("DAL::Bank(%v)", bankId) diff --git a/internal/dal/impl_test.go b/internal/dal/impl_test.go index 9322449..fc28a8d 100644 --- a/internal/dal/impl_test.go +++ b/internal/dal/impl_test.go @@ -317,3 +317,110 @@ func TestDalImpl_InsertTransaction(t *testing.T) { }) } } + +func TestDalImpl_UpdateTransaction(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + date := time.Now() + + type fields struct { + Db *sql.DB + } + type args struct { + t entity.Transaction + } + tests := []struct { + name string + fields fields + args args + want entity.Transaction + wantErr bool + }{ + { + "SuccessfulUpdate", + fields{db}, + args{entity.Transaction{Id: entity.InvalidId, Date: date, Description: "freedom", Value: decimal.NewFromInt(9000)}}, + entity.Transaction{Id: entity.InvalidId, Date: date, Description: "freedom", Value: decimal.NewFromInt(9000)}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dal := &DalImpl{ + Db: tt.fields.Db, + } + + mock. + ExpectExec(` + UPDATE pfbudget.transactions + SET date = \$2, description = \$3, value = \$4 + WHERE id = \$1`). + WithArgs(tt.args.t.Id, tt.args.t.Date, tt.args.t.Description, tt.args.t.Value). + WillReturnResult(sqlmock.NewResult(0, 1)) + + got, err := dal.UpdateTransaction(tt.args.t) + if (err != nil) != tt.wantErr { + t.Errorf("DalImpl.UpdateTransaction() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("DalImpl.UpdateTransaction() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDalImpl_TransactionExists(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + + type fields struct { + Db *sql.DB + } + type args struct { + id uint64 + } + tests := []struct { + name string + fields fields + args args + want bool + wantErr bool + }{ + {"TransactionExists", fields{db}, args{1}, true, false}, + {"TransactionNotExists", fields{db}, args{1}, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dal := &DalImpl{ + Db: tt.fields.Db, + } + + mock. + ExpectQuery(` + SELECT EXISTS\( + SELECT 1 + FROM pfbudget.transactions + WHERE id = \$1 + \)`). + WithArgs(tt.args.id). + WillReturnRows( + mock.NewRows([]string{"exists"}). + AddRows([]driver.Value{tt.want}), + ) + + got, err := dal.TransactionExists(tt.args.id) + if (err != nil) != tt.wantErr { + t.Errorf("DalImpl.TransactionExists() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("DalImpl.TransactionExists() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/mock/mock_dal.gen.go b/internal/mock/mock_dal.gen.go index b7d78d1..54aed8d 100644 --- a/internal/mock/mock_dal.gen.go +++ b/internal/mock/mock_dal.gen.go @@ -99,6 +99,21 @@ func (mr *MockDALMockRecorder) Transaction(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transaction", reflect.TypeOf((*MockDAL)(nil).Transaction), arg0) } +// TransactionExists mocks base method. +func (m *MockDAL) TransactionExists(arg0 uint64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TransactionExists", arg0) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TransactionExists indicates an expected call of TransactionExists. +func (mr *MockDALMockRecorder) TransactionExists(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransactionExists", reflect.TypeOf((*MockDAL)(nil).TransactionExists), arg0) +} + // Transactions mocks base method. func (m *MockDAL) Transactions() ([]entity.Transaction, error) { m.ctrl.T.Helper() @@ -113,3 +128,18 @@ func (mr *MockDALMockRecorder) Transactions() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transactions", reflect.TypeOf((*MockDAL)(nil).Transactions)) } + +// UpdateTransaction mocks base method. +func (m *MockDAL) UpdateTransaction(arg0 entity.Transaction) (entity.Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTransaction", arg0) + ret0, _ := ret[0].(entity.Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateTransaction indicates an expected call of UpdateTransaction. +func (mr *MockDALMockRecorder) UpdateTransaction(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTransaction", reflect.TypeOf((*MockDAL)(nil).UpdateTransaction), arg0) +}