Support modifying a transaction category #32

Merged
satprog merged 1 commits from feature/26-add-category into main 2025-03-19 21:36:31 +00:00
11 changed files with 208 additions and 119 deletions

View File

@ -20,7 +20,7 @@ paths:
in: query in: query
description: filter by transaction category description: filter by transaction category
schema: schema:
type: string $ref: "#/components/schemas/CategoryName"
nullable: true nullable: true
- name: limit - name: limit
in: query in: query
@ -112,14 +112,15 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Transaction" type: object
properties:
category:
$ref: "#/components/schemas/CategoryName"
responses: responses:
"204": "204":
description: Transaction updated successfully description: Transaction updated successfully
content: "400":
application/json: description: Nothing to update
schema:
$ref: "#/components/schemas/Transaction"
"404": "404":
description: Transaction not found description: Transaction not found
@ -194,7 +195,7 @@ components:
type: number type: number
format: float format: float
category: category:
type: string $ref: "#/components/schemas/CategoryName"
required: required:
- date - date
- description - description
@ -220,11 +221,13 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/Bank" $ref: "#/components/schemas/Bank"
CategoryName:
type: string
Category: Category:
type: object type: object
properties: properties:
name: name:
type: string $ref: "#/components/schemas/CategoryName"
group: group:
$ref: "#/components/schemas/CategoryGroup" $ref: "#/components/schemas/CategoryGroup"
required: required:

View File

