From 1467250db44e63b99aaa364d04fbb7b2c82f0f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Murta?= Date: Sun, 23 Jun 2024 20:47:54 +0100 Subject: [PATCH] Implements GET /categories Adds type to OpenAPI. Feature done and tested E2E. Adds common utilities package named "golang" to overcome language deficiencies. Issue #27 --- docs/openapi.yaml | 32 +++++++++++++ internal/api/converter.go | 15 +++++++ internal/api/impl.go | 13 ++++++ internal/api/impl_test.go | 84 +++++++++++++++++++++++++++++++++++ internal/api/server.gen.go | 59 +++++++++++++++++------- internal/dal/dal.go | 1 + internal/dal/impl.go | 15 +++++++ internal/dal/impl_test.go | 62 ++++++++++++++++++++++++++ internal/entity/entity.go | 13 ++++++ internal/golang/type.go | 17 +++++++ internal/mock/mock_dal.gen.go | 15 +++++++ 11 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 internal/golang/type.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c7b341c..1d624eb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -160,6 +160,22 @@ paths: "404": description: Bank not found + /categories: + get: + summary: Retrive existing categories + operationId: getCategories + responses: + "200": + description: Successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Category" + "204": + description: No categories + components: schemas: Transaction: @@ -204,3 +220,19 @@ components: type: array items: $ref: "#/components/schemas/Bank" + Category: + type: object + properties: + name: + type: string + group: + $ref: "#/components/schemas/CategoryGroup" + required: + - name + CategoryGroup: + type: object + properties: + name: + type: string + required: + - name diff --git a/internal/api/converter.go b/internal/api/converter.go index 8f98667..a69b1fc 100644 --- a/internal/api/converter.go +++ b/internal/api/converter.go @@ -53,3 +53,18 @@ func convertBanks(bs entity.Banks) Banks { } return ans } + +func entity2category(cat entity.Category) Category { + if cat.Group != nil { + return Category{Name: cat.Name, Group: &CategoryGroup{*cat.Group}} + } + return Category{Name: cat.Name} +} + +func entities2categories(cats entity.Categories) []Category { + var ans []Category + for _, cat := range cats { + ans = append(ans, entity2category(cat)) + } + return ans +} diff --git a/internal/api/impl.go b/internal/api/impl.go index 5550b7b..bbffd7b 100644 --- a/internal/api/impl.go +++ b/internal/api/impl.go @@ -117,3 +117,16 @@ func (server *ServerImpl) UpdateTransaction(ctx echo.Context, transactionId int6 return ctx.JSON(http.StatusOK, entity2transaction(transaction)) } + +func (server *ServerImpl) GetCategories(ctx echo.Context) error { + categories, err := server.Dal.Categories() + if err != nil { + return ctx.NoContent(http.StatusInternalServerError) + } + + if len(categories) == 0 { + return ctx.NoContent(http.StatusNoContent) + } + + return ctx.JSON(http.StatusOK, entities2categories(categories)) +} diff --git a/internal/api/impl_test.go b/internal/api/impl_test.go index c97152e..9f69cde 100644 --- a/internal/api/impl_test.go +++ b/internal/api/impl_test.go @@ -11,6 +11,7 @@ import ( "git.rosemyrtle.work/personal-finance/server/internal/dal" "git.rosemyrtle.work/personal-finance/server/internal/entity" + "git.rosemyrtle.work/personal-finance/server/internal/golang" "git.rosemyrtle.work/personal-finance/server/internal/mock" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -363,3 +364,86 @@ func TestServerImpl_UpdateTransaction(t *testing.T) { }) } } + +func TestServerImpl_GetCategories(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mock.NewMockDAL(ctrl) + e := m.EXPECT() + + type fields struct { + fn any // func(...any) *gomock.Call + args []any + returns any + err error + } + type args struct { + method string + request string + } + type want struct { + status int + body string + } + tests := []struct { + name string + fields []fields + args args + want want + wantErr bool + }{ + { + "GetCategorySuccessful", + []fields{ + {e.Categories, []any{}, entity.Categories{{Name: "C1", Group: golang.StrPtr("G1")}}, nil}, + }, + args{"GET", ""}, + want{200, `[{"group":{"name":"G1"},"name":"C1"}]`}, + false, + }, + { + "GetMultipleCategoriesSuccessful", + []fields{ + {e.Categories, []any{}, entity.Categories{{Name: "C1", Group: golang.StrPtr("G1")}, {Name: "C2"}}, nil}, + }, + args{"GET", ""}, + want{200, `[{"group":{"name":"G1"},"name":"C1"},{"name":"C2"}]`}, + false, + }, + { + "GetNoCategories", + []fields{ + {e.Categories, []any{}, entity.Categories{}, nil}, + }, + args{"GET", ""}, + want{204, ""}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &ServerImpl{ + Dal: m, + } + for _, expect := range tt.fields { + golang.Call(expect.fn, expect.args...).Return(expect.returns, expect.err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(tt.args.method, "/", strings.NewReader(tt.args.request)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + ctx := echo.New().NewContext(req, rec) + + if err := server.GetCategories(ctx); (err != nil) != tt.wantErr { + t.Errorf("ServerImpl.GetCategories() error = %v, wantErr %v", err, tt.wantErr) + } + if got := rec.Code; !reflect.DeepEqual(got, tt.want.status) { + t.Errorf("ServerImpl.GetCategories() = %v, want %v", got, tt.name) + } + if got := strings.TrimSpace(rec.Body.String()); !reflect.DeepEqual(got, tt.want.body) { + t.Errorf("ServerImpl.GetCategories() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/api/server.gen.go b/internal/api/server.gen.go index 5414c75..ddede2c 100644 --- a/internal/api/server.gen.go +++ b/internal/api/server.gen.go @@ -29,6 +29,17 @@ type Bank struct { // Banks defines model for Banks. type Banks = []Bank +// Category defines model for Category. +type Category struct { + Group *CategoryGroup `json:"group,omitempty"` + Name string `json:"name"` +} + +// CategoryGroup defines model for CategoryGroup. +type CategoryGroup struct { + Name string `json:"name"` +} + // Transaction defines model for Transaction. type Transaction struct { Category *string `json:"category,omitempty"` @@ -73,6 +84,9 @@ type ServerInterface interface { // Find bank by ID // (GET /banks/{bankId}) GetBankById(ctx echo.Context, bankId string) error + // Retrive existing categories + // (GET /categories) + GetCategories(ctx echo.Context) error // Retrieve existing transactions // (GET /transactions) GetTransactions(ctx echo.Context, params GetTransactionsParams) error @@ -117,6 +131,15 @@ func (w *ServerInterfaceWrapper) GetBankById(ctx echo.Context) error { return err } +// GetCategories converts echo context to params. +func (w *ServerInterfaceWrapper) GetCategories(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetCategories(ctx) + return err +} + // GetTransactions converts echo context to params. func (w *ServerInterfaceWrapper) GetTransactions(ctx echo.Context) error { var err error @@ -234,6 +257,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/banks", wrapper.GetBanks) router.GET(baseURL+"/banks/:bankId", wrapper.GetBankById) + router.GET(baseURL+"/categories", wrapper.GetCategories) router.GET(baseURL+"/transactions", wrapper.GetTransactions) router.POST(baseURL+"/transactions", wrapper.CreateTransaction) router.GET(baseURL+"/transactions/:transactionId", wrapper.GetTransactionById) @@ -244,23 +268,24 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/8RWTW/jNhD9KwTboyIpcdCDTm0abGCg2Abd3dN2D7Q4krmlSC05tGsE/u8FSdnRl9cJ", - "ukkutmQO5+O9mTd+oKVuWq1AoaXFA7XlGhoWHm+Y+sd/t0a3YFBA+FVw/4m7FmhBLRqharpPqGINzB9o", - "w0UNahnuVdo0DGlBnROcJmPzfUINfHPCAKfFZxpMgucvR1O9+goles8+v5gSQhMefjZQ0YL+lD3WlHUF", - "ZaGa/dENM4bt/PtHw5RlJQqtpsWWDKHWZjdbGWcIg5rCD8mMIdjSiPYQYnIuhtAIhb9c04Q2QonGNbTI", - "jz6FQqjB+EsbJt0wfCU1w8f4yjUrbzrCtEuyn9PB2RzIPXiejnUf0wnkPiGhKh3w1QpZif4RGiakr0Io", - "pkr4tXEGWcphM0GQ3oOxWjFJ3kVb8gHMBszfvhIpSlA2ABNbkt69/0TuQIFhkty7lRQl+SMakc0izYk2", - "RDIEQxPqjM9gjdjaIsu2221aK5dqU2edW5vVrbxYpPmFRaY4k1pBusZGhjIFSphL74L82YL67X5JFmnu", - "0QZjYyV5epnm/q5uQbFW0IIu0jxd0IS2DNcB6Gx16PMaAlK+QZmHwo8UvQOMg+Bptq32WXqrqzw/IAwq", - "3GNtK0UZbmZfbezFyNhTZsdG5oZMfHBlCdZWTpJjVr6cq/za+xwav9dk1TlKqHVNw/xg0b8AjYANEPhX", - "WBSq7lnF2rMH/7Xk+3Mg3OyWPEBnWAMIxtLi8ziL5S3RVQhBUBMD6IzvG+HPPOYHxSloDEr744PGQdID", - "bSxfX16YhOdwcB1jj6pXGyYFJ8tbYp1PBXi0neHLByRKI6m0U3zE2juheERxtSPL28gWjsTiFFcDUTnD", - "VyUkgvFRet7JUZg75r45CC8ddb3jR3CVk5Kt/IhGGie7Zxw6Kqhvl35h07YZBZeiETiIzKFiTiItLvM8", - "GQj94uqM0E+z0lVlAUlldEO2azDQJRSHCIfQzuUXHcwneHE5k9/5nOJQ4RpCS5yI2x2dnp5kyj1ITvx1", - "z/92Lcq1L9ZqgydidEdvM6GDtv4BaolDf98XzaFxQlttZ4bvdwMMob+fo7yBxRvNdy8BhUdiP0H98qVC", - "jUH/2FeNUD0/qY59Wy98R/sB9hFDwoiCbR/2qQZmD723M/urF/vpa6wviWe22SCT7y61yR/Ryfy/0hC9", - "6rYbc39y6fUx73ZfQls3w+qnlk+G7fmkurb7y/4apL6BEly/hRJEUDmxx3aSu//VGpFrwtSsIofK/wsA", - "AP//Ba56DmsPAAA=", + "H4sIAAAAAAAC/8RXTXPbNhD9Kxi0R5qkLU8PPLW2Jx7NdFJPk5zSHCBySSEFAQZYSNV4/N87ACgZ/FDk", + "jGP7YonCYj/e231L39NStZ2SINHQ4p6acg0t81+vmPzXfXZadaCRg/+VV+4v7jqgBTWouWzoQ0Ila2H+", + "QOmKNyCX/l6tdMuQFtRaXtFkbP6QUA3fLNdQ0eIz9Sbe85eDqVp9hRKdZ5dfSAmh9V9+1VDTgv6SPdaU", + "9QVlvpqHgxumNdu552uG0Ci9m1baaGW7U27312+98XEgRpUdLWrob5LTM71/1EwaViJXcuq7jJCY0Fgx", + "hAGB/odkxhBMqXm3DzE558M+4BJ/u6QJbbnkrW1pkR98conQgHaXNkzYYfhaKIaP8aVtV850BESfZJzT", + "3tkJeJ7eWDGmk/5yCXFZK4+vkshKdF+hZVy4KrhksoTfW6uRpRVsJgjSO9BGSSbIu2BLPoDegP7HVSJ4", + "CdLAY2PQ2/efyC1I0EyQO7sSvCR/BiOyWaQ5UZoIhqBpQq12GawRO1Nk2Xa7TRtpU6WbrHdrsqYTZ4s0", + "PzPIZMWEkpCusRW+TI4C5tI7I391IP+4W5JFmju0QZtQSZ6ep7m7qzqQrOO0oIs0Txc0oR3DtQc6W+2H", + "ugGPlGtQ5qBw+kFvAcPUO5pNp1yWzuoiz/cIg/T3WNcJXvqb2VcTejEw9hShMIG5IRMfbFmCMbUV5JCV", + "K+civ3Q+h8bvFVn1jhJqbNsyN1j0b0DNYQME/uMGuWwiq1B7du8+ltXDKRCudsvKQ6dZCwja0OLzOIvl", + "DVG1D0FQEQ1otesb7s4c5nt5LWgISuPxQW0hiUAba86XFybhRzi4DLFH1csNE7wiyxtirEsFqmA7w5cL", + "SKRCUisrqxFr77isAoqrHVneBLZ6udwviyNMXT9aPROuJ4nRYZvNKtHz+jkqeKap454eWWY40tVjYA30", + "90Rr11wgaEdI5J0cdljf5N8s+Ie+y6PjR2ClFYKtnJqFjp/s1nHosGzcZMWFTSdsFFzwluMgcgU1swJp", + "cZ7nyWAnLi5O7MRpVqquDSCptWrJdg0a+oSC3uAQ2rn8goP5BM/OZ/I7nVPQH1yDn54jcfuj40KTTLkH", + "URF33fG/XfNy7Yo1SuORGP3R24jZoK1/wiDi0N/398vQOKGdMjPDd62BIcSvMmETgMErVe1eAgqHxMME", + "9fOXCjUG/WOsGr766ugiiW3djjjYD7APGBJGJGxj2KcamN1HTydWfRT76Rs/lsQTi3+QyXf3/+SdfTL/", + "rzREr/piMOb+6PtBjHn/mpDQzs6w+qmrJsP246Tarv/v5jVIfQMluHwLJQigVsQc2knsntUagWvC5Kwi", + "+8r/DwAA//8OAsybgxEAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/dal/dal.go b/internal/dal/dal.go index e2d747e..bcf67fa 100644 --- a/internal/dal/dal.go +++ b/internal/dal/dal.go @@ -10,6 +10,7 @@ type DAL interface { TransactionExists(uint64) (bool, error) Bank(bankId string) (*entity.Bank, error) Banks() (entity.Banks, error) + Categories() (entity.Categories, error) } //go:generate go run go.uber.org/mock/mockgen@latest -destination=../mock/mock_dal.gen.go -package=mock . DAL diff --git a/internal/dal/impl.go b/internal/dal/impl.go index 8e63968..0a6790f 100644 --- a/internal/dal/impl.go +++ b/internal/dal/impl.go @@ -157,3 +157,18 @@ func (dal *DalImpl) Banks() (entity.Banks, error) { return convert[entity.Bank](rows), nil } + +func (dal *DalImpl) Categories() (entity.Categories, error) { + log.Print("DAL::Categories") + + if dal.Db == nil { + log.Panic("database not available") + } + + rows, err := dal.Db.Query("SELECT c.name, c.group FROM pfbudget.categories c") + if err != nil { + return []entity.Category{}, err + } + + return convert[entity.Category](rows), nil +} diff --git a/internal/dal/impl_test.go b/internal/dal/impl_test.go index 6bd92fc..6f26daa 100644 --- a/internal/dal/impl_test.go +++ b/internal/dal/impl_test.go @@ -8,6 +8,7 @@ import ( "time" "git.rosemyrtle.work/personal-finance/server/internal/entity" + "git.rosemyrtle.work/personal-finance/server/internal/golang" "github.com/DATA-DOG/go-sqlmock" "github.com/google/uuid" "github.com/shopspring/decimal" @@ -424,3 +425,64 @@ func TestDalImpl_TransactionExists(t *testing.T) { }) } } + +func TestDalImpl_Categories(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + + type fields struct { + Db *sql.DB + } + tests := []struct { + name string + fields fields + mocks [][]driver.Value + want entity.Categories + wantErr bool + }{ + { + "SelectCategories", + fields{db}, + [][]driver.Value{{"C1", "G1"}}, + entity.Categories{{Name: "C1", Group: golang.StrPtr("G1")}}, + false, + }, + { + "SelectMutipleCategories", + fields{db}, + [][]driver.Value{{"C1", "G1"}, {"C2", nil}}, + entity.Categories{{Name: "C1", Group: golang.StrPtr("G1")}, {Name: "C2"}}, + false, + }, + { + "SelectNoCategories", + fields{db}, + [][]driver.Value{}, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dal := &DalImpl{ + Db: tt.fields.Db, + } + + mock. + ExpectQuery(`SELECT \w+\.name, \w+\.group FROM \w+.categories \w+`). + WithoutArgs(). + WillReturnRows(mock.NewRows([]string{"name", "group"}).AddRows(tt.mocks...)) + + got, err := dal.Categories() + if (err != nil) != tt.wantErr { + t.Errorf("DalImpl.Categories() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("DalImpl.Categories() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/entity/entity.go b/internal/entity/entity.go index 0edc391..d6c572e 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -25,3 +25,16 @@ type Bank struct { } type Banks = []Bank + +type Category struct { + Name string + Group *string +} + +type Categories = []Category + +type CategoryGroup struct { + Name string +} + +type CategoryGroups = []CategoryGroup diff --git a/internal/golang/type.go b/internal/golang/type.go new file mode 100644 index 0000000..5b9fab0 --- /dev/null +++ b/internal/golang/type.go @@ -0,0 +1,17 @@ +package golang + +import "go.uber.org/mock/gomock" + +func StrPtr(s string) *string { return &s } + +func Call(fn any, args ...any) *gomock.Call { + switch fn := fn.(type) { + case func() *gomock.Call: + return fn() + case func(any) *gomock.Call: + return fn(args[0]) + case func(any, any) *gomock.Call: + return fn(args[0], args[1]) + } + return nil +} diff --git a/internal/mock/mock_dal.gen.go b/internal/mock/mock_dal.gen.go index 54aed8d..1f195ab 100644 --- a/internal/mock/mock_dal.gen.go +++ b/internal/mock/mock_dal.gen.go @@ -69,6 +69,21 @@ func (mr *MockDALMockRecorder) Banks() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Banks", reflect.TypeOf((*MockDAL)(nil).Banks)) } +// Categories mocks base method. +func (m *MockDAL) Categories() ([]entity.Category, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Categories") + ret0, _ := ret[0].([]entity.Category) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Categories indicates an expected call of Categories. +func (mr *MockDALMockRecorder) Categories() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Categories", reflect.TypeOf((*MockDAL)(nil).Categories)) +} + // InsertTransaction mocks base method. func (m *MockDAL) InsertTransaction(arg0 entity.Transaction) (entity.Transaction, error) { m.ctrl.T.Helper() -- 2.47.2