Implements /transactions PUT method #22

Merged
satprog merged 1 commits from feature/20-transactions-put into main 2024-08-03 22:21:19 +00:00
8 changed files with 358 additions and 18 deletions
Showing only changes of commit 55bd87c864 - Show all commits

View File

@ -73,6 +73,21 @@ paths:
$ref: "#/components/schemas/Transaction" $ref: "#/components/schemas/Transaction"
"400": "400":
description: Transaction not created 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:
nunorosa marked this conversation as resolved
Review

Is this the only response allowed? If you pass an Id that does not exist what would be the expected response?

Is this the only response allowed? If you pass an Id that does not exist what would be the expected response?
Review

Fixed on #25. I was initially thinking that a PUT could also create transactions, however the recommendation from the spec is that when the URI of a resource is generated server-side, creations should be implemented in the POST method only. 4.3.4. PUT

Fixed on #25. I was initially thinking that a PUT could also create transactions, however the recommendation from the spec is that when the URI of a resource is generated server-side, creations should be implemented in the POST method only. [4.3.4. PUT](https://www.rfc-editor.org/rfc/rfc7231#section-4.3.4)
"204":
description: Transaction updated successfully
/transaction/{transactionId}: /transaction/{transactionId}:
get: get:

View File

@ -83,11 +83,39 @@ func (server *ServerImpl) CreateTransaction(ctx echo.Context) error {
return ctx.NoContent(http.StatusBadRequest) return ctx.NoContent(http.StatusBadRequest)
} }
ans, err := server.Dal.InsertTransaction(transaction2entity(*t)) transaction, err := server.Dal.InsertTransaction(transaction2entity(*t))
if err != nil { if err != nil {
log.Printf("%v", err) log.Printf("%v", err)
return ctx.NoContent(http.StatusInternalServerError) 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 {
nunorosa marked this conversation as resolved
Review

I would have inverted this if condition. It would have lowered the number of cascaded ifs below. Not that this is a problem. The number of nested ifs is really small :)

I would have inverted this if condition. It would have lowered the number of cascaded ifs below. Not that this is a problem. The number of nested ifs is really small :)
Review

Removed on #25.

Removed on #25.
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)
}
} }

View File

@ -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)
}
})
}
}

View File

@ -59,9 +59,21 @@ type GetTransactionsParams struct {
Sort *string `form:"sort,omitempty" json:"sort,omitempty"` 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. // CreateTransactionJSONRequestBody defines body for CreateTransaction for application/json ContentType.
type CreateTransactionJSONRequestBody = Transaction type CreateTransactionJSONRequestBody = Transaction
// UpdateTransactionJSONRequestBody defines body for UpdateTransaction for application/json ContentType.
type UpdateTransactionJSONRequestBody UpdateTransactionJSONBody
// ServerInterface represents all server handlers. // ServerInterface represents all server handlers.
type ServerInterface interface { type ServerInterface interface {
// Find bank by ID // Find bank by ID
@ -79,6 +91,9 @@ type ServerInterface interface {
// Create a new transaction // Create a new transaction
// (POST /transactions) // (POST /transactions)
CreateTransaction(ctx echo.Context) error CreateTransaction(ctx echo.Context) error
// Update an existing transaction
// (PUT /transactions)
UpdateTransaction(ctx echo.Context) error
} }
// ServerInterfaceWrapper converts echo contexts to parameters. // ServerInterfaceWrapper converts echo contexts to parameters.
@ -182,6 +197,15 @@ func (w *ServerInterfaceWrapper) CreateTransaction(ctx echo.Context) error {
return err 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 // 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 // are present on both echo.Echo and echo.Group, since we want to allow using
// either of them for path registration // 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+"/transaction/:transactionId", wrapper.GetTransactionById)
router.GET(baseURL+"/transactions", wrapper.GetTransactions) router.GET(baseURL+"/transactions", wrapper.GetTransactions)
router.POST(baseURL+"/transactions", wrapper.CreateTransaction) router.POST(baseURL+"/transactions", wrapper.CreateTransaction)
router.PUT(baseURL+"/transactions", wrapper.UpdateTransaction)
} }
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"jkMmmIZdTHto9N8AAAD//+YRCdlGDQAA", "FkMmmIFdH/aIepgB/VMjvxvoQusPVZTJ1392H8Z/pR6OD7NNOXMkhFiHZP7EY70fYdOWyoSZJWakwr8B",
"AAD//5ap8eBoDgAA",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file

View File

@ -6,6 +6,8 @@ type DAL interface {
Transaction(transactionId int64) (*entity.Transaction, error) Transaction(transactionId int64) (*entity.Transaction, error)
Transactions() (entity.Transactions, error) Transactions() (entity.Transactions, error)
InsertTransaction(entity.Transaction) (entity.Transaction, error) InsertTransaction(entity.Transaction) (entity.Transaction, error)
UpdateTransaction(entity.Transaction) (entity.Transaction, error)
TransactionExists(uint64) (bool, error)
Bank(bankId string) (*entity.Bank, error) Bank(bankId string) (*entity.Bank, error)
Banks() (entity.Banks, error) Banks() (entity.Banks, error)
} }

View File

@ -2,6 +2,7 @@ package dal
import ( import (
"database/sql" "database/sql"
"errors"
"log" "log"
"git.rosemyrtle.work/personal-finance/server/internal/entity" "git.rosemyrtle.work/personal-finance/server/internal/entity"
@ -68,6 +69,60 @@ func (dal *DalImpl) InsertTransaction(t entity.Transaction) (entity.Transaction,
return t, nil 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) { func (dal *DalImpl) Bank(bankId string) (*entity.Bank, error) {
log.Printf("DAL::Bank(%v)", bankId) log.Printf("DAL::Bank(%v)", bankId)

View File

@ -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, amount = \$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)
}
})
}
}

View File

@ -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) 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. // Transactions mocks base method.
func (m *MockDAL) Transactions() ([]entity.Transaction, error) { func (m *MockDAL) Transactions() ([]entity.Transaction, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -113,3 +128,18 @@ func (mr *MockDALMockRecorder) Transactions() *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transactions", reflect.TypeOf((*MockDAL)(nil).Transactions)) 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)
}