diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c7b341c..0475dec 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -20,7 +20,7 @@ paths: in: query description: filter by transaction category schema: - type: string + $ref: "#/components/schemas/CategoryName" nullable: true - name: limit in: query @@ -112,14 +112,15 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Transaction" + type: object + properties: + category: + $ref: "#/components/schemas/CategoryName" responses: "204": description: Transaction updated successfully - content: - application/json: - schema: - $ref: "#/components/schemas/Transaction" + "400": + description: Nothing to update "404": description: Transaction not found @@ -160,6 +161,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: @@ -178,7 +195,7 @@ components: type: number format: float category: - type: string + $ref: "#/components/schemas/CategoryName" required: - date - description @@ -204,3 +221,21 @@ components: type: array items: $ref: "#/components/schemas/Bank" + CategoryName: + type: string + Category: + type: object + properties: + name: + $ref: "#/components/schemas/CategoryName" + 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..9fead55 100644 --- a/internal/api/converter.go +++ b/internal/api/converter.go @@ -2,21 +2,18 @@ package api import ( "git.rosemyrtle.work/personal-finance/server/internal/entity" + "git.rosemyrtle.work/personal-finance/server/internal/golang" openapi_types "github.com/oapi-codegen/runtime/types" "github.com/shopspring/decimal" ) -func ptr[T any](v T) *T { - return &v -} - func entity2transaction(t entity.Transaction) Transaction { return Transaction{ - nil, - openapi_types.Date{Time: t.Date}, - t.Description, - ptr(int64(t.Id)), - float32(t.Value.InexactFloat64()), + Id: golang.Ptr(int64(t.Id)), + Date: openapi_types.Date{Time: t.Date}, + Description: t.Description, + Value: float32(t.Value.InexactFloat64()), + Category: t.Category, } } @@ -53,3 +50,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..9017165 100644 --- a/internal/api/impl.go +++ b/internal/api/impl.go @@ -5,6 +5,7 @@ import ( "net/http" "git.rosemyrtle.work/personal-finance/server/internal/dal" + "git.rosemyrtle.work/personal-finance/server/internal/entity" "github.com/labstack/echo/v4" ) @@ -93,27 +94,40 @@ func (server *ServerImpl) CreateTransaction(ctx echo.Context) error { } func (server *ServerImpl) UpdateTransaction(ctx echo.Context, transactionId int64) error { - if exists, err := server.Dal.TransactionExists(uint64(transactionId)); err != nil { + if exists, err := server.Dal.TransactionExists(entity.TransactionId(transactionId)); err != nil { log.Printf("%v", err) return ctx.NoContent(http.StatusInternalServerError) } else if !exists { return ctx.NoContent(http.StatusNotFound) } - t := new(Transaction) - if err := ctx.Bind(t); err != nil { + update := new(UpdateTransactionJSONBody) + if err := ctx.Bind(update); err != nil { log.Printf("%v", err) return ctx.NoContent(http.StatusBadRequest) } - transaction := transaction2entity(*t) - transaction.Id = uint64(transactionId) - - transaction, err := server.Dal.UpdateTransaction(transaction) + success, err := server.Dal.UpdateTransaction(entity.TransactionId(transactionId), update.Category) if err != nil { log.Printf("%v", err) return ctx.NoContent(http.StatusInternalServerError) } - return ctx.JSON(http.StatusOK, entity2transaction(transaction)) + if !success { + return ctx.NoContent(http.StatusBadRequest) + } + return ctx.NoContent(http.StatusNoContent) +} + +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..a2becef 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" @@ -291,16 +292,13 @@ 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 { - fn func(any) *gomock.Call - args any - returns any - err error + fn any // func(...any) *gomock.Call + args []any + returns []any } type args struct { request string @@ -320,17 +318,35 @@ func TestServerImpl_UpdateTransaction(t *testing.T) { { "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}, + {e.TransactionExists, []any{gomock.Any()}, []any{true, nil}}, + { + e.UpdateTransaction, + []any{gomock.Eq(uint64(1)), gomock.Eq(golang.Ptr("new"))}, + []any{true, nil}, + }, }, - args{`{"date":"1974-04-25","description":"freedom","value":9000}`, 1}, - want{200, `{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`}, + args{`{"category":"new"}`, 1}, + want{204, ""}, + false, + }, + { + "NothingToUpdate", + []fields{ + {e.TransactionExists, []any{gomock.Any()}, []any{true, nil}}, + { + e.UpdateTransaction, + []any{gomock.Eq(uint64(1)), gomock.Nil()}, + []any{false, nil}, + }, + }, + args{`{}`, 1}, + want{400, ""}, false, }, { "NotExisting", []fields{ - {e.TransactionExists, gomock.Any(), false, nil}, + {e.TransactionExists, []any{gomock.Any()}, []any{false, nil}}, }, args{`{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`, 1}, want{404, ""}, @@ -343,7 +359,7 @@ func TestServerImpl_UpdateTransaction(t *testing.T) { Dal: m, } for _, expect := range tt.fields { - expect.fn(expect.args).Return(expect.returns, expect.err) + golang.Call(expect.fn, expect.args...).Return(expect.returns...) } rec := httptest.NewRecorder() @@ -363,3 +379,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.Ptr("G1")}}, nil}, + }, + args{"GET", ""}, + want{200, `[{"group":{"name":"G1"},"name":"C1"}]`}, + false, + }, + { + "GetMultipleCategoriesSuccessful", + []fields{ + {e.Categories, []any{}, entity.Categories{{Name: "C1", Group: golang.Ptr("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..5b032be 100644 --- a/internal/api/server.gen.go +++ b/internal/api/server.gen.go @@ -29,9 +29,23 @@ 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 CategoryName `json:"name"` +} + +// CategoryGroup defines model for CategoryGroup. +type CategoryGroup struct { + Name string `json:"name"` +} + +// CategoryName defines model for CategoryName. +type CategoryName = string + // Transaction defines model for Transaction. type Transaction struct { - Category *string `json:"category,omitempty"` + Category *CategoryName `json:"category,omitempty"` Date openapi_types.Date `json:"date"` Description string `json:"description"` Id *int64 `json:"id,omitempty"` @@ -44,7 +58,7 @@ type Transactions = []Transaction // GetTransactionsParams defines parameters for GetTransactions. type GetTransactionsParams struct { // Category filter by transaction category - Category *string `form:"category,omitempty" json:"category,omitempty"` + Category *CategoryName `form:"category,omitempty" json:"category,omitempty"` // Limit number of transactions to return Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` @@ -59,11 +73,16 @@ type GetTransactionsParams struct { Sort *string `form:"sort,omitempty" json:"sort,omitempty"` } +// UpdateTransactionJSONBody defines parameters for UpdateTransaction. +type UpdateTransactionJSONBody struct { + Category *CategoryName `json:"category,omitempty"` +} + // CreateTransactionJSONRequestBody defines body for CreateTransaction for application/json ContentType. type CreateTransactionJSONRequestBody = Transaction // UpdateTransactionJSONRequestBody defines body for UpdateTransaction for application/json ContentType. -type UpdateTransactionJSONRequestBody = Transaction +type UpdateTransactionJSONRequestBody UpdateTransactionJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { @@ -73,6 +92,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 +139,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 +265,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 +276,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/8RXTXPbNhD9Kxi0R5qkI08PPLW2Jx7NdFxPk5zSHCBySSElAQZYSNV49N87ACgZ/Irk", + "OnYvNiksdt++XbwFH2kum1YKEKhp9kh1voaGucdrJv62/1slW1DIwf3KC/sXdy3QjGpUXFR0H1HBGphe", + "kKrgFYil21dK1TCkGTWGFzQamu8jquCb4QoKmn2mzsR5/nI0lauvkKP1bPF5SAiNe/hZQUkz+lPylFPS", + "JZS4bPZHN0wptrPvNwyhkmo3zrRS0rSn3B623znjgIhzNt1b22HSs/n2Q43gzlTgud7v5wr5UTGhWY5c", + "inHwPGDx/MwjWjCEXl+4H6Jx9AJ0rnh7iD5a5/324gJ/uaIRbbjgjWlolh59coFQgbKbNqw2/fBlLRk+", + "xRemWVnTAYkdyBDTwdkUtQFz5/drSPeobS0gLkrpqJcCWY72ERrGa5sFF0zk8GtjFLK4gM2IQfoASkvB", + "avLe25IPoDag/rKZ1DwHoeGpqejd/SdyBwIUq8mDWdU8J797I7JZxCmRitQMQdGIGmURrBFbnSXJdruN", + "K2Fiqaqkc6uTqq0vFnF6oZGJgtVSQLzGpnZpcqxhCt4F+aMF8dvDkizi1LINSvtM0vgyTu1e2YJgLacZ", + "XcRpvKARbRmuHdHJ6qAVFTimbO8yS4WVJXoH6MXEllm30qK0Vu/S9MAwCLePtW3Nc7cz+ap9L/qKnaM/", + "2leuX4kPJs9B69LU5IjKpvMuvbI++8b3kqw6RxHVpmmYPXP0T0DFYQME/uEauagCK5978mj/LYv9KRKu", + "d8vCUadYAwhK0+zzEMXylsjShSAoiQI0yvYNt2uW84NqZ9QHpeHxQWUgCkgb6tWXVy7Cc2pw5WMPshcb", + "VvOCLG+JNhYKFN52ol42IBESSSmNKAZVe89F4Vlc7cjy1lerU9LDDJqp1M2T1QvpOkuMjkNyUole1s9B", + "whNNHfb0wDLBga7OkdXT3xOtXfIaQdmCBN7Jcbx1Tf7NgHvpujxYPq8PhxeAIQo/d+whC3McH7YBjpo3", + "HHsgCiiZqZFml2ka9cbj4t2J8ThGJctSA5JSyYZs16CgA+SlB/ssT+HzDqYBXlxO4DuNyUsRrsEdpJm4", + "3dK85kTjNoC6IHa7bYXtmudrm6yWCmdidEv/j671OvwHnEns+/v+qOkbR7SVeuIc3ihgCOGtxg8F0Hgt", + "i91rUGGZ2I9Yv3ytUEPSP4YC4rIvZmdKaGvHxdG+x73nkDAiYBvSPpbD5DF4OzH1g9jnD/9QHU/cAXpI", + "vnsVGF3fR+f/jQ7Rm94RhrWfvSqEnHc3hoi2ZqKqn9pidNieX1TTdh86b1HU/6YEP+IrdD/6ZJuSjROF", + "81wVRB+7pN7Ndse9xLVTziPHL+kNX2zCxKQku2z+DQAA//9F7YqS5hEAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/dal/dal.go b/internal/dal/dal.go index e2d747e..7b7aaa7 100644 --- a/internal/dal/dal.go +++ b/internal/dal/dal.go @@ -6,10 +6,11 @@ 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) + UpdateTransaction(entity.TransactionId, *entity.CategoryName) (bool, error) 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..4d0879b 100644 --- a/internal/dal/impl.go +++ b/internal/dal/impl.go @@ -3,6 +3,7 @@ package dal import ( "database/sql" "errors" + "fmt" "log" "git.rosemyrtle.work/personal-finance/server/internal/entity" @@ -19,7 +20,15 @@ func (dal *DalImpl) Transaction(transactionId int64) (*entity.Transaction, error log.Panic("database not available") } - rows, err := dal.Db.Query("SELECT t.id, t.date, t.description, t.amount FROM pfbudget.transactions t WHERE t.id = $1", transactionId) + stmt := ` + SELECT t.id, t.date, t.description, t.amount, tc.name + FROM pfbudget.transactions t + LEFT JOIN pfbudget.transactions_categorized tc + ON t.id = tc.id + WHERE t.id = $1 + ` + + rows, err := dal.Db.Query(stmt, transactionId) if err != nil { return nil, err } @@ -39,7 +48,14 @@ func (dal *DalImpl) Transactions() (entity.Transactions, error) { log.Panic("database not available") } - rows, err := dal.Db.Query("SELECT t.id, t.date, t.description, t.amount FROM pfbudget.transactions t") + stmt := ` + SELECT t.id, t.date, t.description, t.amount, tc.name + FROM pfbudget.transactions t + LEFT JOIN pfbudget.transactions_categorized tc + ON t.id = tc.id + ` + + rows, err := dal.Db.Query(stmt) if err != nil { return entity.Transactions{}, err } @@ -69,34 +85,40 @@ func (dal *DalImpl) InsertTransaction(t entity.Transaction) (entity.Transaction, return t, nil } -func (dal *DalImpl) UpdateTransaction(t entity.Transaction) (entity.Transaction, error) { +func (dal *DalImpl) UpdateTransaction(id entity.TransactionId, category *entity.CategoryName) (bool, error) { log.Print("DAL::UpdateTransaction") if dal.Db == nil { log.Panic("database not available") } + if category == nil { + return false, errors.New("missing category") + } + + // TODO(#31): build stmt from existing (!=nil) arguments stmt := ` - UPDATE pfbudget.transactions - SET date = $2, description = $3, amount = $4 + UPDATE pfbudget.transactions_categorized + SET name = $2 WHERE id = $1 ` - result, err := dal.Db.Exec(stmt, t.Id, t.Date, t.Description, t.Value) + result, err := dal.Db.Exec(stmt, id, *category) if err != nil { - return entity.Transaction{}, err + return false, err } nAffected, err := result.RowsAffected() if err != nil { - return entity.Transaction{}, err + return false, err } + // TODO: find if this value can be different than 1, otherwise the func return can only be error if nAffected != 1 { - return t, errors.New("more than 1 row affected") + return false, fmt.Errorf("%d rows affected", nAffected) } - return t, nil + return true, nil } func (dal *DalImpl) TransactionExists(id uint64) (bool, error) { @@ -157,3 +179,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..400c692 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" @@ -25,27 +26,38 @@ func TestDalImpl_Transaction(t *testing.T) { } type args struct { transactionId int64 - rows [][]driver.Value } tests := []struct { name string fields fields args args + mocks [][]driver.Value want *entity.Transaction wantErr bool }{ { - "200", + "SelectTransaction", fields{db}, - args{ - 1, - [][]driver.Value{ - {1, date, "income", 1000}, - }}, + args{1}, + [][]driver.Value{ + {1, date, "income", 1000, nil}, + }, &entity.Transaction{Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000)}, false, }, - {"404", fields{db}, args{2, nil}, nil, false}, + { + "SelectTransactionWithCategory", + fields{db}, + args{1}, + [][]driver.Value{ + {1, date, "income", 1000, "C1"}, + }, + &entity.Transaction{Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000), Category: golang.Ptr("C1")}, + false, + }, + { + "SelectNoTransaction", fields{db}, args{2}, nil, nil, false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -54,12 +66,15 @@ func TestDalImpl_Transaction(t *testing.T) { } mock. - ExpectQuery("^SELECT .* FROM .*transactions t WHERE t.id = \\$1$"). + ExpectQuery(` + ^SELECT \w+\.id, \w+\.date, \w+\.description, \w+\.amount, \w+\.name + FROM \w+\.transactions \w+ + LEFT JOIN \w+\.transactions_categorized \w+ + ON \w+\.id = \w+\.id + WHERE \w+\.id = \$1$`). WithArgs(tt.args.transactionId). WillReturnRows( - mock. - NewRows([]string{"id", "date", "description", "amount"}). - AddRows(tt.args.rows...), + mock.NewRows([]string{"id", "date", "description", "amount", "category"}).AddRows(tt.mocks...), ) got, err := d.Transaction(tt.args.transactionId) @@ -84,30 +99,42 @@ func TestDalImpl_Transactions(t *testing.T) { type fields struct { Db *sql.DB } - type args struct { - rows [][]driver.Value - } tests := []struct { name string fields fields - args args + mocks [][]driver.Value want entity.Transactions wantErr bool }{ { - "200", + "SelectTransactions", fields{db}, - args{[][]driver.Value{ - {1, date, "income", 1000}, - {2, date, "expense", -10.50}, - }}, + [][]driver.Value{ + {1, date, "income", 1000, nil}, + {2, date, "expense", -10.50, nil}, + }, entity.Transactions{ {Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000)}, {Id: 2, Date: date, Description: "expense", Value: decimal.NewFromFloat(-10.50)}, }, false, }, - {"204", fields{db}, args{}, nil, false}, + { + "SelectTransactionsWithCategory", + fields{db}, + [][]driver.Value{ + {1, date, "income", 1000, "C1"}, + {2, date, "expense", -10.50, nil}, + }, + entity.Transactions{ + {Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000), Category: golang.Ptr("C1")}, + {Id: 2, Date: date, Description: "expense", Value: decimal.NewFromFloat(-10.50)}, + }, + false, + }, + { + "SelectNoTransactions", fields{db}, nil, nil, false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -116,12 +143,14 @@ func TestDalImpl_Transactions(t *testing.T) { } mock. - ExpectQuery("^SELECT .* FROM .*transactions t$"). + ExpectQuery(` + ^SELECT \w+\.id, \w+\.date, \w+\.description, \w+\.amount, \w+\.name + FROM \w+\.transactions \w+ + LEFT JOIN \w+\.transactions_categorized \w+ + ON \w+\.id = \w+\.id`). WithoutArgs(). WillReturnRows( - mock. - NewRows([]string{"id", "date", "description", "amount"}). - AddRows(tt.args.rows...), + mock.NewRows([]string{"id", "date", "description", "amount", "category"}).AddRows(tt.mocks...), ) got, err := dal.Transactions() @@ -323,28 +352,38 @@ func TestDalImpl_UpdateTransaction(t *testing.T) { if err != nil { t.Fatal(err) } - date := time.Now() type fields struct { Db *sql.DB } type args struct { - t entity.Transaction + id entity.TransactionId + category *entity.CategoryName } tests := []struct { name string fields fields args args - want entity.Transaction + mocks driver.Result + want bool 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)}, + args{id: entity.InvalidId, category: golang.Ptr("C1")}, + sqlmock.NewResult(0, 1), + true, false, }, + { + "UnsuccessfulUpdate", + fields{db}, + args{id: entity.InvalidId, category: golang.Ptr("C1")}, + sqlmock.NewResult(0, 0), + false, + true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -354,13 +393,13 @@ func TestDalImpl_UpdateTransaction(t *testing.T) { mock. ExpectExec(` - UPDATE pfbudget.transactions - SET date = \$2, description = \$3, amount = \$4 + UPDATE \w+\.transactions_categorized + SET name = \$2 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)) + WithArgs(tt.args.id, tt.args.category). + WillReturnResult(tt.mocks) - got, err := dal.UpdateTransaction(tt.args.t) + got, err := dal.UpdateTransaction(tt.args.id, tt.args.category) if (err != nil) != tt.wantErr { t.Errorf("DalImpl.UpdateTransaction() error = %v, wantErr %v", err, tt.wantErr) return @@ -424,3 +463,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.Ptr("G1")}}, + false, + }, + { + "SelectMutipleCategories", + fields{db}, + [][]driver.Value{{"C1", "G1"}, {"C2", nil}}, + entity.Categories{{Name: "C1", Group: golang.Ptr("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..62cbf07 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -7,13 +7,16 @@ import ( "github.com/shopspring/decimal" ) -const InvalidId uint64 = 0 +const InvalidId TransactionId = 0 + +type TransactionId = uint64 type Transaction struct { - Id uint64 + Id TransactionId Date time.Time Description string Value decimal.Decimal + Category *CategoryName } type Transactions = []Transaction @@ -25,3 +28,18 @@ type Bank struct { } type Banks = []Bank + +type CategoryName = string + +type Category struct { + Name CategoryName + 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..e159f8e --- /dev/null +++ b/internal/golang/type.go @@ -0,0 +1,17 @@ +package golang + +import "go.uber.org/mock/gomock" + +func Ptr[T any](v T) *T { return &v } + +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..96c6c40 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() @@ -130,16 +145,16 @@ func (mr *MockDALMockRecorder) Transactions() *gomock.Call { } // UpdateTransaction mocks base method. -func (m *MockDAL) UpdateTransaction(arg0 entity.Transaction) (entity.Transaction, error) { +func (m *MockDAL) UpdateTransaction(arg0 uint64, arg1 *string) (bool, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTransaction", arg0) - ret0, _ := ret[0].(entity.Transaction) + ret := m.ctrl.Call(m, "UpdateTransaction", arg0, arg1) + ret0, _ := ret[0].(bool) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateTransaction indicates an expected call of UpdateTransaction. -func (mr *MockDALMockRecorder) UpdateTransaction(arg0 any) *gomock.Call { +func (mr *MockDALMockRecorder) UpdateTransaction(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTransaction", reflect.TypeOf((*MockDAL)(nil).UpdateTransaction), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTransaction", reflect.TypeOf((*MockDAL)(nil).UpdateTransaction), arg0, arg1) }