@ -2,21 +2,18 @@ package api
import ( import (
"git.rosemyrtle.work/personal-finance/server/internal/entity" "git.rosemyrtle.work/personal-finance/server/internal/entity"
"git.rosemyrtle.work/personal-finance/server/internal/golang"
openapi_types "github.com/oapi-codegen/runtime/types" openapi_types "github.com/oapi-codegen/runtime/types"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
func ptr[T any](v T) *T {
return &v
}
func entity2transaction(t entity.Transaction) Transaction { func entity2transaction(t entity.Transaction) Transaction {
return Transaction{ return Transaction{
nil, Id: golang.Ptr(int64(t.Id)),
openapi_types.Date{Time: t.Date}, Date: openapi_types.Date{Time: t.Date},
t.Description, Description: t.Description,
ptr(int64(t.Id)), Value: float32(t.Value.InexactFloat64()),
float32(t.Value.InexactFloat64()), Category: t.Category,
} }
} }

View File

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"git.rosemyrtle.work/personal-finance/server/internal/dal" "git.rosemyrtle.work/personal-finance/server/internal/dal"
"git.rosemyrtle.work/personal-finance/server/internal/entity"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@ -93,29 +94,29 @@ func (server *ServerImpl) CreateTransaction(ctx echo.Context) error {
} }
func (server *ServerImpl) UpdateTransaction(ctx echo.Context, transactionId int64) 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) log.Printf("%v", err)
return ctx.NoContent(http.StatusInternalServerError) return ctx.NoContent(http.StatusInternalServerError)
} else if !exists { } else if !exists {
return ctx.NoContent(http.StatusNotFound) return ctx.NoContent(http.StatusNotFound)
} }
t := new(Transaction) update := new(UpdateTransactionJSONBody)
if err := ctx.Bind(t); err != nil { if err := ctx.Bind(update); err != nil {
log.Printf("%v", err) log.Printf("%v", err)
return ctx.NoContent(http.StatusBadRequest) return ctx.NoContent(http.StatusBadRequest)
} }
transaction := transaction2entity(*t) success, err := server.Dal.UpdateTransaction(entity.TransactionId(transactionId), update.Category)
transaction.Id = uint64(transactionId)
transaction, err := server.Dal.UpdateTransaction(transaction)
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.StatusOK, entity2transaction(transaction)) if !success {
return ctx.NoContent(http.StatusBadRequest)
}
return ctx.NoContent(http.StatusNoContent)
} }
func (server *ServerImpl) GetCategories(ctx echo.Context) error { func (server *ServerImpl) GetCategories(ctx echo.Context) error {

View File

@ -292,16 +292,13 @@ func TestServerImpl_UpdateTransaction(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
date, _ := time.Parse(time.DateOnly, "1974-04-25")
m := mock.NewMockDAL(ctrl) m := mock.NewMockDAL(ctrl)
e := m.EXPECT() e := m.EXPECT()
type fields struct { type fields struct {
fn func(any) *gomock.Call fn any // func(...any) *gomock.Call
args any args []any
returns any returns []any
err error
} }
type args struct { type args struct {
request string request string
@ -321,17 +318,35 @@ func TestServerImpl_UpdateTransaction(t *testing.T) {
{ {
"UpdateExisting", "UpdateExisting",
[]fields{ []fields{
{e.TransactionExists, gomock.Any(), true, nil}, {e.TransactionExists, []any{gomock.Any()}, []any{true, nil}},
{e.UpdateTransaction, gomock.Any(), entity.Transaction{Id: 1, Date: date, Description: "freedom", Value: decimal.New(9000, 0)}, 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, false,
}, },
{ {
"NotExisting", "NotExisting",
[]fields{ []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}, args{`{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`, 1},
want{404, ""}, want{404, ""},
@ -344,7 +359,7 @@ func TestServerImpl_UpdateTransaction(t *testing.T) {
Dal: m, Dal: m,
} }
for _, expect := range tt.fields { 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() rec := httptest.NewRecorder()
@ -396,7 +411,7 @@ func TestServerImpl_GetCategories(t *testing.T) {
{ {
"GetCategorySuccessful", "GetCategorySuccessful",
[]fields{ []fields{
{e.Categories, []any{}, entity.Categories{{Name: "C1", Group: golang.StrPtr("G1")}}, nil}, {e.Categories, []any{}, entity.Categories{{Name: "C1", Group: golang.Ptr("G1")}}, nil},
}, },
args{"GET", ""}, args{"GET", ""},
want{200, `[{"group":{"name":"G1"},"name":"C1"}]`}, want{200, `[{"group":{"name":"G1"},"name":"C1"}]`},
@ -405,7 +420,7 @@ func TestServerImpl_GetCategories(t *testing.T) {
{ {
"GetMultipleCategoriesSuccessful", "GetMultipleCategoriesSuccessful",
[]fields{ []fields{
{e.Categories, []any{}, entity.Categories{{Name: "C1", Group: golang.StrPtr("G1")}, {Name: "C2"}}, nil}, {e.Categories, []any{}, entity.Categories{{Name: "C1", Group: golang.Ptr("G1")}, {Name: "C2"}}, nil},
}, },
args{"GET", ""}, args{"GET", ""},
want{200, `[{"group":{"name":"G1"},"name":"C1"},{"name":"C2"}]`}, want{200, `[{"group":{"name":"G1"},"name":"C1"},{"name":"C2"}]`},

View File

@ -32,7 +32,7 @@ type Banks = []Bank
// Category defines model for Category. // Category defines model for Category.
type Category struct { type Category struct {
Group *CategoryGroup `json:"group,omitempty"` Group *CategoryGroup `json:"group,omitempty"`
Name string `json:"name"` Name CategoryName `json:"name"`
} }
// CategoryGroup defines model for CategoryGroup. // CategoryGroup defines model for CategoryGroup.
@ -40,9 +40,12 @@ type CategoryGroup struct {
Name string `json:"name"` Name string `json:"name"`
} }
// CategoryName defines model for CategoryName.
type CategoryName = string
// Transaction defines model for Transaction. // Transaction defines model for Transaction.
type Transaction struct { type Transaction struct {
Category *string `json:"category,omitempty"` Category *CategoryName `json:"category,omitempty"`
Date openapi_types.Date `json:"date"` Date openapi_types.Date `json:"date"`
Description string `json:"description"` Description string `json:"description"`
Id *int64 `json:"id,omitempty"` Id *int64 `json:"id,omitempty"`
@ -55,7 +58,7 @@ type Transactions = []Transaction
// GetTransactionsParams defines parameters for GetTransactions. // GetTransactionsParams defines parameters for GetTransactions.
type GetTransactionsParams struct { type GetTransactionsParams struct {
// Category filter by transaction category // 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 number of transactions to return
Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"`
@ -70,11 +73,16 @@ 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 *CategoryName `json:"category,omitempty"`
}
// 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. // UpdateTransactionJSONRequestBody defines body for UpdateTransaction for application/json ContentType.
type UpdateTransactionJSONRequestBody = Transaction type UpdateTransactionJSONRequestBody UpdateTransactionJSONBody
// ServerInterface represents all server handlers. // ServerInterface represents all server handlers.
type ServerInterface interface { type ServerInterface interface {
@ -268,24 +276,24 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"+8r/DwAA//8OAsybgxEAAA==", "81wVRB+7pN7Ndse9xLVTziPHL+kNX2zCxKQku2z+DQAA//9F7YqS5hEAAA==",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file

View File

@ -6,7 +6,7 @@ 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) UpdateTransaction(entity.TransactionId, *entity.CategoryName) (bool, error)
TransactionExists(uint64) (bool, 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

@ -3,6 +3,7 @@ package dal
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"log" "log"
"git.rosemyrtle.work/personal-finance/server/internal/entity" "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") 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 { if err != nil {
return nil, err return nil, err
} }
@ -39,7 +48,14 @@ func (dal *DalImpl) Transactions() (entity.Transactions, error) {
log.Panic("database not available") 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 { if err != nil {
return entity.Transactions{}, err return entity.Transactions{}, err
} }
@ -69,34 +85,40 @@ 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) { func (dal *DalImpl) UpdateTransaction(id entity.TransactionId, category *entity.CategoryName) (bool, error) {
log.Print("DAL::UpdateTransaction") log.Print("DAL::UpdateTransaction")
if dal.Db == nil { if dal.Db == nil {
log.Panic("database not available") log.Panic("database not available")
} }
if category == nil {
return false, errors.New("missing category")
}
// TODO(#31): build stmt from existing (!=nil) arguments
stmt := ` stmt := `
UPDATE pfbudget.transactions UPDATE pfbudget.transactions_categorized
SET date = $2, description = $3, amount = $4 SET name = $2
WHERE id = $1 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 { if err != nil {
return entity.Transaction{}, err return false, err
} }
nAffected, err := result.RowsAffected() nAffected, err := result.RowsAffected()
if err != nil { 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 { 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) { func (dal *DalImpl) TransactionExists(id uint64) (bool, error) {

View File

@ -26,27 +26,38 @@ func TestDalImpl_Transaction(t *testing.T) {
} }
type args struct { type args struct {
transactionId int64 transactionId int64
rows [][]driver.Value
} }
tests := []struct { tests := []struct {
name string name string
fields fields fields fields
args args args args
mocks [][]driver.Value
want *entity.Transaction want *entity.Transaction
wantErr bool wantErr bool
}{ }{
{ {
"200", "SelectTransaction",
fields{db}, fields{db},
args{ args{1},
1,
[][]driver.Value{ [][]driver.Value{
{1, date, "income", 1000}, {1, date, "income", 1000, nil},
}}, },
&entity.Transaction{Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000)}, &entity.Transaction{Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000)},
false, 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -55,12 +66,15 @@ func TestDalImpl_Transaction(t *testing.T) {
} }
mock. 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). WithArgs(tt.args.transactionId).
WillReturnRows( WillReturnRows(
mock. mock.NewRows([]string{"id", "date", "description", "amount", "category"}).AddRows(tt.mocks...),
NewRows([]string{"id", "date", "description", "amount"}).
AddRows(tt.args.rows...),
) )
got, err := d.Transaction(tt.args.transactionId) got, err := d.Transaction(tt.args.transactionId)
@ -85,30 +99,42 @@ func TestDalImpl_Transactions(t *testing.T) {
type fields struct { type fields struct {
Db *sql.DB Db *sql.DB
} }
type args struct {
rows [][]driver.Value
}
tests := []struct { tests := []struct {
name string name string
fields fields fields fields
args args mocks [][]driver.Value
want entity.Transactions want entity.Transactions
wantErr bool wantErr bool
}{ }{
{ {
"200", "SelectTransactions",
fields{db}, fields{db},
args{[][]driver.Value{ [][]driver.Value{
{1, date, "income", 1000}, {1, date, "income", 1000, nil},
{2, date, "expense", -10.50}, {2, date, "expense", -10.50, nil},
}}, },
entity.Transactions{ entity.Transactions{
{Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000)}, {Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000)},
{Id: 2, Date: date, Description: "expense", Value: decimal.NewFromFloat(-10.50)}, {Id: 2, Date: date, Description: "expense", Value: decimal.NewFromFloat(-10.50)},
}, },
false, 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -117,12 +143,14 @@ func TestDalImpl_Transactions(t *testing.T) {
} }
mock. 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(). WithoutArgs().
WillReturnRows( WillReturnRows(
mock. mock.NewRows([]string{"id", "date", "description", "amount", "category"}).AddRows(tt.mocks...),
NewRows([]string{"id", "date", "description", "amount"}).
AddRows(tt.args.rows...),
) )
got, err := dal.Transactions() got, err := dal.Transactions()
@ -324,28 +352,38 @@ func TestDalImpl_UpdateTransaction(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
date := time.Now()
type fields struct { type fields struct {
Db *sql.DB Db *sql.DB
} }
type args struct { type args struct {
t entity.Transaction id entity.TransactionId
category *entity.CategoryName
} }
tests := []struct { tests := []struct {
name string name string
fields fields fields fields
args args args args
want entity.Transaction mocks driver.Result
want bool
wantErr bool wantErr bool
}{ }{
{ {
"SuccessfulUpdate", "SuccessfulUpdate",
fields{db}, fields{db},
args{entity.Transaction{Id: entity.InvalidId, Date: date, Description: "freedom", Value: decimal.NewFromInt(9000)}}, args{id: entity.InvalidId, category: golang.Ptr("C1")},
entity.Transaction{Id: entity.InvalidId, Date: date, Description: "freedom", Value: decimal.NewFromInt(9000)}, sqlmock.NewResult(0, 1),
true,
false, false,
}, },
{
"UnsuccessfulUpdate",
fields{db},
args{id: entity.InvalidId, category: golang.Ptr("C1")},
sqlmock.NewResult(0, 0),
false,
true,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -355,13 +393,13 @@ func TestDalImpl_UpdateTransaction(t *testing.T) {
mock. mock.
ExpectExec(` ExpectExec(`
UPDATE pfbudget.transactions UPDATE \w+\.transactions_categorized
SET date = \$2, description = \$3, amount = \$4 SET name = \$2
WHERE id = \$1`). WHERE id = \$1`).
WithArgs(tt.args.t.Id, tt.args.t.Date, tt.args.t.Description, tt.args.t.Value). WithArgs(tt.args.id, tt.args.category).
WillReturnResult(sqlmock.NewResult(0, 1)) 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 { if (err != nil) != tt.wantErr {
t.Errorf("DalImpl.UpdateTransaction() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("DalImpl.UpdateTransaction() error = %v, wantErr %v", err, tt.wantErr)
return return
@ -446,14 +484,14 @@ func TestDalImpl_Categories(t *testing.T) {
"SelectCategories", "SelectCategories",
fields{db}, fields{db},
[][]driver.Value{{"C1", "G1"}}, [][]driver.Value{{"C1", "G1"}},
entity.Categories{{Name: "C1", Group: golang.StrPtr("G1")}}, entity.Categories{{Name: "C1", Group: golang.Ptr("G1")}},
false, false,
}, },
{ {
"SelectMutipleCategories", "SelectMutipleCategories",
fields{db}, fields{db},
[][]driver.Value{{"C1", "G1"}, {"C2", nil}}, [][]driver.Value{{"C1", "G1"}, {"C2", nil}},
entity.Categories{{Name: "C1", Group: golang.StrPtr("G1")}, {Name: "C2"}}, entity.Categories{{Name: "C1", Group: golang.Ptr("G1")}, {Name: "C2"}},
false, false,
}, },
{ {

View File

@ -7,13 +7,16 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
const InvalidId uint64 = 0 const InvalidId TransactionId = 0
type TransactionId = uint64
type Transaction struct { type Transaction struct {
Id uint64 Id TransactionId
Date time.Time Date time.Time
Description string Description string
Value decimal.Decimal Value decimal.Decimal
Category *CategoryName
} }
type Transactions = []Transaction type Transactions = []Transaction
@ -26,8 +29,10 @@ type Bank struct {
type Banks = []Bank type Banks = []Bank
type CategoryName = string
type Category struct { type Category struct {
Name string Name CategoryName
Group *string Group *string
} }

View File

@ -2,7 +2,7 @@ package golang
import "go.uber.org/mock/gomock" import "go.uber.org/mock/gomock"
func StrPtr(s string) *string { return &s } func Ptr[T any](v T) *T { return &v }
func Call(fn any, args ...any) *gomock.Call { func Call(fn any, args ...any) *gomock.Call {
switch fn := fn.(type) { switch fn := fn.(type) {

View File

@ -145,16 +145,16 @@ func (mr *MockDALMockRecorder) Transactions() *gomock.Call {
} }
// UpdateTransaction mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateTransaction", arg0) ret := m.ctrl.Call(m, "UpdateTransaction", arg0, arg1)
ret0, _ := ret[0].(entity.Transaction) ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
// UpdateTransaction indicates an expected call of UpdateTransaction. // 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() 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)
} }