diff --git a/.dockerignore b/.dockerignore index 6f23d5d..8639c9e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,7 @@ +# Docker .dockerignore +Dockerfile +docker-compose.yml # Jetbrains IDEA .idea @@ -11,4 +14,6 @@ README.md # Project docs +tests +Makefile .golangci.yml diff --git a/Dockerfile b/Dockerfile index cc19ee3..5d25e0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.19-alpine as builder +FROM golang:1.21-alpine as builder WORKDIR /build @@ -16,4 +16,3 @@ FROM gcr.io/distroless/base:latest COPY --from=builder /build/binary /app CMD ["/app"] - diff --git a/app/aggregate/bitcoin/entity/btc.go b/app/aggregate/bitcoin/entity/btc.go index 52987b6..988275f 100644 --- a/app/aggregate/bitcoin/entity/btc.go +++ b/app/aggregate/bitcoin/entity/btc.go @@ -1,58 +1,38 @@ package bitcoinentity import ( - "errors" "fmt" "math/big" "time" + "github.com/F0rzend/simple-go-webserver/app/common" + "github.com/shopspring/decimal" ) const ( SatoshiInBitcoin = 100_000_000 - - BTCSuffix = "BTC" + BTCSuffix = "BTC" ) type BTC struct { amount decimal.Decimal } -func NewBTC(amount float64) (BTC, error) { - if amount < 0 { - return BTC{}, ErrNegativeCurrency - } - return BTC{decimal.NewFromFloat(amount)}, nil -} - -func MustNewBTC(amount float64) BTC { - btc, err := NewBTC(amount) - if err != nil { - panic(err) - } - return btc +func NewBTC(amount float64) BTC { + return BTC{decimal.NewFromFloat(amount)} } func (btc BTC) ToFloat() *big.Float { return btc.amount.BigFloat() } -func (btc BTC) ToFloat64() float64 { - amount, _ := btc.amount.Float64() - return amount -} - func (btc BTC) ToUSD(price BTCPrice) USD { return USD{btc.amount.Mul(price.GetPrice().amount)} } -func (btc BTC) IsZero() bool { - return btc.amount.IsZero() -} - func (btc BTC) String() string { - if btc.IsZero() { + if btc.amount.IsZero() { return fmt.Sprintf("0 %s", BTCSuffix) } if btc.amount.IsInteger() { @@ -61,21 +41,15 @@ func (btc BTC) String() string { precision := MustCountPrecision(SatoshiInBitcoin) format := fmt.Sprintf("%%.%df %s", precision, BTCSuffix) - return fmt.Sprintf(format, btc.ToFloat()) + return fmt.Sprintf(format, btc.amount.BigFloat()) } func (btc BTC) Add(toAdd BTC) BTC { return BTC{btc.amount.Add(toAdd.amount)} } -// TODO remove sub error -var ErrSubtractMoreBTCThanHave = errors.New("can't subtract more btc than available") - -func (btc BTC) Sub(toSubtract BTC) (BTC, error) { - if toSubtract.amount.GreaterThan(btc.amount) { - return BTC{}, ErrSubtractMoreBTCThanHave - } - return BTC{btc.amount.Sub(toSubtract.amount)}, nil +func (btc BTC) Sub(toSubtract BTC) BTC { + return BTC{btc.amount.Sub(toSubtract.amount)} } func (btc BTC) LessThan(other BTC) bool { @@ -91,11 +65,18 @@ type BTCPrice struct { updatedAt time.Time } -func NewBTCPrice(price USD, updatedAt time.Time) BTCPrice { +func NewBTCPrice(price USD, updatedAt time.Time) (BTCPrice, error) { + if price.LessThan(NewUSD(0)) { + return BTCPrice{}, common.NewFlaggedError( + "the price cannot be negative. Please pass a number greater than 0", + common.FlagInvalidArgument, + ) + } + return BTCPrice{ price: price, updatedAt: updatedAt, - } + }, nil } func (btcPrice BTCPrice) GetPrice() USD { diff --git a/app/aggregate/bitcoin/entity/btc_test.go b/app/aggregate/bitcoin/entity/btc_test.go index 1941e53..37d9109 100644 --- a/app/aggregate/bitcoin/entity/btc_test.go +++ b/app/aggregate/bitcoin/entity/btc_test.go @@ -1,34 +1,47 @@ package bitcoinentity import ( - "math/big" "testing" + "time" + + "github.com/F0rzend/simple-go-webserver/app/common" + "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/stretchr/testify/assert" ) -func TestBTC_ToFloat(t *testing.T) { +func TestBTC_ToUSD(t *testing.T) { t.Parallel() testCases := []struct { name string - btc BTC - expected *big.Float + input BTC + price BTCPrice + expected USD }{ { name: "zero", - btc: MustNewBTC(0), - expected: big.NewFloat(0), + input: NewBTC(0), + price: BTCPrice{price: NewUSD(0)}, + expected: NewUSD(0), }, { - name: "satoshi", - btc: MustNewBTC(1e-8), - expected: big.NewFloat(1e-8), + name: "btc is 1 usd", + input: NewBTC(1), + price: BTCPrice{price: NewUSD(1)}, + expected: NewUSD(1), }, { - name: "bitcoin", - btc: MustNewBTC(1), - expected: big.NewFloat(1), + name: "btc is 100 usd", + input: NewBTC(1), + price: BTCPrice{price: NewUSD(10)}, + expected: NewUSD(10), + }, + { + name: "btc is 1 cent", + input: NewBTC(100), + price: BTCPrice{price: NewUSD(0.01)}, + expected: NewUSD(1), }, } @@ -37,46 +50,35 @@ func TestBTC_ToFloat(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - actual, _ := tc.btc.ToFloat().Float64() - expect, _ := tc.expected.Float64() + actual := tc.input.ToUSD(tc.price) - assert.Equal(t, expect, actual) + assert.Equal(t, tc.expected, actual) }) } } -func TestBTC_ToUSD(t *testing.T) { +func TestBTC_String(t *testing.T) { t.Parallel() testCases := []struct { name string input BTC - price BTCPrice - expected USD + expected string }{ { name: "zero", - input: MustNewBTC(0), - price: BTCPrice{price: MustNewUSD(0)}, - expected: MustNewUSD(0), - }, - { - name: "btc is 1 usd", - input: MustNewBTC(1), - price: BTCPrice{price: MustNewUSD(1)}, - expected: MustNewUSD(1), + input: NewBTC(0), + expected: "0 BTC", }, { - name: "btc is 100 usd", - input: MustNewBTC(1), - price: BTCPrice{price: MustNewUSD(10)}, - expected: MustNewUSD(10), + name: "satoshi", + input: NewBTC(1e-8), + expected: "0.00000001 BTC", }, { - name: "btc is 1 cent", - input: MustNewBTC(100), - price: BTCPrice{price: MustNewUSD(0.01)}, - expected: MustNewUSD(1), + name: "bitcoin", + input: NewBTC(1), + expected: "1 BTC", }, } @@ -85,30 +87,45 @@ func TestBTC_ToUSD(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - actual := tc.input.ToUSD(tc.price) + actual := tc.input.String() assert.Equal(t, tc.expected, actual) }) } } -func TestBTC_IsZero(t *testing.T) { +func TestBTC_Add(t *testing.T) { + t.Parallel() + + initial := NewBTC(1) + toAdd := NewBTC(2) + expected := NewBTC(3) + + actual := initial.Add(toAdd) + + assert.Equal(t, expected, actual) +} + +func TestBTC_Sub(t *testing.T) { t.Parallel() testCases := []struct { - name string - input BTC - expected bool + name string + initial BTC + toSubtract BTC + expected BTC }{ { - name: "zero", - input: MustNewBTC(0), - expected: true, + name: "success", + initial: NewBTC(2), + toSubtract: NewBTC(1), + expected: NewBTC(1), }, { - name: "not zero", - input: MustNewBTC(1), - expected: false, + name: "subtract more than available", + initial: NewBTC(1), + toSubtract: NewBTC(2), + expected: NewBTC(-1), }, } @@ -117,35 +134,47 @@ func TestBTC_IsZero(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - actual := tc.input.IsZero() + actual := tc.initial.Sub(tc.toSubtract) assert.Equal(t, tc.expected, actual) }) } } -func TestBTC_String(t *testing.T) { +func TestBTCComparativeTransactions(t *testing.T) { t.Parallel() testCases := []struct { - name string - input BTC - expected string + name string + a BTC + b BTC + aLessThanB bool + aEqualToB bool + aGreaterThanB bool }{ { - name: "zero", - input: MustNewBTC(0), - expected: "0 BTC", + name: "a less than b", + a: NewBTC(1), + b: NewBTC(2), + aLessThanB: true, + aEqualToB: false, + aGreaterThanB: false, }, { - name: "satoshi", - input: MustNewBTC(1e-8), - expected: "0.00000001 BTC", + name: "a equal to b", + a: NewBTC(1), + b: NewBTC(1), + aLessThanB: false, + aEqualToB: true, + aGreaterThanB: false, }, { - name: "bitcoin", - input: MustNewBTC(1), - expected: "1 BTC", + name: "a greater than b", + a: NewBTC(2), + b: NewBTC(1), + aLessThanB: false, + aEqualToB: false, + aGreaterThanB: true, }, } @@ -154,60 +183,47 @@ func TestBTC_String(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - actual := tc.input.String() - - assert.Equal(t, tc.expected, actual) + assert.Equal(t, tc.aLessThanB, tc.a.LessThan(tc.b)) + assert.Equal(t, tc.aEqualToB, tc.a.Equal(tc.b)) + assert.Equal(t, tc.aGreaterThanB, tc.b.LessThan(tc.a)) }) } } -func TestBTC_Add(t *testing.T) { - t.Parallel() - - initial := MustNewBTC(1) - toAdd := MustNewBTC(2) - expected := MustNewBTC(3) - - actual := initial.Add(toAdd) - - assert.Equal(t, expected, actual) -} - -func TestBTC_Sub(t *testing.T) { +func TestNewBTCPrice(t *testing.T) { t.Parallel() testCases := []struct { - name string - initial BTC - toSubtract BTC - expected BTC - err error + name string + price USD + expectedPrice USD + checkErr tests.ErrorChecker }{ { - name: "success", - initial: MustNewBTC(2), - toSubtract: MustNewBTC(1), - expected: MustNewBTC(1), - err: nil, + name: "success", + price: NewUSD(100.0), + expectedPrice: NewUSD(100), + checkErr: assert.NoError, }, { - name: "subtract more than available", - initial: MustNewBTC(1), - toSubtract: MustNewBTC(2), - expected: BTC{}, - err: ErrSubtractMoreBTCThanHave, + name: "negative price", + price: NewUSD(-1.0), + expectedPrice: USD{}, + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, } + now := time.Now() + for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - actual, err := tc.initial.Sub(tc.toSubtract) + price, err := NewBTCPrice(tc.price, now) - assert.ErrorIs(t, err, tc.err) - assert.Equal(t, tc.expected, actual) + tc.checkErr(t, err) + assert.Equal(t, tc.expectedPrice, price.GetPrice()) }) } } diff --git a/app/aggregate/bitcoin/entity/errors.go b/app/aggregate/bitcoin/entity/errors.go deleted file mode 100644 index 3bdf7fc..0000000 --- a/app/aggregate/bitcoin/entity/errors.go +++ /dev/null @@ -1,12 +0,0 @@ -package bitcoinentity - -import ( - "net/http" - - "github.com/F0rzend/simple-go-webserver/app/common" -) - -var ErrNegativeCurrency = common.NewApplicationError( - http.StatusBadRequest, - "The amount of currency cannot be negative. Please pass a number greater than 0", -) diff --git a/app/aggregate/bitcoin/entity/usd.go b/app/aggregate/bitcoin/entity/usd.go index 3f3edd4..38fef6d 100644 --- a/app/aggregate/bitcoin/entity/usd.go +++ b/app/aggregate/bitcoin/entity/usd.go @@ -1,7 +1,6 @@ package bitcoinentity import ( - "errors" "fmt" "math/big" @@ -10,7 +9,6 @@ import ( const ( CentInUSD = 100 - USDPrefix = "$" ) @@ -18,36 +16,16 @@ type USD struct { amount decimal.Decimal } -func NewUSD(amount float64) (USD, error) { - if amount < 0 { - return USD{}, ErrNegativeCurrency - } - return USD{decimal.NewFromFloat(amount)}, nil -} - -func MustNewUSD(amount float64) USD { - usd, err := NewUSD(amount) - if err != nil { - panic(err) - } - return usd +func NewUSD(amount float64) USD { + return USD{decimal.NewFromFloat(amount)} } func (usd USD) ToFloat() *big.Float { return usd.amount.BigFloat() } -func (usd USD) ToFloat64() float64 { - amount, _ := usd.ToFloat().Float64() - return amount -} - -func (usd USD) IsZero() bool { - return usd.amount.IsZero() -} - func (usd USD) String() string { - if usd.IsZero() { + if usd.amount.IsZero() { return fmt.Sprintf("%s0", USDPrefix) } @@ -64,20 +42,18 @@ func (usd USD) Add(toAdd USD) USD { return USD{usd.amount.Add(toAdd.amount)} } -var ErrSubtractMoreUSDThanHave = errors.New("can't subtract more usd than available") - -func (usd USD) Sub(toSubtract USD) (USD, error) { - if toSubtract.amount.GreaterThan(usd.amount) { - return USD{}, ErrSubtractMoreUSDThanHave - } - - return USD{usd.amount.Sub(toSubtract.amount)}, nil +func (usd USD) Sub(toSubtract USD) USD { + return USD{usd.amount.Sub(toSubtract.amount)} } func (usd USD) LessThan(toCompare USD) bool { return usd.amount.LessThan(toCompare.amount) } +func (usd USD) IsNegative() bool { + return usd.amount.IsNegative() +} + func (usd USD) Equal(toCompare USD) bool { return usd.amount.Equal(toCompare.amount) } diff --git a/app/aggregate/bitcoin/entity/usd_test.go b/app/aggregate/bitcoin/entity/usd_test.go index 1006219..f8038df 100644 --- a/app/aggregate/bitcoin/entity/usd_test.go +++ b/app/aggregate/bitcoin/entity/usd_test.go @@ -1,120 +1,11 @@ package bitcoinentity import ( - "math/big" "testing" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" ) -func TestNewUSD(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - input float64 - expected USD - err error - }{ - { - name: "success", - input: 1, - expected: USD{decimal.NewFromFloat(1)}, - err: nil, - }, - { - name: "negative", - input: -1, - expected: USD{}, - err: ErrNegativeCurrency, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - actual, err := NewUSD(tc.input) - - assert.ErrorIs(t, err, tc.err) - assert.Equal(t, tc.expected, actual) - }) - } -} - -func TestUSD_ToFloat(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - usd USD - expected *big.Float - }{ - { - name: "zero", - usd: MustNewUSD(0), - expected: big.NewFloat(0), - }, - { - name: "cent", - usd: MustNewUSD(0.01), - expected: big.NewFloat(0.01), - }, - { - name: "dollar", - usd: MustNewUSD(1), - expected: big.NewFloat(1), - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - actual, _ := tc.usd.ToFloat().Float64() - expect, _ := tc.expected.Float64() - - assert.Equal(t, expect, actual) - }) - } -} - -func TestUSD_IsZero(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - input USD - expected bool - }{ - { - name: "zero", - input: MustNewUSD(0), - expected: true, - }, - { - name: "not zero", - input: MustNewUSD(1), - expected: false, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - actual := tc.input.IsZero() - - assert.Equal(t, tc.expected, actual) - }) - } -} - func TestUSD_String(t *testing.T) { t.Parallel() @@ -125,17 +16,17 @@ func TestUSD_String(t *testing.T) { }{ { name: "zero", - input: MustNewUSD(0), + input: NewUSD(0), expected: "$0", }, { name: "cent", - input: MustNewUSD(0.01), + input: NewUSD(0.01), expected: "$0.01", }, { name: "dollar", - input: MustNewUSD(1), + input: NewUSD(1), expected: "$1", }, } @@ -155,9 +46,9 @@ func TestUSD_String(t *testing.T) { func TestUSD_Add(t *testing.T) { t.Parallel() - initial := MustNewUSD(1) - toAdd := MustNewUSD(2) - expected := MustNewUSD(3) + initial := NewUSD(1) + toAdd := NewUSD(2) + expected := NewUSD(3) actual := initial.Add(toAdd) @@ -172,21 +63,18 @@ func TestUSD_Sub(t *testing.T) { initial USD toSubtract USD expected USD - err error }{ { name: "success", - initial: MustNewUSD(2), - toSubtract: MustNewUSD(1), - expected: MustNewUSD(1), - err: nil, + initial: NewUSD(2), + toSubtract: NewUSD(1), + expected: NewUSD(1), }, { name: "subtract more than available", - initial: MustNewUSD(1), - toSubtract: MustNewUSD(2), - expected: USD{}, - err: ErrSubtractMoreUSDThanHave, + initial: NewUSD(1), + toSubtract: NewUSD(2), + expected: NewUSD(-1), }, } @@ -195,9 +83,8 @@ func TestUSD_Sub(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - actual, err := tc.initial.Sub(tc.toSubtract) + actual := tc.initial.Sub(tc.toSubtract) - assert.ErrorIs(t, err, tc.err) assert.Equal(t, tc.expected, actual) }) } @@ -216,24 +103,24 @@ func TestUSDComparativeTransactions(t *testing.T) { }{ { name: "a less than b", - a: MustNewUSD(1), - b: MustNewUSD(2), + a: NewUSD(1), + b: NewUSD(2), aLessThanB: true, aEqualToB: false, aGreaterThanB: false, }, { name: "a equal to b", - a: MustNewUSD(1), - b: MustNewUSD(1), + a: NewUSD(1), + b: NewUSD(1), aLessThanB: false, aEqualToB: true, aGreaterThanB: false, }, { name: "a greater than b", - a: MustNewUSD(2), - b: MustNewUSD(1), + a: NewUSD(2), + b: NewUSD(1), aLessThanB: false, aEqualToB: false, aGreaterThanB: true, diff --git a/app/aggregate/bitcoin/handlers/get_btc_price.go b/app/aggregate/bitcoin/handlers/get_btc_price.go index 4c8257d..bf0eea7 100644 --- a/app/aggregate/bitcoin/handlers/get_btc_price.go +++ b/app/aggregate/bitcoin/handlers/get_btc_price.go @@ -22,9 +22,11 @@ func BTCToResponse(btc bitcoinentity.BTCPrice) BTCResponse { } } -func (h *BitcoinHTTPHandlers) GetBTCPrice(w http.ResponseWriter, r *http.Request) { +func (h *BitcoinHTTPHandlers) GetBTCPrice(w http.ResponseWriter, r *http.Request) error { btc := h.service.GetBTCPrice() render.Status(r, http.StatusOK) render.Respond(w, r, map[string]BTCResponse{"btc": BTCToResponse(btc)}) + + return nil } diff --git a/app/aggregate/bitcoin/handlers/get_btc_price_test.go b/app/aggregate/bitcoin/handlers/get_btc_price_test.go index 499c720..86d0eec 100644 --- a/app/aggregate/bitcoin/handlers/get_btc_price_test.go +++ b/app/aggregate/bitcoin/handlers/get_btc_price_test.go @@ -5,8 +5,13 @@ import ( "testing" "time" + "github.com/F0rzend/simple-go-webserver/app/common" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" + bitcoinservice "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/service" "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" @@ -15,21 +20,29 @@ import ( func TestGetBTCPrice(t *testing.T) { t.Parallel() - service := &MockBitcoinService{ - GetBTCPriceFunc: func() bitcoinentity.BTCPrice { - btcPrice, _ := bitcoinentity.NewUSD(1) - return bitcoinentity.NewBTCPrice( - btcPrice, - time.Now(), - ) + const expectedStatus = http.StatusOK + + now := time.Now() + + repository := &bitcoinservice.MockBTCRepository{ + GetPriceFunc: func() bitcoinentity.BTCPrice { + price, err := bitcoinentity.NewBTCPrice(bitcoinentity.NewUSD(1), now) + require.NoError(t, err) + return price }, } - handler := http.HandlerFunc( - NewBitcoinHTTPHandlers(service).GetBTCPrice, - ) - - w, r := tests.PrepareHandlerArgs(t, http.MethodGet, "/bitcoin", nil) - handler.ServeHTTP(w, r) - tests.AssertStatus(t, w, r, http.StatusOK) - assert.Len(t, service.GetBTCPriceCalls(), 1) + service := bitcoinservice.NewBitcoinService(repository) + handler := NewBitcoinHTTPHandlers(service).GetBTCPrice + sut := common.ErrorHandler(handler) + + tests.HTTPExpect(t, sut). + GET("/bitcoin"). + Expect(). + Status(expectedStatus). + ContentType("application/json"). + JSON().Object().Value("btc").Object(). + ValueEqual("price", "1"). + ValueEqual("updated_at", now) + + assert.Len(t, repository.GetPriceCalls(), 1) } diff --git a/app/aggregate/bitcoin/handlers/handlers.go b/app/aggregate/bitcoin/handlers/handlers.go index 8dfab39..9efbb3a 100644 --- a/app/aggregate/bitcoin/handlers/handlers.go +++ b/app/aggregate/bitcoin/handlers/handlers.go @@ -14,7 +14,6 @@ func NewBitcoinHTTPHandlers(bitcoinService BitcoinService) *BitcoinHTTPHandlers } } -//go:generate moq -out "mock_bitcoin_service.gen.go" . BitcoinService:MockBitcoinService type BitcoinService interface { GetBTCPrice() bitcoinentity.BTCPrice SetBTCPrice(newPrice float64) error diff --git a/app/aggregate/bitcoin/handlers/mock_bitcoin_service.gen.go b/app/aggregate/bitcoin/handlers/mock_bitcoin_service.gen.go deleted file mode 100644 index 7babaa0..0000000 --- a/app/aggregate/bitcoin/handlers/mock_bitcoin_service.gen.go +++ /dev/null @@ -1,110 +0,0 @@ -// Code generated by moq; DO NOT EDIT. -// github.com/matryer/moq - -package bitcoinhandlers - -import ( - "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" - "sync" -) - -// Ensure, that MockBitcoinService does implement BitcoinService. -// If this is not the case, regenerate this file with moq. -var _ BitcoinService = &MockBitcoinService{} - -// MockBitcoinService is a mock implementation of BitcoinService. -// -// func TestSomethingThatUsesBitcoinService(t *testing.T) { -// -// // make and configure a mocked BitcoinService -// mockedBitcoinService := &MockBitcoinService{ -// GetBTCPriceFunc: func() bitcoinentity.BTCPrice { -// panic("mock out the GetBTCPrice method") -// }, -// SetBTCPriceFunc: func(newPrice float64) error { -// panic("mock out the SetBTCPrice method") -// }, -// } -// -// // use mockedBitcoinService in code that requires BitcoinService -// // and then make assertions. -// -// } -type MockBitcoinService struct { - // GetBTCPriceFunc mocks the GetBTCPrice method. - GetBTCPriceFunc func() bitcoinentity.BTCPrice - - // SetBTCPriceFunc mocks the SetBTCPrice method. - SetBTCPriceFunc func(newPrice float64) error - - // calls tracks calls to the methods. - calls struct { - // GetBTCPrice holds details about calls to the GetBTCPrice method. - GetBTCPrice []struct { - } - // SetBTCPrice holds details about calls to the SetBTCPrice method. - SetBTCPrice []struct { - // NewPrice is the newPrice argument value. - NewPrice float64 - } - } - lockGetBTCPrice sync.RWMutex - lockSetBTCPrice sync.RWMutex -} - -// GetBTCPrice calls GetBTCPriceFunc. -func (mock *MockBitcoinService) GetBTCPrice() bitcoinentity.BTCPrice { - if mock.GetBTCPriceFunc == nil { - panic("MockBitcoinService.GetBTCPriceFunc: method is nil but BitcoinService.GetBTCPrice was just called") - } - callInfo := struct { - }{} - mock.lockGetBTCPrice.Lock() - mock.calls.GetBTCPrice = append(mock.calls.GetBTCPrice, callInfo) - mock.lockGetBTCPrice.Unlock() - return mock.GetBTCPriceFunc() -} - -// GetBTCPriceCalls gets all the calls that were made to GetBTCPrice. -// Check the length with: -// len(mockedBitcoinService.GetBTCPriceCalls()) -func (mock *MockBitcoinService) GetBTCPriceCalls() []struct { -} { - var calls []struct { - } - mock.lockGetBTCPrice.RLock() - calls = mock.calls.GetBTCPrice - mock.lockGetBTCPrice.RUnlock() - return calls -} - -// SetBTCPrice calls SetBTCPriceFunc. -func (mock *MockBitcoinService) SetBTCPrice(newPrice float64) error { - if mock.SetBTCPriceFunc == nil { - panic("MockBitcoinService.SetBTCPriceFunc: method is nil but BitcoinService.SetBTCPrice was just called") - } - callInfo := struct { - NewPrice float64 - }{ - NewPrice: newPrice, - } - mock.lockSetBTCPrice.Lock() - mock.calls.SetBTCPrice = append(mock.calls.SetBTCPrice, callInfo) - mock.lockSetBTCPrice.Unlock() - return mock.SetBTCPriceFunc(newPrice) -} - -// SetBTCPriceCalls gets all the calls that were made to SetBTCPrice. -// Check the length with: -// len(mockedBitcoinService.SetBTCPriceCalls()) -func (mock *MockBitcoinService) SetBTCPriceCalls() []struct { - NewPrice float64 -} { - var calls []struct { - NewPrice float64 - } - mock.lockSetBTCPrice.RLock() - calls = mock.calls.SetBTCPrice - mock.lockSetBTCPrice.RUnlock() - return calls -} diff --git a/app/aggregate/bitcoin/handlers/set_btc_price.go b/app/aggregate/bitcoin/handlers/set_btc_price.go index 60d5fd7..2897255 100644 --- a/app/aggregate/bitcoin/handlers/set_btc_price.go +++ b/app/aggregate/bitcoin/handlers/set_btc_price.go @@ -1,10 +1,9 @@ package bitcoinhandlers import ( + "fmt" "net/http" - "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" - "github.com/F0rzend/simple-go-webserver/app/common" "github.com/go-chi/render" ) @@ -14,23 +13,30 @@ type SetBTCPriceRequest struct { } func (r SetBTCPriceRequest) Bind(_ *http.Request) error { - _, err := bitcoinentity.NewUSD(r.Price) - return err + if r.Price == 0 { + return common.NewValidationError("price is required") + } + + return nil } -func (h *BitcoinHTTPHandlers) SetBTCPrice(w http.ResponseWriter, r *http.Request) { +func (h *BitcoinHTTPHandlers) SetBTCPrice(w http.ResponseWriter, r *http.Request) error { request := &SetBTCPriceRequest{} if err := render.Bind(r, request); err != nil { - common.RenderHTTPError(w, r, err) - return + return err } - if err := h.service.SetBTCPrice(request.Price); err != nil { - common.RenderHTTPError(w, r, err) - return + err := h.service.SetBTCPrice(request.Price) + if common.IsFlaggedError(err, common.FlagInvalidArgument) { + return common.NewValidationError(err.Error()) + } + if err != nil { + return fmt.Errorf("failed to set btc price: %w", err) } render.Status(r, http.StatusNoContent) render.Respond(w, r, nil) + + return nil } diff --git a/app/aggregate/bitcoin/handlers/set_btc_price_test.go b/app/aggregate/bitcoin/handlers/set_btc_price_test.go index 187ad13..3199777 100644 --- a/app/aggregate/bitcoin/handlers/set_btc_price_test.go +++ b/app/aggregate/bitcoin/handlers/set_btc_price_test.go @@ -4,6 +4,10 @@ import ( "net/http" "testing" + "github.com/F0rzend/simple-go-webserver/app/common" + + bitcoinentity "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" + bitcoinservice "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/service" "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/stretchr/testify/assert" ) @@ -12,22 +16,28 @@ func TestSetBTCPrice(t *testing.T) { t.Parallel() testCases := []struct { - name string - newPrice float64 - serviceCallsAmount int - expectedStatusCode int + name string + price float64 + expectedStatus int + repositorySetPriceCallsAmount int }{ { - name: "success", - newPrice: 1.0, - serviceCallsAmount: 1, - expectedStatusCode: http.StatusNoContent, + name: "success", + price: 100.0, + expectedStatus: http.StatusNoContent, + repositorySetPriceCallsAmount: 1, + }, + { + name: "empty price", + price: 0.0, + expectedStatus: http.StatusBadRequest, + repositorySetPriceCallsAmount: 0, }, { - name: "negative", - newPrice: -1.0, - serviceCallsAmount: 0, - expectedStatusCode: http.StatusBadRequest, + name: "negative price", + price: -100.0, + expectedStatus: http.StatusBadRequest, + repositorySetPriceCallsAmount: 0, }, } @@ -36,25 +46,21 @@ func TestSetBTCPrice(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - service := &MockBitcoinService{ - SetBTCPriceFunc: func(newPrice float64) error { - return nil - }, + repository := &bitcoinservice.MockBTCRepository{ + SetPriceFunc: func(_ bitcoinentity.BTCPrice) error { return nil }, } + service := bitcoinservice.NewBitcoinService(repository) + handler := NewBitcoinHTTPHandlers(service).SetBTCPrice + sut := common.ErrorHandler(handler) - handler := http.HandlerFunc( - NewBitcoinHTTPHandlers(service).SetBTCPrice, - ) - - w, r := tests.PrepareHandlerArgs(t, - http.MethodPost, - "/bitcoin", - SetBTCPriceRequest{Price: tc.newPrice}, - ) - handler.ServeHTTP(w, r) + tests.HTTPExpect(t, sut). + POST("/bitcoin"). + WithJSON(SetBTCPriceRequest{Price: tc.price}). + Expect(). + Status(tc.expectedStatus). + ContentType("application/json") - tests.AssertStatus(t, w, r, tc.expectedStatusCode) - assert.Len(t, service.SetBTCPriceCalls(), tc.serviceCallsAmount) + assert.Len(t, repository.SetPriceCalls(), tc.repositorySetPriceCallsAmount) }) } } diff --git a/app/aggregate/bitcoin/repositories/memory.go b/app/aggregate/bitcoin/repositories/memory.go index f4439ef..ef3ac2f 100644 --- a/app/aggregate/bitcoin/repositories/memory.go +++ b/app/aggregate/bitcoin/repositories/memory.go @@ -10,21 +10,22 @@ type MemoryBTCRepository struct { bitcoin bitcoinentity.BTCPrice } -func NewMemoryBTCRepository(initialPrice bitcoinentity.USD) (*MemoryBTCRepository, error) { - btcPrice := bitcoinentity.NewBTCPrice(initialPrice, time.Now()) +func NewMemoryBTCRepository() *MemoryBTCRepository { + const defaultPrice = 100 + + btcPrice, _ := bitcoinentity.NewBTCPrice(bitcoinentity.NewUSD(defaultPrice), time.Now()) return &MemoryBTCRepository{ bitcoin: btcPrice, - }, nil + } } func (r *MemoryBTCRepository) GetPrice() bitcoinentity.BTCPrice { return r.bitcoin } -func (r *MemoryBTCRepository) SetPrice(price bitcoinentity.USD) error { - btcPrice := bitcoinentity.NewBTCPrice(price, time.Now()) +func (r *MemoryBTCRepository) SetPrice(price bitcoinentity.BTCPrice) error { + r.bitcoin = price - r.bitcoin = btcPrice return nil } diff --git a/app/aggregate/bitcoin/repositories/repositories_test.go b/app/aggregate/bitcoin/repositories/repositories_test.go new file mode 100644 index 0000000..7456b55 --- /dev/null +++ b/app/aggregate/bitcoin/repositories/repositories_test.go @@ -0,0 +1,26 @@ +package bitcoinrepositories + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + bitcoinentity "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" +) + +func TestMemoryBTCRepositories(t *testing.T) { + t.Parallel() + + sut := NewMemoryBTCRepository() + + defaultPrice := sut.GetPrice() + assert.True(t, defaultPrice.GetPrice().Equal(bitcoinentity.NewUSD(100.0))) + + price, _ := bitcoinentity.NewBTCPrice(bitcoinentity.NewUSD(100.0), time.Now()) + err := sut.SetPrice(price) + assert.NoError(t, err) + + actualPrice := sut.GetPrice() + assert.True(t, actualPrice.GetPrice().Equal(bitcoinentity.NewUSD(100.0))) +} diff --git a/app/aggregate/bitcoin/service/get_btc_price_test.go b/app/aggregate/bitcoin/service/get_btc_price_test.go index 650a096..c4337c9 100644 --- a/app/aggregate/bitcoin/service/get_btc_price_test.go +++ b/app/aggregate/bitcoin/service/get_btc_price_test.go @@ -12,9 +12,8 @@ import ( func TestBitcoinService_GetBTCPrice(t *testing.T) { t.Parallel() - priceInUSD, _ := bitcoinentity.NewUSD(1) - bitcoinPrice := bitcoinentity.NewBTCPrice( - priceInUSD, + bitcoinPrice, _ := bitcoinentity.NewBTCPrice( + bitcoinentity.NewUSD(1), time.Now(), ) bitcoinRepository := &MockBTCRepository{ @@ -22,10 +21,9 @@ func TestBitcoinService_GetBTCPrice(t *testing.T) { return bitcoinPrice }, } + sut := NewBitcoinService(bitcoinRepository) - service := NewBitcoinService(bitcoinRepository) - - actual := service.GetBTCPrice() + actual := sut.GetBTCPrice() assert.Equal(t, bitcoinPrice, actual) assert.Len(t, bitcoinRepository.GetPriceCalls(), 1) diff --git a/app/aggregate/bitcoin/service/mock_btc_repository.gen.go b/app/aggregate/bitcoin/service/mock_btc_repository.gen.go index baf088b..7dbce90 100644 --- a/app/aggregate/bitcoin/service/mock_btc_repository.gen.go +++ b/app/aggregate/bitcoin/service/mock_btc_repository.gen.go @@ -21,7 +21,7 @@ var _ BTCRepository = &MockBTCRepository{} // GetPriceFunc: func() bitcoinentity.BTCPrice { // panic("mock out the GetPrice method") // }, -// SetPriceFunc: func(price bitcoinentity.USD) error { +// SetPriceFunc: func(price bitcoinentity.BTCPrice) error { // panic("mock out the SetPrice method") // }, // } @@ -35,7 +35,7 @@ type MockBTCRepository struct { GetPriceFunc func() bitcoinentity.BTCPrice // SetPriceFunc mocks the SetPrice method. - SetPriceFunc func(price bitcoinentity.USD) error + SetPriceFunc func(price bitcoinentity.BTCPrice) error // calls tracks calls to the methods. calls struct { @@ -45,7 +45,7 @@ type MockBTCRepository struct { // SetPrice holds details about calls to the SetPrice method. SetPrice []struct { // Price is the price argument value. - Price bitcoinentity.USD + Price bitcoinentity.BTCPrice } } lockGetPrice sync.RWMutex @@ -79,12 +79,12 @@ func (mock *MockBTCRepository) GetPriceCalls() []struct { } // SetPrice calls SetPriceFunc. -func (mock *MockBTCRepository) SetPrice(price bitcoinentity.USD) error { +func (mock *MockBTCRepository) SetPrice(price bitcoinentity.BTCPrice) error { if mock.SetPriceFunc == nil { panic("MockBTCRepository.SetPriceFunc: method is nil but BTCRepository.SetPrice was just called") } callInfo := struct { - Price bitcoinentity.USD + Price bitcoinentity.BTCPrice }{ Price: price, } @@ -98,10 +98,10 @@ func (mock *MockBTCRepository) SetPrice(price bitcoinentity.USD) error { // Check the length with: // len(mockedBTCRepository.SetPriceCalls()) func (mock *MockBTCRepository) SetPriceCalls() []struct { - Price bitcoinentity.USD + Price bitcoinentity.BTCPrice } { var calls []struct { - Price bitcoinentity.USD + Price bitcoinentity.BTCPrice } mock.lockSetPrice.RLock() calls = mock.calls.SetPrice diff --git a/app/aggregate/bitcoin/service/service.go b/app/aggregate/bitcoin/service/service.go index 11a23af..beac08f 100644 --- a/app/aggregate/bitcoin/service/service.go +++ b/app/aggregate/bitcoin/service/service.go @@ -11,7 +11,7 @@ type BitcoinService struct { //go:generate moq -out "mock_btc_repository.gen.go" . BTCRepository:MockBTCRepository type BTCRepository interface { GetPrice() bitcoinentity.BTCPrice - SetPrice(price bitcoinentity.USD) error + SetPrice(price bitcoinentity.BTCPrice) error } func NewBitcoinService( diff --git a/app/aggregate/bitcoin/service/set_btc_price.go b/app/aggregate/bitcoin/service/set_btc_price.go index bf27c9d..c4fff6c 100644 --- a/app/aggregate/bitcoin/service/set_btc_price.go +++ b/app/aggregate/bitcoin/service/set_btc_price.go @@ -1,14 +1,22 @@ package bitcoinservice import ( + "fmt" + "time" + "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" ) func (bs *BitcoinService) SetBTCPrice(newPrice float64) error { - price, err := bitcoinentity.NewUSD(newPrice) + price, err := bitcoinentity.NewBTCPrice(bitcoinentity.NewUSD(newPrice), time.Now()) + if err != nil { + return fmt.Errorf("cannot create new btc price: %w", err) + } + + err = bs.bitcoinRepository.SetPrice(price) if err != nil { - return err + return fmt.Errorf("cannot set btc price: %w", err) } - return bs.bitcoinRepository.SetPrice(price) + return nil } diff --git a/app/aggregate/bitcoin/service/set_btc_price_test.go b/app/aggregate/bitcoin/service/set_btc_price_test.go index 820802b..b6de6b3 100644 --- a/app/aggregate/bitcoin/service/set_btc_price_test.go +++ b/app/aggregate/bitcoin/service/set_btc_price_test.go @@ -1,11 +1,8 @@ package bitcoinservice import ( - "net/http" "testing" - "github.com/F0rzend/simple-go-webserver/app/common" - "github.com/stretchr/testify/assert" "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" @@ -14,42 +11,12 @@ import ( func TestBitcoinService_SetBTCPrice(t *testing.T) { t.Parallel() - testCases := []struct { - name string - price float64 - err error - }{ - { - name: "success", - price: 1.0, - err: nil, - }, - { - name: "negative price", - price: -1.0, - err: common.NewApplicationError( - http.StatusBadRequest, - "The amount of currency cannot be negative. Please pass a number greater than 0", - ), - }, + bitcoinRepository := &MockBTCRepository{ + SetPriceFunc: func(_ bitcoinentity.BTCPrice) error { return nil }, } - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - bitcoinRepository := &MockBTCRepository{ - SetPriceFunc: func(price bitcoinentity.USD) error { - return nil - }, - } - - service := NewBitcoinService(bitcoinRepository) + sut := NewBitcoinService(bitcoinRepository) - err := service.SetBTCPrice(tc.price) - - assert.Equal(t, tc.err, err) - }) - } + err := sut.SetBTCPrice(1.0) + assert.NoError(t, err) } diff --git a/app/aggregate/user/entity/balance_test.go b/app/aggregate/user/entity/balance_test.go index b587beb..687e5c9 100644 --- a/app/aggregate/user/entity/balance_test.go +++ b/app/aggregate/user/entity/balance_test.go @@ -4,6 +4,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" @@ -18,36 +20,36 @@ func TestBalance_Total(t *testing.T) { name string usd bitcoinentity.USD btc bitcoinentity.BTC - btcPrice bitcoinentity.BTCPrice + btcPrice bitcoinentity.USD expected bitcoinentity.USD }{ { name: "empty", - usd: bitcoinentity.MustNewUSD(0), - btc: bitcoinentity.MustNewBTC(0), - btcPrice: bitcoinentity.NewBTCPrice(bitcoinentity.MustNewUSD(0), now), - expected: bitcoinentity.MustNewUSD(0), + usd: bitcoinentity.NewUSD(0), + btc: bitcoinentity.NewBTC(0), + btcPrice: bitcoinentity.NewUSD(0), + expected: bitcoinentity.NewUSD(0), }, { name: "usd only", - usd: bitcoinentity.MustNewUSD(1), - btc: bitcoinentity.MustNewBTC(0), - btcPrice: bitcoinentity.NewBTCPrice(bitcoinentity.MustNewUSD(1), now), - expected: bitcoinentity.MustNewUSD(1), + usd: bitcoinentity.NewUSD(1), + btc: bitcoinentity.NewBTC(0), + btcPrice: bitcoinentity.NewUSD(1), + expected: bitcoinentity.NewUSD(1), }, { name: "btc only", - usd: bitcoinentity.MustNewUSD(0), - btc: bitcoinentity.MustNewBTC(1), - btcPrice: bitcoinentity.NewBTCPrice(bitcoinentity.MustNewUSD(1), now), - expected: bitcoinentity.MustNewUSD(1), + usd: bitcoinentity.NewUSD(0), + btc: bitcoinentity.NewBTC(1), + btcPrice: bitcoinentity.NewUSD(1), + expected: bitcoinentity.NewUSD(1), }, { name: "usd and btc", - usd: bitcoinentity.MustNewUSD(1), - btc: bitcoinentity.MustNewBTC(1), - btcPrice: bitcoinentity.NewBTCPrice(bitcoinentity.MustNewUSD(1), now), - expected: bitcoinentity.MustNewUSD(2), + usd: bitcoinentity.NewUSD(1), + btc: bitcoinentity.NewBTC(1), + btcPrice: bitcoinentity.NewUSD(1), + expected: bitcoinentity.NewUSD(2), }, } @@ -56,9 +58,11 @@ func TestBalance_Total(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + price, err := bitcoinentity.NewBTCPrice(tc.btcPrice, now) + require.NoError(t, err) balance := NewBalance(tc.usd, tc.btc) - actual := balance.Total(tc.btcPrice) + actual := balance.Total(price) assert.Equal(t, tc.expected, actual) }) diff --git a/app/aggregate/user/entity/errors.go b/app/aggregate/user/entity/errors.go deleted file mode 100644 index 2075831..0000000 --- a/app/aggregate/user/entity/errors.go +++ /dev/null @@ -1,29 +0,0 @@ -package userentity - -import ( - "fmt" - "net/http" - - "github.com/F0rzend/simple-go-webserver/app/common" -) - -var ( - ErrInsufficientFunds = common.NewApplicationError( - http.StatusBadRequest, - "The user does not have enough funds", - ) - ErrInvalidUSDAction = common.NewApplicationError( - http.StatusBadRequest, - fmt.Sprintf( - "You must specify a valid action. Available actions: %s and %s", - DepositUSDAction, WithdrawUSDAction, - ), - ) - ErrInvalidBTCAction = common.NewApplicationError( - http.StatusBadRequest, - fmt.Sprintf( - "You must specify a valid action. Available actions: %s and %s", - BuyBTCAction, SellBTCAction, - ), - ) -) diff --git a/app/aggregate/user/entity/user.go b/app/aggregate/user/entity/user.go index aa674cf..ea1281b 100644 --- a/app/aggregate/user/entity/user.go +++ b/app/aggregate/user/entity/user.go @@ -1,7 +1,7 @@ package userentity import ( - "net/http" + "fmt" "net/mail" "time" @@ -19,21 +19,6 @@ type User struct { UpdatedAt time.Time } -var ( - ErrNameEmpty = common.NewApplicationError( - http.StatusBadRequest, - "Name cannot be empty", - ) - ErrUsernameEmpty = common.NewApplicationError( - http.StatusBadRequest, - "Username cannot be empty", - ) - ErrInvalidEmail = common.NewApplicationError( - http.StatusBadRequest, - "You must provide a valid email", - ) -) - func NewUser( id uint64, name string, @@ -45,26 +30,16 @@ func NewUser( updatedAt time.Time, ) (*User, error) { if name == "" { - return nil, ErrNameEmpty + return nil, common.NewFlaggedError("name cannot be empty", common.FlagInvalidArgument) } if username == "" { - return nil, ErrUsernameEmpty + return nil, common.NewFlaggedError("username cannot be empty", common.FlagInvalidArgument) } addr, err := ParseEmail(email) if err != nil { - return nil, err - } - - usdAmount, err := bitcoinentity.NewUSD(usdBalance) - if err != nil { - return nil, err - } - - btcAmount, err := bitcoinentity.NewBTC(btcBalance) - if err != nil { - return nil, err + return nil, fmt.Errorf("error parsing email: %w", err) } return &User{ @@ -72,7 +47,7 @@ func NewUser( Name: name, Username: username, Email: addr, - Balance: NewBalance(usdAmount, btcAmount), + Balance: NewBalance(bitcoinentity.NewUSD(usdBalance), bitcoinentity.NewBTC(btcBalance)), CreatedAt: createdAt, UpdatedAt: updatedAt, }, nil @@ -81,19 +56,23 @@ func NewUser( func ParseEmail(email string) (*mail.Address, error) { addr, err := mail.ParseAddress(email) if err != nil { - return nil, ErrInvalidEmail + return nil, common.NewFlaggedError("you must provide a valid email", common.FlagInvalidArgument) } return addr, nil } func (u *User) ChangeUSDBalance(action Action, amount bitcoinentity.USD) error { - switch action { + if amount.IsNegative() { + return common.NewFlaggedError("amount cannot be negative", common.FlagInvalidArgument) + } + + switch action { //nolint:exhaustive case DepositUSDAction: return u.deposit(amount) case WithdrawUSDAction: return u.withdraw(amount) default: - return ErrInvalidUSDAction + return common.NewFlaggedError("invalid action", common.FlagInvalidArgument) } } @@ -105,40 +84,31 @@ func (u *User) deposit(amount bitcoinentity.USD) error { func (u *User) withdraw(amount bitcoinentity.USD) error { if u.Balance.USD.LessThan(amount) { - return ErrInsufficientFunds + return common.NewFlaggedError("the user does not have enough usd to withdraw", common.FlagInvalidArgument) } - updatedUSD, err := u.Balance.USD.Sub(amount) - if err != nil { - return err - } - u.Balance.USD = updatedUSD + u.Balance.USD = u.Balance.USD.Sub(amount) return nil } func (u *User) ChangeBTCBalance(action Action, amount bitcoinentity.BTC, price bitcoinentity.BTCPrice) error { - switch action { + switch action { //nolint:exhaustive case BuyBTCAction: return u.buyBTC(amount, price) case SellBTCAction: return u.sellBTC(amount, price) default: - return ErrInvalidBTCAction + return common.NewFlaggedError("invalid action", common.FlagInvalidArgument) } } func (u *User) buyBTC(amount bitcoinentity.BTC, price bitcoinentity.BTCPrice) error { if u.Balance.USD.LessThan(price.GetPrice()) { - return ErrInsufficientFunds + return common.NewFlaggedError("the user does not have enough usd to buy btc", common.FlagInvalidArgument) } - updatedUSD, err := u.Balance.USD.Sub(amount.ToUSD(price)) - if err != nil { - return err - } - - u.Balance.USD = updatedUSD + u.Balance.USD = u.Balance.USD.Sub(amount.ToUSD(price)) u.Balance.BTC = u.Balance.BTC.Add(amount) return nil @@ -146,15 +116,10 @@ func (u *User) buyBTC(amount bitcoinentity.BTC, price bitcoinentity.BTCPrice) er func (u *User) sellBTC(amount bitcoinentity.BTC, price bitcoinentity.BTCPrice) error { if u.Balance.BTC.LessThan(amount) { - return ErrInsufficientFunds - } - - updatedBTC, err := u.Balance.BTC.Sub(amount) - if err != nil { - return err + return common.NewFlaggedError("the user does not have enough btc to sell", common.FlagInvalidArgument) } - u.Balance.BTC = updatedBTC + u.Balance.BTC = u.Balance.BTC.Sub(amount) u.Balance.USD = u.Balance.USD.Add(amount.ToUSD(price)) return nil diff --git a/app/aggregate/user/entity/user_test.go b/app/aggregate/user/entity/user_test.go index 2e1e302..ef8c8ba 100644 --- a/app/aggregate/user/entity/user_test.go +++ b/app/aggregate/user/entity/user_test.go @@ -5,6 +5,12 @@ import ( "testing" "time" + "github.com/F0rzend/simple-go-webserver/app/common" + + "github.com/F0rzend/simple-go-webserver/app/tests" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" @@ -28,7 +34,7 @@ func TestNewUser(t *testing.T) { updatedAt time.Time expected *User - err error + checkErr tests.ErrorChecker }{ { testName: "success", @@ -47,11 +53,11 @@ func TestNewUser(t *testing.T) { Name: "John Doe", Username: "johndoe", Email: &mail.Address{Name: "", Address: "johndoe@gmail.com"}, - Balance: Balance{USD: bitcoinentity.MustNewUSD(0), BTC: bitcoinentity.MustNewBTC(0)}, + Balance: Balance{USD: bitcoinentity.NewUSD(0), BTC: bitcoinentity.NewBTC(0)}, CreatedAt: now, UpdatedAt: now, }, - err: nil, + checkErr: assert.NoError, }, { testName: "wrong email", @@ -66,7 +72,7 @@ func TestNewUser(t *testing.T) { updatedAt: now, expected: nil, - err: ErrInvalidEmail, + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { testName: "empty name", @@ -81,7 +87,7 @@ func TestNewUser(t *testing.T) { updatedAt: now, expected: nil, - err: ErrNameEmpty, + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { testName: "empty username", @@ -96,7 +102,7 @@ func TestNewUser(t *testing.T) { updatedAt: now, expected: nil, - err: ErrUsernameEmpty, + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { testName: "small btc amount", @@ -110,8 +116,16 @@ func TestNewUser(t *testing.T) { createdAt: now, updatedAt: now, - expected: nil, - err: bitcoinentity.ErrNegativeCurrency, + expected: &User{ + ID: 1, + Name: "John Doe", + Username: "johndoe", + Email: &mail.Address{Name: "", Address: "johndoe@gmail.com"}, + Balance: Balance{USD: bitcoinentity.NewUSD(0), BTC: bitcoinentity.NewBTC(-1)}, + CreatedAt: now, + UpdatedAt: now, + }, + checkErr: assert.NoError, }, { testName: "small usd amount", @@ -125,8 +139,16 @@ func TestNewUser(t *testing.T) { createdAt: now, updatedAt: now, - expected: nil, - err: bitcoinentity.ErrNegativeCurrency, + expected: &User{ + ID: 1, + Name: "John Doe", + Username: "johndoe", + Email: &mail.Address{Name: "", Address: "johndoe@gmail.com"}, + Balance: Balance{USD: bitcoinentity.NewUSD(-1), BTC: bitcoinentity.NewBTC(0)}, + CreatedAt: now, + UpdatedAt: now, + }, + checkErr: assert.NoError, }, } @@ -146,7 +168,7 @@ func TestNewUser(t *testing.T) { tc.updatedAt, ) - assert.Equal(t, err, tc.err) + tc.checkErr(t, err) assert.Equal(t, tc.expected, user) }) } @@ -161,55 +183,55 @@ func TestUser_ChangeUSDBalance(t *testing.T) { action Action amount bitcoinentity.USD expected Balance - err error + checkErr tests.ErrorChecker }{ { name: "success deposit", user: User{ Balance: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(0), }, }, action: DepositUSDAction, - amount: bitcoinentity.MustNewUSD(1), + amount: bitcoinentity.NewUSD(1), expected: Balance{ - USD: bitcoinentity.MustNewUSD(1), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(1), + BTC: bitcoinentity.NewBTC(0), }, - err: nil, + checkErr: assert.NoError, }, { name: "success withdraw", user: User{ Balance: Balance{ - USD: bitcoinentity.MustNewUSD(1), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(1), + BTC: bitcoinentity.NewBTC(0), }, }, action: WithdrawUSDAction, - amount: bitcoinentity.MustNewUSD(1), + amount: bitcoinentity.NewUSD(1), expected: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(0), }, - err: nil, + checkErr: assert.NoError, }, { name: "insufficient funds", user: User{ Balance: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(0), }, }, action: WithdrawUSDAction, - amount: bitcoinentity.MustNewUSD(1), + amount: bitcoinentity.NewUSD(1), expected: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(0), }, - err: ErrInsufficientFunds, + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, } @@ -221,7 +243,7 @@ func TestUser_ChangeUSDBalance(t *testing.T) { user := &tc.user err := user.ChangeUSDBalance(tc.action, tc.amount) - assert.ErrorIs(t, err, tc.err) + tc.checkErr(t, err) assert.True(t, tc.expected.BTC.Equal(user.Balance.BTC)) assert.True(t, tc.expected.USD.Equal(user.Balance.USD)) }) @@ -238,77 +260,77 @@ func TestUser_ChangeBTCBalance(t *testing.T) { user User action Action amount bitcoinentity.BTC - btcPrice bitcoinentity.BTCPrice + btcPrice bitcoinentity.USD expected Balance - err error + checkErr tests.ErrorChecker }{ { name: "success buy", user: User{ Balance: Balance{ - USD: bitcoinentity.MustNewUSD(1), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(1), + BTC: bitcoinentity.NewBTC(0), }, }, action: BuyBTCAction, - amount: bitcoinentity.MustNewBTC(1), - btcPrice: bitcoinentity.NewBTCPrice(bitcoinentity.MustNewUSD(1), now), + amount: bitcoinentity.NewBTC(1), + btcPrice: bitcoinentity.NewUSD(1), expected: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(1), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(1), }, - err: nil, + checkErr: assert.NoError, }, { name: "success sale", user: User{ Balance: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(1), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(1), }, }, action: SellBTCAction, - amount: bitcoinentity.MustNewBTC(1), - btcPrice: bitcoinentity.NewBTCPrice(bitcoinentity.MustNewUSD(1), now), + amount: bitcoinentity.NewBTC(1), + btcPrice: bitcoinentity.NewUSD(1), expected: Balance{ - USD: bitcoinentity.MustNewUSD(1), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(1), + BTC: bitcoinentity.NewBTC(0), }, - err: nil, + checkErr: assert.NoError, }, { name: "insufficient funds on buy", user: User{ Balance: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(0), }, }, action: BuyBTCAction, - amount: bitcoinentity.MustNewBTC(1), - btcPrice: bitcoinentity.NewBTCPrice(bitcoinentity.MustNewUSD(1), now), + amount: bitcoinentity.NewBTC(1), + btcPrice: bitcoinentity.NewUSD(1), expected: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(0), }, - err: ErrInsufficientFunds, + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "insufficient funds on sell", user: User{ Balance: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(0), }, }, action: SellBTCAction, - amount: bitcoinentity.MustNewBTC(1), - btcPrice: bitcoinentity.NewBTCPrice(bitcoinentity.MustNewUSD(1), now), + amount: bitcoinentity.NewBTC(1), + btcPrice: bitcoinentity.NewUSD(1), expected: Balance{ - USD: bitcoinentity.MustNewUSD(0), - BTC: bitcoinentity.MustNewBTC(0), + USD: bitcoinentity.NewUSD(0), + BTC: bitcoinentity.NewBTC(0), }, - err: ErrInsufficientFunds, + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, } @@ -318,9 +340,12 @@ func TestUser_ChangeBTCBalance(t *testing.T) { t.Parallel() user := &tc.user - err := user.ChangeBTCBalance(tc.action, tc.amount, tc.btcPrice) + price, err := bitcoinentity.NewBTCPrice(tc.btcPrice, now) + require.NoError(t, err) - assert.ErrorIs(t, err, tc.err) + err = user.ChangeBTCBalance(tc.action, tc.amount, price) + + tc.checkErr(t, err) assert.True(t, tc.expected.BTC.Equal(user.Balance.BTC)) assert.True(t, tc.expected.USD.Equal(user.Balance.USD)) }) @@ -331,19 +356,19 @@ func TestUser_ParseEmail(t *testing.T) { t.Parallel() testCases := []struct { - name string - email string - err error + name string + email string + checkErr tests.ErrorChecker }{ { - name: "success", - email: "test@mail.com", - err: nil, + name: "success", + email: "test@mail.com", + checkErr: assert.NoError, }, { - name: "invalid mail", - email: "test", - err: ErrInvalidEmail, + name: "invalid mail", + email: "test", + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, } @@ -354,7 +379,7 @@ func TestUser_ParseEmail(t *testing.T) { _, err := ParseEmail(tc.email) - assert.ErrorIs(t, err, tc.err) + tc.checkErr(t, err) }) } } diff --git a/app/aggregate/user/handlers/change_btc_balance.go b/app/aggregate/user/handlers/change_btc_balance.go index d25c329..24d8ffe 100644 --- a/app/aggregate/user/handlers/change_btc_balance.go +++ b/app/aggregate/user/handlers/change_btc_balance.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/go-chi/render" - "github.com/rs/zerolog/log" "github.com/F0rzend/simple-go-webserver/app/common" ) @@ -17,36 +16,35 @@ type ChangeBTCBalanceRequest struct { func (r ChangeBTCBalanceRequest) Bind(_ *http.Request) error { if r.Action == "" { - return ErrEmptyAction + return common.NewValidationError("action cannot be empty") } if r.Amount == 0 { - return ErrZeroAmount + return common.NewValidationError("amount cannot be empty") } return nil } -func (h *UserHTTPHandlers) ChangeBTCBalance(w http.ResponseWriter, r *http.Request) { +func (h *UserHTTPHandlers) ChangeBTCBalance(w http.ResponseWriter, r *http.Request) error { request := &ChangeBTCBalanceRequest{} id, err := h.getUserIDFromRequest(r) if err != nil { - log.Error().Err(err).Send() - w.WriteHeader(http.StatusInternalServerError) - return + return fmt.Errorf("failed to get user id from request: %w", err) } if err := render.Bind(r, request); err != nil { - common.RenderHTTPError(w, r, err) - return + return fmt.Errorf("failed to bind request: %w", err) } - if err = h.service.ChangeBitcoinBalance(id, request.Action, request.Amount); err != nil { - common.RenderHTTPError(w, r, err) - return + err = h.service.ChangeBitcoinBalance(id, request.Action, request.Amount) + if err != nil { + return common.NewValidationError(err.Error()) } render.Status(r, http.StatusNoContent) w.Header().Set("Location", fmt.Sprintf("/users/%d", id)) render.Respond(w, r, nil) + + return nil } diff --git a/app/aggregate/user/handlers/change_btc_balance_test.go b/app/aggregate/user/handlers/change_btc_balance_test.go index fec4d43..62acc09 100644 --- a/app/aggregate/user/handlers/change_btc_balance_test.go +++ b/app/aggregate/user/handlers/change_btc_balance_test.go @@ -3,35 +3,143 @@ package userhandlers import ( "net/http" "testing" + "time" + + "github.com/F0rzend/simple-go-webserver/app/common" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" + bitcoinservice "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/service" + userentity "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" + userservice "github.com/F0rzend/simple-go-webserver/app/aggregate/user/service" "github.com/F0rzend/simple-go-webserver/app/tests" ) func TestUserHTTPHandlers_ChangeBTCBalance(t *testing.T) { t.Parallel() - request := ChangeBTCBalanceRequest{ - Action: "buy", - Amount: 1, + type calls struct { + getUser int + saveUser int + getPrice int + } + + type response struct { + status int + location string } - expectedStatus := http.StatusNoContent - service := &MockUserService{ - ChangeBitcoinBalanceFunc: func(_ uint64, _ string, _ float64) error { - return nil + testCases := []struct { + name string + request ChangeBTCBalanceRequest + response response + calls calls + }{ + { + name: "success buying", + request: ChangeBTCBalanceRequest{ + Action: "buy", + Amount: 10.0, + }, + response: response{ + status: http.StatusNoContent, + location: "/users/1", + }, + calls: calls{ + getUser: 1, + saveUser: 1, + getPrice: 1, + }, + }, + { + name: "success selling", + request: ChangeBTCBalanceRequest{ + Action: "sell", + Amount: 10.0, + }, + response: response{ + status: http.StatusNoContent, + location: "/users/1", + }, + calls: calls{ + getUser: 1, + saveUser: 1, + getPrice: 1, + }, }, + { + name: "empty amount", + request: ChangeBTCBalanceRequest{ + Action: "buy", + }, + response: response{ + status: http.StatusBadRequest, + }, + }, + { + name: "empty action", + request: ChangeBTCBalanceRequest{ + Amount: 10.0, + }, + response: response{ + status: http.StatusBadRequest, + }, + }, + } + + getUserFunc := func(id uint64) (*userentity.User, error) { + return userentity.NewUser( + id, + "John", + "john", + "john@mail.com", + 10, + 10, + time.Now(), + time.Now(), + ) } + getPriceFunc := func() bitcoinentity.BTCPrice { + price, err := bitcoinentity.NewBTCPrice(bitcoinentity.NewUSD(1), time.Now()) + require.NoError(t, err) - handler := http.HandlerFunc(NewUserHTTPHandlers(service, func(_ *http.Request) (uint64, error) { + return price + } + saveUserFunc := func(_ *userentity.User) error { + return nil + } + idProvider := func(r *http.Request) (uint64, error) { return 1, nil - }).ChangeBTCBalance) + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - w, r := tests.PrepareHandlerArgs(t, http.MethodPost, "/users/1/bitcoin", request) - handler.ServeHTTP(w, r) + userRepository := &userservice.MockUserRepository{ + GetFunc: getUserFunc, + SaveFunc: saveUserFunc, + } + bitcoinRepository := &bitcoinservice.MockBTCRepository{GetPriceFunc: getPriceFunc} - tests.AssertStatus(t, w, r, expectedStatus) - assert.Equal(t, "/users/1", w.Header().Get("Location")) - assert.Len(t, service.ChangeBitcoinBalanceCalls(), 1) + service := userservice.NewUserService(userRepository, bitcoinRepository) + handler := NewUserHTTPHandlers(service, idProvider).ChangeBTCBalance + sut := common.ErrorHandler(handler) + + tests.HTTPExpect(t, sut). + POST("/"). + WithJSON(tc.request). + Expect(). + Status(tc.response.status). + ContentType("application/json"). + Header("Location").Equal(tc.response.location) + + assert.Len(t, userRepository.GetCalls(), tc.calls.getUser) + assert.Len(t, bitcoinRepository.GetPriceCalls(), tc.calls.getPrice) + assert.Len(t, userRepository.SaveCalls(), tc.calls.saveUser) + }) + } } diff --git a/app/aggregate/user/handlers/change_usd_balance.go b/app/aggregate/user/handlers/change_usd_balance.go index 94d275c..5719494 100644 --- a/app/aggregate/user/handlers/change_usd_balance.go +++ b/app/aggregate/user/handlers/change_usd_balance.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/go-chi/render" - "github.com/rs/zerolog/log" "github.com/F0rzend/simple-go-webserver/app/common" ) @@ -17,36 +16,38 @@ type ChangeUSDBalanceRequest struct { func (r ChangeUSDBalanceRequest) Bind(_ *http.Request) error { if r.Action == "" { - return ErrEmptyAction + return common.NewValidationError("action is required") } if r.Amount == 0 { - return ErrZeroAmount + return common.NewValidationError("amount is required") } return nil } -func (h *UserHTTPHandlers) ChangeUSDBalance(w http.ResponseWriter, r *http.Request) { +func (h *UserHTTPHandlers) ChangeUSDBalance(w http.ResponseWriter, r *http.Request) error { request := &ChangeUSDBalanceRequest{} id, err := h.getUserIDFromRequest(r) if err != nil { - log.Error().Err(err).Send() - w.WriteHeader(http.StatusInternalServerError) - return + return fmt.Errorf("failed to get user id from request: %w", err) } if err := render.Bind(r, request); err != nil { - common.RenderHTTPError(w, r, err) - return + return fmt.Errorf("failed to bind request: %w", err) } - if err := h.service.ChangeUserBalance(id, request.Action, request.Amount); err != nil { - common.RenderHTTPError(w, r, err) - return + err = h.service.ChangeUserBalance(id, request.Action, request.Amount) + if common.IsFlaggedError(err, common.FlagInvalidArgument) { + return common.NewValidationError(err.Error()) + } + if err != nil { + return fmt.Errorf("failed to change user balance: %w", err) } render.Status(r, http.StatusNoContent) w.Header().Set("Location", fmt.Sprintf("/users/%d", id)) render.Respond(w, r, nil) + + return nil } diff --git a/app/aggregate/user/handlers/change_usd_balance_test.go b/app/aggregate/user/handlers/change_usd_balance_test.go index 2db5db5..4b8d168 100644 --- a/app/aggregate/user/handlers/change_usd_balance_test.go +++ b/app/aggregate/user/handlers/change_usd_balance_test.go @@ -3,34 +3,131 @@ package userhandlers import ( "net/http" "testing" + "time" + + "github.com/F0rzend/simple-go-webserver/app/common" - "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/stretchr/testify/assert" + + bitcoinservice "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/service" + userentity "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" + userservice "github.com/F0rzend/simple-go-webserver/app/aggregate/user/service" + + "github.com/F0rzend/simple-go-webserver/app/tests" ) func TestUserHTTPHandlers_ChangeUSDBalance(t *testing.T) { t.Parallel() - request := ChangeUSDBalanceRequest{ - Action: "withdraw", - Amount: 1, + type calls struct { + getUser int + saveUser int + } + + type response struct { + status int + location string } - expectedStatus := http.StatusNoContent - service := &MockUserService{ - ChangeUserBalanceFunc: func(_ uint64, _ string, _ float64) error { - return nil + testCases := []struct { + name string + request ChangeUSDBalanceRequest + calls calls + response response + }{ + { + name: "success deposit", + request: ChangeUSDBalanceRequest{ + Action: "deposit", + Amount: 10.0, + }, + calls: calls{ + getUser: 1, + saveUser: 1, + }, + response: response{ + status: http.StatusNoContent, + location: "/users/1", + }, + }, + { + name: "success withdraw", + request: ChangeUSDBalanceRequest{ + Action: "withdraw", + Amount: 1.0, + }, + calls: calls{ + getUser: 1, + saveUser: 1, + }, + response: response{ + status: http.StatusNoContent, + location: "/users/1", + }, + }, + { + name: "empty action", + request: ChangeUSDBalanceRequest{ + Amount: 10.0, + }, + response: response{ + status: http.StatusBadRequest, + }, + }, + { + name: "empty amount", + request: ChangeUSDBalanceRequest{ + Action: "deposit", + }, + response: response{ + status: http.StatusBadRequest, + }, }, } - handler := http.HandlerFunc(NewUserHTTPHandlers(service, func(_ *http.Request) (uint64, error) { + getUserFunc := func(id uint64) (*userentity.User, error) { + return userentity.NewUser( + id, + "John", + "john", + "john@mail.com", + 0, + 1, + time.Now(), + time.Now(), + ) + } + saveUserFunc := func(_ *userentity.User) error { + return nil + } + idProvider := func(_ *http.Request) (uint64, error) { return 1, nil - }).ChangeUSDBalance) + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - w, r := tests.PrepareHandlerArgs(t, http.MethodPost, "/users/1/usd", request) - handler.ServeHTTP(w, r) + userRepository := &userservice.MockUserRepository{ + GetFunc: getUserFunc, + SaveFunc: saveUserFunc, + } + bitcoinRepository := &bitcoinservice.MockBTCRepository{} + service := userservice.NewUserService(userRepository, bitcoinRepository) + handler := NewUserHTTPHandlers(service, idProvider).ChangeUSDBalance + sut := common.ErrorHandler(handler) - tests.AssertStatus(t, w, r, expectedStatus) - assert.Equal(t, "/users/1", w.Header().Get("Location")) - assert.Len(t, service.ChangeUserBalanceCalls(), 1) + tests.HTTPExpect(t, sut). + POST("/"). + WithJSON(tc.request). + Expect(). + Status(tc.response.status). + ContentType("application/json"). + Header("Location").Equal(tc.response.location) + + assert.Len(t, userRepository.GetCalls(), tc.calls.getUser) + assert.Len(t, userRepository.SaveCalls(), tc.calls.saveUser) + }) + } } diff --git a/app/aggregate/user/handlers/create_user.go b/app/aggregate/user/handlers/create_user.go index d33e83f..15d3406 100644 --- a/app/aggregate/user/handlers/create_user.go +++ b/app/aggregate/user/handlers/create_user.go @@ -15,47 +15,44 @@ type CreateUserRequest struct { Email string `json:"email"` } -var ( - ErrInvalidName = common.NewApplicationError( - http.StatusBadRequest, - "Name cannot be empty", - ) - ErrInvalidUsername = common.NewApplicationError( - http.StatusBadRequest, - "Username cannot be empty", - ) -) - func (r CreateUserRequest) Bind(_ *http.Request) error { if r.Name == "" { - return ErrInvalidName + return common.NewValidationError("Name cannot be empty") } if r.Username == "" { - return ErrInvalidUsername + return common.NewValidationError("Username cannot be empty") } - if _, err := userentity.ParseEmail(r.Email); err != nil { - return err + _, err := userentity.ParseEmail(r.Email) + if common.IsFlaggedError(err, common.FlagInvalidArgument) { + return common.NewValidationError(err.Error()) + } + if err != nil { + return fmt.Errorf("error parsing email: %w", err) } + return nil } -func (h *UserHTTPHandlers) CreateUser(w http.ResponseWriter, r *http.Request) { +func (h *UserHTTPHandlers) CreateUser(w http.ResponseWriter, r *http.Request) error { request := &CreateUserRequest{} if err := render.Bind(r, request); err != nil { - common.RenderHTTPError(w, r, err) - return + return fmt.Errorf("error binding request: %w", err) } id, err := h.service.CreateUser(request.Name, request.Username, request.Email) + if common.IsFlaggedError(err, common.FlagInvalidArgument) { + return common.NewValidationError(err.Error()) + } if err != nil { - common.RenderHTTPError(w, r, err) - return + return fmt.Errorf("error creating user: %w", err) } render.Status(r, http.StatusCreated) w.Header().Set("Location", fmt.Sprintf("/users/%d", id)) render.Respond(w, r, nil) + + return nil } diff --git a/app/aggregate/user/handlers/create_user_test.go b/app/aggregate/user/handlers/create_user_test.go index aaba36d..376bd6c 100644 --- a/app/aggregate/user/handlers/create_user_test.go +++ b/app/aggregate/user/handlers/create_user_test.go @@ -4,26 +4,29 @@ import ( "net/http" "testing" - "github.com/F0rzend/simple-go-webserver/app/tests" + "github.com/F0rzend/simple-go-webserver/app/common" + "github.com/stretchr/testify/assert" + + bitcoinservice "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/service" + userentity "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" + userservice "github.com/F0rzend/simple-go-webserver/app/aggregate/user/service" + "github.com/F0rzend/simple-go-webserver/app/tests" ) func TestUserHTTPHandlers_CreateUser(t *testing.T) { t.Parallel() - getUserIDFromURL := func(_ *http.Request) (uint64, error) { - return 1, nil - } - createUserFunc := func(_, _, _ string) (uint64, error) { - return 1, nil + type response struct { + status int + location string } testCases := []struct { - name string - request CreateUserRequest - shouldContainLocationHeader bool - createUserCallsAmount int - expectedStatus int + name string + request CreateUserRequest + saveUserCallsAmount int + response response }{ { name: "success", @@ -32,9 +35,11 @@ func TestUserHTTPHandlers_CreateUser(t *testing.T) { Username: "test", Email: "test@mail.com", }, - shouldContainLocationHeader: true, - createUserCallsAmount: 1, - expectedStatus: http.StatusCreated, + saveUserCallsAmount: 1, + response: response{ + status: http.StatusCreated, + location: "/users/1", + }, }, { name: "empty name", @@ -43,9 +48,10 @@ func TestUserHTTPHandlers_CreateUser(t *testing.T) { Username: "test", Email: "test@mail.com", }, - shouldContainLocationHeader: false, - createUserCallsAmount: 0, - expectedStatus: http.StatusBadRequest, + saveUserCallsAmount: 0, + response: response{ + status: http.StatusBadRequest, + }, }, { name: "empty username", @@ -54,9 +60,10 @@ func TestUserHTTPHandlers_CreateUser(t *testing.T) { Username: "", Email: "test@mail.com", }, - shouldContainLocationHeader: false, - createUserCallsAmount: 0, - expectedStatus: http.StatusBadRequest, + saveUserCallsAmount: 0, + response: response{ + status: http.StatusBadRequest, + }, }, { name: "invalid email", @@ -65,29 +72,42 @@ func TestUserHTTPHandlers_CreateUser(t *testing.T) { Username: "test", Email: "test", }, - shouldContainLocationHeader: false, - createUserCallsAmount: 0, - expectedStatus: http.StatusBadRequest, + saveUserCallsAmount: 0, + response: response{ + status: http.StatusBadRequest, + }, }, } + saveUserFunc := func(_ *userentity.User) error { + return nil + } + getUserIDFromURL := func(_ *http.Request) (uint64, error) { + return 1, nil + } + for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - service := &MockUserService{CreateUserFunc: createUserFunc} - - handler := http.HandlerFunc(NewUserHTTPHandlers(service, getUserIDFromURL).CreateUser) + userRepository := &userservice.MockUserRepository{ + SaveFunc: saveUserFunc, + } + bitcoinRepository := &bitcoinservice.MockBTCRepository{} + service := userservice.NewUserService(userRepository, bitcoinRepository) + handler := NewUserHTTPHandlers(service, getUserIDFromURL).CreateUser + sut := common.ErrorHandler(handler) - w, r := tests.PrepareHandlerArgs(t, http.MethodPost, "/users/", tc.request) - handler.ServeHTTP(w, r) + tests.HTTPExpect(t, sut). + POST("/"). + WithJSON(tc.request). + Expect(). + Status(tc.response.status). + ContentType("application/json"). + Header("Location").Equal(tc.response.location) - tests.AssertStatus(t, w, r, tc.expectedStatus) - if tc.shouldContainLocationHeader { - assert.Equal(t, "/users/1", w.Header().Get("Location")) - } - assert.Len(t, service.CreateUserCalls(), tc.createUserCallsAmount) + assert.Len(t, userRepository.SaveCalls(), tc.saveUserCallsAmount) }) } } diff --git a/app/aggregate/user/handlers/errors.go b/app/aggregate/user/handlers/errors.go deleted file mode 100644 index fb3b716..0000000 --- a/app/aggregate/user/handlers/errors.go +++ /dev/null @@ -1,18 +0,0 @@ -package userhandlers - -import ( - "net/http" - - "github.com/F0rzend/simple-go-webserver/app/common" -) - -var ( - ErrEmptyAction = common.NewApplicationError( - http.StatusBadRequest, - "Action cannot be empty", - ) - ErrZeroAmount = common.NewApplicationError( - http.StatusBadRequest, - "Amount can't be zero", - ) -) diff --git a/app/aggregate/user/handlers/get_balance.go b/app/aggregate/user/handlers/get_balance.go index 5446e8f..a97ecdc 100644 --- a/app/aggregate/user/handlers/get_balance.go +++ b/app/aggregate/user/handlers/get_balance.go @@ -1,29 +1,30 @@ package userhandlers import ( - "math/big" + "fmt" "net/http" "github.com/go-chi/render" - "github.com/rs/zerolog/log" "github.com/F0rzend/simple-go-webserver/app/common" ) -func (h *UserHTTPHandlers) GetUserBalance(w http.ResponseWriter, r *http.Request) { +func (h *UserHTTPHandlers) GetUserBalance(w http.ResponseWriter, r *http.Request) error { id, err := h.getUserIDFromRequest(r) if err != nil { - log.Error().Err(err).Send() - w.WriteHeader(http.StatusInternalServerError) - return + return fmt.Errorf("failed to get user id from request: %w", err) } balance, err := h.service.GetUserBalance(id) + if common.IsFlaggedError(err, common.FlagNotFound) { + return common.NewNotFoundError("user not found") + } if err != nil { - common.RenderHTTPError(w, r, err) - return + return fmt.Errorf("failed to get user balance: %w", err) } render.Status(r, http.StatusOK) - render.Respond(w, r, map[string]*big.Float{"balance": balance.ToFloat()}) + render.Respond(w, r, map[string]any{"balance": balance.ToFloat()}) + + return nil } diff --git a/app/aggregate/user/handlers/get_balance_test.go b/app/aggregate/user/handlers/get_balance_test.go index 0b7a111..9aabfb3 100644 --- a/app/aggregate/user/handlers/get_balance_test.go +++ b/app/aggregate/user/handlers/get_balance_test.go @@ -3,31 +3,64 @@ package userhandlers import ( "net/http" "testing" + "time" - "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" + "github.com/F0rzend/simple-go-webserver/app/common" - "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" + bitcoinservice "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/service" + userentity "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" + userservice "github.com/F0rzend/simple-go-webserver/app/aggregate/user/service" + "github.com/F0rzend/simple-go-webserver/app/tests" ) func TestUserHTTPHandlers_GetUserBalance(t *testing.T) { t.Parallel() + const expectedStatus = http.StatusOK + + getUserFunc := func(id uint64) (*userentity.User, error) { + return userentity.NewUser( + id, + "John", + "john", + "john@mail.com", + 0, + 100, + time.Now(), + time.Now(), + ) + } + getPriceFunc := func() bitcoinentity.BTCPrice { + price, err := bitcoinentity.NewBTCPrice(bitcoinentity.NewUSD(1), time.Now()) + require.NoError(t, err) + + return price + } getUserIDFromURL := func(_ *http.Request) (uint64, error) { return 1, nil } - service := &MockUserService{ - GetUserBalanceFunc: func(_ uint64) (bitcoinentity.USD, error) { - return bitcoinentity.USD{}, nil - }, + userRepository := &userservice.MockUserRepository{ + GetFunc: getUserFunc, } + bitcoinRepository := &bitcoinservice.MockBTCRepository{ + GetPriceFunc: getPriceFunc, + } + service := userservice.NewUserService(userRepository, bitcoinRepository) + handler := NewUserHTTPHandlers(service, getUserIDFromURL).GetUserBalance + sut := common.ErrorHandler(handler) - handler := http.HandlerFunc(NewUserHTTPHandlers(service, getUserIDFromURL).GetUserBalance) - - w, r := tests.PrepareHandlerArgs(t, http.MethodPost, "/users/1/balance", nil) - handler.ServeHTTP(w, r) + tests.HTTPExpect(t, sut). + POST("/"). + Expect(). + Status(expectedStatus). + ContentType("application/json"). + JSON().Object().ValueEqual("balance", "100") - tests.AssertStatus(t, w, r, http.StatusOK) - assert.Len(t, service.GetUserBalanceCalls(), 1) + assert.Len(t, userRepository.GetCalls(), 1) + assert.Len(t, bitcoinRepository.GetPriceCalls(), 1) } diff --git a/app/aggregate/user/handlers/get_user.go b/app/aggregate/user/handlers/get_user.go index 6970fd7..e63cfcf 100644 --- a/app/aggregate/user/handlers/get_user.go +++ b/app/aggregate/user/handlers/get_user.go @@ -1,12 +1,12 @@ package userhandlers import ( + "fmt" "math/big" "net/http" "time" "github.com/go-chi/render" - "github.com/rs/zerolog/log" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" "github.com/F0rzend/simple-go-webserver/app/common" @@ -19,8 +19,8 @@ type UserResponse struct { Email string `json:"email"` BTCBalance *big.Float `json:"btc_balance"` USDBalance *big.Float `json:"usd_balance"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func UserToResponse(user *userentity.User) *UserResponse { @@ -31,25 +31,27 @@ func UserToResponse(user *userentity.User) *UserResponse { Email: user.Email.Address, BTCBalance: user.Balance.BTC.ToFloat(), USDBalance: user.Balance.USD.ToFloat(), - CreatedAt: user.CreatedAt.Format(time.RFC3339), - UpdatedAt: user.UpdatedAt.Format(time.RFC3339), + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, } } -func (h *UserHTTPHandlers) GetUser(w http.ResponseWriter, r *http.Request) { +func (h *UserHTTPHandlers) GetUser(w http.ResponseWriter, r *http.Request) error { id, err := h.getUserIDFromRequest(r) if err != nil { - log.Error().Err(err).Send() - w.WriteHeader(http.StatusInternalServerError) - return + return fmt.Errorf("failed to get user id from request: %w", err) } user, err := h.service.GetUser(id) + if common.IsFlaggedError(err, common.FlagNotFound) { + return common.NewNotFoundError("user not found") + } if err != nil { - common.RenderHTTPError(w, r, err) - return + return fmt.Errorf("failed to get user: %w", err) } render.Status(r, http.StatusOK) render.Respond(w, r, UserToResponse(user)) + + return nil } diff --git a/app/aggregate/user/handlers/get_user_test.go b/app/aggregate/user/handlers/get_user_test.go index 07ebd2e..81e1f6c 100644 --- a/app/aggregate/user/handlers/get_user_test.go +++ b/app/aggregate/user/handlers/get_user_test.go @@ -1,36 +1,67 @@ package userhandlers import ( + "math/big" "net/http" - "net/mail" "testing" + "time" - "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" + "github.com/F0rzend/simple-go-webserver/app/common" - "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/stretchr/testify/assert" + + bitcoinservice "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/service" + userentity "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" + userservice "github.com/F0rzend/simple-go-webserver/app/aggregate/user/service" + "github.com/F0rzend/simple-go-webserver/app/tests" ) func TestUserHTTPHandlers_GetUser(t *testing.T) { t.Parallel() + now := time.Now() + const expectedStatus = http.StatusOK + + getUserFunc := func(id uint64) (*userentity.User, error) { + return userentity.NewUser( + 1, + "John", + "john", + "john@mail.com", + 0, + 0, + now, + now, + ) + } getUserIDFromURL := func(_ *http.Request) (uint64, error) { return 1, nil } - service := &MockUserService{ - GetUserFunc: func(_ uint64) (*userentity.User, error) { - return &userentity.User{ - Email: &mail.Address{Address: "test@mail.com"}, - }, nil - }, + userRepository := &userservice.MockUserRepository{ + GetFunc: getUserFunc, } + bitcoinRepository := &bitcoinservice.MockBTCRepository{} + service := userservice.NewUserService(userRepository, bitcoinRepository) + handler := NewUserHTTPHandlers(service, getUserIDFromURL).GetUser + sut := common.ErrorHandler(handler) - handler := http.HandlerFunc(NewUserHTTPHandlers(service, getUserIDFromURL).GetUser) - - w, r := tests.PrepareHandlerArgs(t, http.MethodGet, "/users/1", nil) - handler.ServeHTTP(w, r) + tests.HTTPExpect(t, sut). + GET("/"). + Expect(). + Status(expectedStatus). + ContentType("application/json"). + JSON().Object().Equal( + UserResponse{ + ID: 1, + Name: "John", + Username: "john", + Email: "john@mail.com", + BTCBalance: big.NewFloat(0), + USDBalance: big.NewFloat(0), + CreatedAt: now, + UpdatedAt: now, + }) - tests.AssertStatus(t, w, r, http.StatusOK) - assert.Len(t, service.GetUserCalls(), 1) + assert.Len(t, userRepository.GetCalls(), 1) } diff --git a/app/aggregate/user/handlers/handlers.go b/app/aggregate/user/handlers/handlers.go index 5c7303b..26f918b 100644 --- a/app/aggregate/user/handlers/handlers.go +++ b/app/aggregate/user/handlers/handlers.go @@ -13,7 +13,6 @@ type UserHTTPHandlers struct { getUserIDFromRequest func(r *http.Request) (uint64, error) } -//go:generate moq -out "mock_user_service.gen.go" . UserService:MockUserService type UserService interface { CreateUser(name, username, email string) (uint64, error) GetUser(uint64) (*userentity.User, error) diff --git a/app/aggregate/user/handlers/mock_user_service.gen.go b/app/aggregate/user/handlers/mock_user_service.gen.go deleted file mode 100644 index ad38e51..0000000 --- a/app/aggregate/user/handlers/mock_user_service.gen.go +++ /dev/null @@ -1,338 +0,0 @@ -// Code generated by moq; DO NOT EDIT. -// github.com/matryer/moq - -package userhandlers - -import ( - "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" - "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" - "sync" -) - -// Ensure, that MockUserService does implement UserService. -// If this is not the case, regenerate this file with moq. -var _ UserService = &MockUserService{} - -// MockUserService is a mock implementation of UserService. -// -// func TestSomethingThatUsesUserService(t *testing.T) { -// -// // make and configure a mocked UserService -// mockedUserService := &MockUserService{ -// ChangeBitcoinBalanceFunc: func(userID uint64, action string, amount float64) error { -// panic("mock out the ChangeBitcoinBalance method") -// }, -// ChangeUserBalanceFunc: func(userID uint64, action string, amount float64) error { -// panic("mock out the ChangeUserBalance method") -// }, -// CreateUserFunc: func(name string, username string, email string) (uint64, error) { -// panic("mock out the CreateUser method") -// }, -// GetUserFunc: func(v uint64) (*userentity.User, error) { -// panic("mock out the GetUser method") -// }, -// GetUserBalanceFunc: func(userID uint64) (bitcoinentity.USD, error) { -// panic("mock out the GetUserBalance method") -// }, -// UpdateUserFunc: func(userID uint64, name *string, email *string) error { -// panic("mock out the UpdateUser method") -// }, -// } -// -// // use mockedUserService in code that requires UserService -// // and then make assertions. -// -// } -type MockUserService struct { - // ChangeBitcoinBalanceFunc mocks the ChangeBitcoinBalance method. - ChangeBitcoinBalanceFunc func(userID uint64, action string, amount float64) error - - // ChangeUserBalanceFunc mocks the ChangeUserBalance method. - ChangeUserBalanceFunc func(userID uint64, action string, amount float64) error - - // CreateUserFunc mocks the CreateUser method. - CreateUserFunc func(name string, username string, email string) (uint64, error) - - // GetUserFunc mocks the GetUser method. - GetUserFunc func(v uint64) (*userentity.User, error) - - // GetUserBalanceFunc mocks the GetUserBalance method. - GetUserBalanceFunc func(userID uint64) (bitcoinentity.USD, error) - - // UpdateUserFunc mocks the UpdateUser method. - UpdateUserFunc func(userID uint64, name *string, email *string) error - - // calls tracks calls to the methods. - calls struct { - // ChangeBitcoinBalance holds details about calls to the ChangeBitcoinBalance method. - ChangeBitcoinBalance []struct { - // UserID is the userID argument value. - UserID uint64 - // Action is the action argument value. - Action string - // Amount is the amount argument value. - Amount float64 - } - // ChangeUserBalance holds details about calls to the ChangeUserBalance method. - ChangeUserBalance []struct { - // UserID is the userID argument value. - UserID uint64 - // Action is the action argument value. - Action string - // Amount is the amount argument value. - Amount float64 - } - // CreateUser holds details about calls to the CreateUser method. - CreateUser []struct { - // Name is the name argument value. - Name string - // Username is the username argument value. - Username string - // Email is the email argument value. - Email string - } - // GetUser holds details about calls to the GetUser method. - GetUser []struct { - // V is the v argument value. - V uint64 - } - // GetUserBalance holds details about calls to the GetUserBalance method. - GetUserBalance []struct { - // UserID is the userID argument value. - UserID uint64 - } - // UpdateUser holds details about calls to the UpdateUser method. - UpdateUser []struct { - // UserID is the userID argument value. - UserID uint64 - // Name is the name argument value. - Name *string - // Email is the email argument value. - Email *string - } - } - lockChangeBitcoinBalance sync.RWMutex - lockChangeUserBalance sync.RWMutex - lockCreateUser sync.RWMutex - lockGetUser sync.RWMutex - lockGetUserBalance sync.RWMutex - lockUpdateUser sync.RWMutex -} - -// ChangeBitcoinBalance calls ChangeBitcoinBalanceFunc. -func (mock *MockUserService) ChangeBitcoinBalance(userID uint64, action string, amount float64) error { - if mock.ChangeBitcoinBalanceFunc == nil { - panic("MockUserService.ChangeBitcoinBalanceFunc: method is nil but UserService.ChangeBitcoinBalance was just called") - } - callInfo := struct { - UserID uint64 - Action string - Amount float64 - }{ - UserID: userID, - Action: action, - Amount: amount, - } - mock.lockChangeBitcoinBalance.Lock() - mock.calls.ChangeBitcoinBalance = append(mock.calls.ChangeBitcoinBalance, callInfo) - mock.lockChangeBitcoinBalance.Unlock() - return mock.ChangeBitcoinBalanceFunc(userID, action, amount) -} - -// ChangeBitcoinBalanceCalls gets all the calls that were made to ChangeBitcoinBalance. -// Check the length with: -// len(mockedUserService.ChangeBitcoinBalanceCalls()) -func (mock *MockUserService) ChangeBitcoinBalanceCalls() []struct { - UserID uint64 - Action string - Amount float64 -} { - var calls []struct { - UserID uint64 - Action string - Amount float64 - } - mock.lockChangeBitcoinBalance.RLock() - calls = mock.calls.ChangeBitcoinBalance - mock.lockChangeBitcoinBalance.RUnlock() - return calls -} - -// ChangeUserBalance calls ChangeUserBalanceFunc. -func (mock *MockUserService) ChangeUserBalance(userID uint64, action string, amount float64) error { - if mock.ChangeUserBalanceFunc == nil { - panic("MockUserService.ChangeUserBalanceFunc: method is nil but UserService.ChangeUserBalance was just called") - } - callInfo := struct { - UserID uint64 - Action string - Amount float64 - }{ - UserID: userID, - Action: action, - Amount: amount, - } - mock.lockChangeUserBalance.Lock() - mock.calls.ChangeUserBalance = append(mock.calls.ChangeUserBalance, callInfo) - mock.lockChangeUserBalance.Unlock() - return mock.ChangeUserBalanceFunc(userID, action, amount) -} - -// ChangeUserBalanceCalls gets all the calls that were made to ChangeUserBalance. -// Check the length with: -// len(mockedUserService.ChangeUserBalanceCalls()) -func (mock *MockUserService) ChangeUserBalanceCalls() []struct { - UserID uint64 - Action string - Amount float64 -} { - var calls []struct { - UserID uint64 - Action string - Amount float64 - } - mock.lockChangeUserBalance.RLock() - calls = mock.calls.ChangeUserBalance - mock.lockChangeUserBalance.RUnlock() - return calls -} - -// CreateUser calls CreateUserFunc. -func (mock *MockUserService) CreateUser(name string, username string, email string) (uint64, error) { - if mock.CreateUserFunc == nil { - panic("MockUserService.CreateUserFunc: method is nil but UserService.CreateUser was just called") - } - callInfo := struct { - Name string - Username string - Email string - }{ - Name: name, - Username: username, - Email: email, - } - mock.lockCreateUser.Lock() - mock.calls.CreateUser = append(mock.calls.CreateUser, callInfo) - mock.lockCreateUser.Unlock() - return mock.CreateUserFunc(name, username, email) -} - -// CreateUserCalls gets all the calls that were made to CreateUser. -// Check the length with: -// len(mockedUserService.CreateUserCalls()) -func (mock *MockUserService) CreateUserCalls() []struct { - Name string - Username string - Email string -} { - var calls []struct { - Name string - Username string - Email string - } - mock.lockCreateUser.RLock() - calls = mock.calls.CreateUser - mock.lockCreateUser.RUnlock() - return calls -} - -// GetUser calls GetUserFunc. -func (mock *MockUserService) GetUser(v uint64) (*userentity.User, error) { - if mock.GetUserFunc == nil { - panic("MockUserService.GetUserFunc: method is nil but UserService.GetUser was just called") - } - callInfo := struct { - V uint64 - }{ - V: v, - } - mock.lockGetUser.Lock() - mock.calls.GetUser = append(mock.calls.GetUser, callInfo) - mock.lockGetUser.Unlock() - return mock.GetUserFunc(v) -} - -// GetUserCalls gets all the calls that were made to GetUser. -// Check the length with: -// len(mockedUserService.GetUserCalls()) -func (mock *MockUserService) GetUserCalls() []struct { - V uint64 -} { - var calls []struct { - V uint64 - } - mock.lockGetUser.RLock() - calls = mock.calls.GetUser - mock.lockGetUser.RUnlock() - return calls -} - -// GetUserBalance calls GetUserBalanceFunc. -func (mock *MockUserService) GetUserBalance(userID uint64) (bitcoinentity.USD, error) { - if mock.GetUserBalanceFunc == nil { - panic("MockUserService.GetUserBalanceFunc: method is nil but UserService.GetUserBalance was just called") - } - callInfo := struct { - UserID uint64 - }{ - UserID: userID, - } - mock.lockGetUserBalance.Lock() - mock.calls.GetUserBalance = append(mock.calls.GetUserBalance, callInfo) - mock.lockGetUserBalance.Unlock() - return mock.GetUserBalanceFunc(userID) -} - -// GetUserBalanceCalls gets all the calls that were made to GetUserBalance. -// Check the length with: -// len(mockedUserService.GetUserBalanceCalls()) -func (mock *MockUserService) GetUserBalanceCalls() []struct { - UserID uint64 -} { - var calls []struct { - UserID uint64 - } - mock.lockGetUserBalance.RLock() - calls = mock.calls.GetUserBalance - mock.lockGetUserBalance.RUnlock() - return calls -} - -// UpdateUser calls UpdateUserFunc. -func (mock *MockUserService) UpdateUser(userID uint64, name *string, email *string) error { - if mock.UpdateUserFunc == nil { - panic("MockUserService.UpdateUserFunc: method is nil but UserService.UpdateUser was just called") - } - callInfo := struct { - UserID uint64 - Name *string - Email *string - }{ - UserID: userID, - Name: name, - Email: email, - } - mock.lockUpdateUser.Lock() - mock.calls.UpdateUser = append(mock.calls.UpdateUser, callInfo) - mock.lockUpdateUser.Unlock() - return mock.UpdateUserFunc(userID, name, email) -} - -// UpdateUserCalls gets all the calls that were made to UpdateUser. -// Check the length with: -// len(mockedUserService.UpdateUserCalls()) -func (mock *MockUserService) UpdateUserCalls() []struct { - UserID uint64 - Name *string - Email *string -} { - var calls []struct { - UserID uint64 - Name *string - Email *string - } - mock.lockUpdateUser.RLock() - calls = mock.calls.UpdateUser - mock.lockUpdateUser.RUnlock() - return calls -} diff --git a/app/aggregate/user/handlers/update_user.go b/app/aggregate/user/handlers/update_user.go index 764f8a0..14c1ede 100644 --- a/app/aggregate/user/handlers/update_user.go +++ b/app/aggregate/user/handlers/update_user.go @@ -5,10 +5,8 @@ import ( "net/http" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" - "github.com/go-chi/render" - "github.com/rs/zerolog/log" - "github.com/F0rzend/simple-go-webserver/app/common" + "github.com/go-chi/render" ) type UpdateUserRequest struct { @@ -16,45 +14,45 @@ type UpdateUserRequest struct { Email *string `json:"email,omitempty"` } -var ErrNothingToUpdate = common.NewApplicationError( - http.StatusBadRequest, - "At least one field must be updated", -) - func (r UpdateUserRequest) Bind(_ *http.Request) error { if r.Name == nil && r.Email == nil { - return ErrNothingToUpdate + return common.NewValidationError("nothing to update, please provide name or email") } if r.Email != nil { if _, err := userentity.ParseEmail(*r.Email); err != nil { - return err + return common.NewValidationError("invalid email") } } return nil } -func (h *UserHTTPHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) { +func (h *UserHTTPHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) error { request := &UpdateUserRequest{} id, err := h.getUserIDFromRequest(r) if err != nil { - log.Error().Err(err).Send() - w.WriteHeader(http.StatusInternalServerError) - return + return fmt.Errorf("failed to get user id from request: %w", err) } if err := render.Bind(r, request); err != nil { - common.RenderHTTPError(w, r, err) - return + return fmt.Errorf("failed to bind request: %w", err) } - if err := h.service.UpdateUser(id, request.Name, request.Email); err != nil { - common.RenderHTTPError(w, r, err) - return + err = h.service.UpdateUser(id, request.Name, request.Email) + if common.IsFlaggedError(err, common.FlagInvalidArgument) { + return common.NewValidationError(err.Error()) + } + if common.IsFlaggedError(err, common.FlagNotFound) { + return common.NewNotFoundError(fmt.Sprintf("user with id %d not found", id)) + } + if err != nil { + return fmt.Errorf("failed to update user: %w", err) } render.Status(r, http.StatusNoContent) w.Header().Set("Location", fmt.Sprintf("/users/%d", id)) render.Respond(w, r, nil) + + return nil } diff --git a/app/aggregate/user/handlers/update_user_test.go b/app/aggregate/user/handlers/update_user_test.go index cd83cbc..26cd952 100644 --- a/app/aggregate/user/handlers/update_user_test.go +++ b/app/aggregate/user/handlers/update_user_test.go @@ -3,27 +3,39 @@ package userhandlers import ( "net/http" "testing" + "time" + + "github.com/F0rzend/simple-go-webserver/app/common" - "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/stretchr/testify/assert" + + bitcoinservice "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/service" + userentity "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" + userservice "github.com/F0rzend/simple-go-webserver/app/aggregate/user/service" + "github.com/F0rzend/simple-go-webserver/app/tests" ) func TestUserHTTPHandlers_UpdateUser(t *testing.T) { t.Parallel() + type response struct { + status int + location string + } + testCases := []struct { - name string - request UpdateUserRequest - shouldContainLocationHeader bool - updateUserCallsAmount int - expectedStatus int + name string + request UpdateUserRequest + saveUserCallsAmount int + response response }{ { - name: "empty request", - request: UpdateUserRequest{}, - shouldContainLocationHeader: false, - updateUserCallsAmount: 0, - expectedStatus: http.StatusBadRequest, + name: "empty request", + request: UpdateUserRequest{}, + saveUserCallsAmount: 0, + response: response{ + status: http.StatusBadRequest, + }, }, { name: "update name and email", @@ -31,62 +43,88 @@ func TestUserHTTPHandlers_UpdateUser(t *testing.T) { Name: strPointer("test"), Email: strPointer("test@mail.com"), }, - shouldContainLocationHeader: true, - updateUserCallsAmount: 1, - expectedStatus: http.StatusNoContent, + saveUserCallsAmount: 1, + response: response{ + status: http.StatusNoContent, + location: "/users/1", + }, }, { name: "correct email", request: UpdateUserRequest{ - Email: strPointer("test@m"), + Email: strPointer("test@mail.com"), + }, + saveUserCallsAmount: 1, + response: response{ + status: http.StatusNoContent, + location: "/users/1", }, - shouldContainLocationHeader: true, - updateUserCallsAmount: 1, - expectedStatus: http.StatusNoContent, }, { name: "incorrect email", request: UpdateUserRequest{ Email: strPointer("test"), }, - shouldContainLocationHeader: false, - updateUserCallsAmount: 0, - expectedStatus: http.StatusBadRequest, + saveUserCallsAmount: 0, + response: response{ + status: http.StatusBadRequest, + }, }, { name: "update name", request: UpdateUserRequest{ Name: strPointer("test"), }, - shouldContainLocationHeader: true, - updateUserCallsAmount: 1, - expectedStatus: http.StatusNoContent, + saveUserCallsAmount: 1, + response: response{ + status: http.StatusNoContent, + location: "/users/1", + }, }, } - getUserIDFromURL := func(_ *http.Request) (uint64, error) { - return 1, nil + getUserFunc := func(id uint64) (*userentity.User, error) { + return userentity.NewUser( + id, + "John", + "john", + "john@mail.com", + 0, + 100, + time.Now(), + time.Now(), + ) } - updateUserFunc := func(_ uint64, _, _ *string) error { + saveUserFunc := func(_ *userentity.User) error { return nil } + getUserIDFromURL := func(_ *http.Request) (uint64, error) { + return 1, nil + } for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - service := &MockUserService{UpdateUserFunc: updateUserFunc} - handler := http.HandlerFunc(NewUserHTTPHandlers(service, getUserIDFromURL).UpdateUser) + userRepository := &userservice.MockUserRepository{ + GetFunc: getUserFunc, + SaveFunc: saveUserFunc, + } + bitcoinRepository := &bitcoinservice.MockBTCRepository{} + service := userservice.NewUserService(userRepository, bitcoinRepository) + handler := NewUserHTTPHandlers(service, getUserIDFromURL).UpdateUser + sut := common.ErrorHandler(handler) - w, r := tests.PrepareHandlerArgs(t, http.MethodPost, "/users/1", tc.request) - handler.ServeHTTP(w, r) + tests.HTTPExpect(t, sut). + POST("/"). + WithJSON(tc.request). + Expect(). + Status(tc.response.status). + ContentType("application/json"). + Header("Location").Equal(tc.response.location) - tests.AssertStatus(t, w, r, tc.expectedStatus) - if tc.shouldContainLocationHeader { - assert.Equal(t, "/users/1", w.Header().Get("Location")) - } - assert.Len(t, service.UpdateUserCalls(), tc.updateUserCallsAmount) + assert.Len(t, userRepository.SaveCalls(), tc.saveUserCallsAmount) }) } } diff --git a/app/aggregate/user/repositories/errors.go b/app/aggregate/user/repositories/errors.go deleted file mode 100644 index de683f2..0000000 --- a/app/aggregate/user/repositories/errors.go +++ /dev/null @@ -1,12 +0,0 @@ -package userrepositories - -import ( - "net/http" - - "github.com/F0rzend/simple-go-webserver/app/common" -) - -var ErrUserNotFound = common.NewApplicationError( - http.StatusNotFound, - "User not found", -) diff --git a/app/aggregate/user/repositories/memory.go b/app/aggregate/user/repositories/memory.go index b0d63a6..147405e 100644 --- a/app/aggregate/user/repositories/memory.go +++ b/app/aggregate/user/repositories/memory.go @@ -2,6 +2,7 @@ package userrepositories import ( "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" + "github.com/F0rzend/simple-go-webserver/app/common" ) type MemoryUserRepository struct { @@ -22,7 +23,7 @@ func (r *MemoryUserRepository) Save(user *userentity.User) error { func (r *MemoryUserRepository) Get(id uint64) (*userentity.User, error) { user, ok := r.users[id] if !ok { - return nil, ErrUserNotFound + return nil, common.NewFlaggedError("user not found", common.FlagNotFound) } return user, nil } @@ -30,7 +31,7 @@ func (r *MemoryUserRepository) Get(id uint64) (*userentity.User, error) { func (r *MemoryUserRepository) Delete(id uint64) error { _, ok := r.users[id] if !ok { - return ErrUserNotFound + return common.NewFlaggedError("user to delete not found", common.FlagNotFound) } delete(r.users, id) return nil diff --git a/app/aggregate/user/service/change_bitcoin_balance.go b/app/aggregate/user/service/change_bitcoin_balance.go index d8f3c09..85311c4 100644 --- a/app/aggregate/user/service/change_bitcoin_balance.go +++ b/app/aggregate/user/service/change_bitcoin_balance.go @@ -1,25 +1,33 @@ package userservice import ( + "fmt" + "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" ) func (us *UserService) ChangeBitcoinBalance(userID uint64, action string, amount float64) error { - btc, err := bitcoinentity.NewBTC(amount) - if err != nil { - return err - } - user, err := us.userRepository.Get(userID) if err != nil { - return err + return fmt.Errorf("cannot get user: %w", err) } currentBitcoinPrice := us.priceGetter.GetPrice() - if err := user.ChangeBTCBalance(userentity.Action(action), btc, currentBitcoinPrice); err != nil { - return err + err = user.ChangeBTCBalance( + userentity.Action(action), + bitcoinentity.NewBTC(amount), + currentBitcoinPrice, + ) + if err != nil { + return fmt.Errorf("cannot change user balance: %w", err) } - return us.userRepository.Save(user) + + err = us.userRepository.Save(user) + if err != nil { + return fmt.Errorf("cannot save user: %w", err) + } + + return nil } diff --git a/app/aggregate/user/service/change_bitcoin_balance_test.go b/app/aggregate/user/service/change_bitcoin_balance_test.go index 6dc7b9e..b11b3c5 100644 --- a/app/aggregate/user/service/change_bitcoin_balance_test.go +++ b/app/aggregate/user/service/change_bitcoin_balance_test.go @@ -1,28 +1,30 @@ package userservice import ( - "net/http" "testing" "time" - userrepositories "github.com/F0rzend/simple-go-webserver/app/aggregate/user/repositories" + "github.com/F0rzend/simple-go-webserver/app/common" + + "github.com/stretchr/testify/require" + + "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/stretchr/testify/assert" "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" - "github.com/F0rzend/simple-go-webserver/app/common" ) func TestUserService_ChangeBitcoinBalance(t *testing.T) { t.Parallel() var ( - zeroDollar, _ = bitcoinentity.NewUSD(0) - zeroBitcoin, _ = bitcoinentity.NewBTC(0) + zeroDollar = bitcoinentity.NewUSD(0) + zeroBitcoin = bitcoinentity.NewBTC(0) - oneDollar, _ = bitcoinentity.NewUSD(1) - oneBitcoin, _ = bitcoinentity.NewBTC(1) + oneDollar = bitcoinentity.NewUSD(1) + oneBitcoin = bitcoinentity.NewBTC(1) ) testUsers := map[uint64]*userentity.User{ @@ -34,7 +36,7 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { getUserFunc := func(id uint64) (*userentity.User, error) { user, ok := testUsers[id] if !ok { - return nil, userrepositories.ErrUserNotFound + return nil, common.NewFlaggedError("user not found", common.FlagNotFound) } return user, nil } @@ -42,8 +44,10 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { return nil } getBitcoinPriceFunc := func() bitcoinentity.BTCPrice { - price, _ := bitcoinentity.NewUSD(1) - return bitcoinentity.NewBTCPrice(price, time.Now()) + price, err := bitcoinentity.NewBTCPrice(bitcoinentity.NewUSD(1), time.Now()) + require.NoError(t, err) + + return price } type command struct { @@ -58,7 +62,7 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { getUserCallsAmount int saveUserCallsAmount int getPriceCallsAmount int - err error + checkErr tests.ErrorChecker }{ { name: "invalid action", @@ -67,10 +71,7 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { }, getUserCallsAmount: 1, getPriceCallsAmount: 1, - err: common.NewApplicationError( - http.StatusBadRequest, - "You must specify a valid action. Available actions: buy and sell", - ), + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "user not found", @@ -79,10 +80,7 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { action: "buy", }, getUserCallsAmount: 1, - err: common.NewApplicationError( - http.StatusNotFound, - "User not found", - ), + checkErr: tests.AssertErrorFlag(common.FlagNotFound), }, { name: "negative currency", @@ -91,10 +89,8 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { action: "buy", amount: -1, }, - err: common.NewApplicationError( - http.StatusBadRequest, - "The amount of currency cannot be negative. Please pass a number greater than 0", - ), + getPriceCallsAmount: 1, + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "user has not enough funds to buy btc", @@ -104,10 +100,7 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { amount: 1, }, getPriceCallsAmount: 1, - err: common.NewApplicationError( - http.StatusBadRequest, - "The user does not have enough funds", - ), + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "user has not enough funds to sell btc", @@ -117,10 +110,7 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { amount: 1, }, getPriceCallsAmount: 1, - err: common.NewApplicationError( - http.StatusBadRequest, - "The user does not have enough funds", - ), + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "user has enough funds to buy btc", @@ -132,6 +122,7 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { getUserCallsAmount: 1, saveUserCallsAmount: 1, getPriceCallsAmount: 1, + checkErr: assert.NoError, }, { name: "user has enough funds to sell btc", @@ -143,6 +134,7 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { getUserCallsAmount: 1, saveUserCallsAmount: 1, getPriceCallsAmount: 1, + checkErr: assert.NoError, }, } @@ -162,7 +154,7 @@ func TestUserService_ChangeBitcoinBalance(t *testing.T) { tc.cmd.amount, ) - assert.Equal(t, tc.err, err) + tc.checkErr(t, err) assert.Len(t, userRepository.SaveCalls(), tc.saveUserCallsAmount) assert.Len(t, btcPriceGetter.GetPriceCalls(), tc.getPriceCallsAmount) }) diff --git a/app/aggregate/user/service/change_user_balance.go b/app/aggregate/user/service/change_user_balance.go index 6a157f6..276a913 100644 --- a/app/aggregate/user/service/change_user_balance.go +++ b/app/aggregate/user/service/change_user_balance.go @@ -1,24 +1,30 @@ package userservice import ( - "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" + "fmt" + + bitcoinentity "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" ) func (us *UserService) ChangeUserBalance(userID uint64, action string, amount float64) error { - usd, err := bitcoinentity.NewUSD(amount) + user, err := us.userRepository.Get(userID) if err != nil { - return err + return fmt.Errorf("error getting user: %w", err) } - user, err := us.userRepository.Get(userID) + err = user.ChangeUSDBalance( + userentity.Action(action), + bitcoinentity.NewUSD(amount), + ) if err != nil { - return err + return fmt.Errorf("error changing user balance: %w", err) } - if err := user.ChangeUSDBalance(userentity.Action(action), usd); err != nil { - return err + err = us.userRepository.Save(user) + if err != nil { + return fmt.Errorf("error saving user: %w", err) } - return us.userRepository.Save(user) + return nil } diff --git a/app/aggregate/user/service/change_user_balance_test.go b/app/aggregate/user/service/change_user_balance_test.go index 31ba743..154f95e 100644 --- a/app/aggregate/user/service/change_user_balance_test.go +++ b/app/aggregate/user/service/change_user_balance_test.go @@ -1,34 +1,29 @@ package userservice import ( - "net/http" "testing" "github.com/F0rzend/simple-go-webserver/app/common" + "github.com/F0rzend/simple-go-webserver/app/tests" + "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" - "github.com/F0rzend/simple-go-webserver/app/aggregate/user/repositories" "github.com/stretchr/testify/assert" ) func TestUserService_ChangeUserBalance(t *testing.T) { t.Parallel() - var ( - zeroBitcoin, _ = bitcoinentity.NewBTC(0) - oneDollar, _ = bitcoinentity.NewUSD(1) - ) - testUsers := map[uint64]userentity.User{ 0: {}, - 1: {Balance: userentity.Balance{USD: oneDollar, BTC: zeroBitcoin}}, + 1: {Balance: userentity.Balance{USD: bitcoinentity.NewUSD(1), BTC: bitcoinentity.NewBTC(0)}}, } getUserFunc := func(id uint64) (*userentity.User, error) { user, ok := testUsers[id] if !ok { - return nil, userrepositories.ErrUserNotFound + return nil, common.NewFlaggedError("user not found", common.FlagNotFound) } return &user, nil } @@ -47,7 +42,7 @@ func TestUserService_ChangeUserBalance(t *testing.T) { cmd command getUserCallsAmount int saveUserCallsAmount int - err error + checkErr tests.ErrorChecker }{ { name: "invalid action", @@ -57,10 +52,7 @@ func TestUserService_ChangeUserBalance(t *testing.T) { amount: 1, }, getUserCallsAmount: 1, - err: common.NewApplicationError( - http.StatusBadRequest, - "You must specify a valid action. Available actions: deposit and withdraw", - ), + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "negative currency", @@ -69,10 +61,9 @@ func TestUserService_ChangeUserBalance(t *testing.T) { action: "deposit", amount: -1, }, - err: common.NewApplicationError( - http.StatusBadRequest, - "The amount of currency cannot be negative. Please pass a number greater than 0", - ), + getUserCallsAmount: 1, + saveUserCallsAmount: 0, + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "user not found", @@ -82,10 +73,7 @@ func TestUserService_ChangeUserBalance(t *testing.T) { amount: 1, }, getUserCallsAmount: 1, - err: common.NewApplicationError( - http.StatusNotFound, - "User not found", - ), + checkErr: tests.AssertErrorFlag(common.FlagNotFound), }, { name: "user has not enough money to withdraw", @@ -95,10 +83,7 @@ func TestUserService_ChangeUserBalance(t *testing.T) { amount: 1, }, getUserCallsAmount: 1, - err: common.NewApplicationError( - http.StatusBadRequest, - "The user does not have enough funds", - ), + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "success withdraw", @@ -109,6 +94,7 @@ func TestUserService_ChangeUserBalance(t *testing.T) { }, getUserCallsAmount: 1, saveUserCallsAmount: 1, + checkErr: assert.NoError, }, { name: "success deposit", @@ -119,6 +105,7 @@ func TestUserService_ChangeUserBalance(t *testing.T) { }, getUserCallsAmount: 1, saveUserCallsAmount: 1, + checkErr: assert.NoError, }, } @@ -138,7 +125,7 @@ func TestUserService_ChangeUserBalance(t *testing.T) { tc.cmd.amount, ) - assert.Equal(t, tc.err, err) + tc.checkErr(t, err) assert.Len(t, userRepository.GetCalls(), tc.getUserCallsAmount) assert.Len(t, userRepository.SaveCalls(), tc.saveUserCallsAmount) }) diff --git a/app/aggregate/user/service/create_user.go b/app/aggregate/user/service/create_user.go index 9bb54b5..85e7d8e 100644 --- a/app/aggregate/user/service/create_user.go +++ b/app/aggregate/user/service/create_user.go @@ -1,6 +1,7 @@ package userservice import ( + "fmt" "time" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" @@ -26,11 +27,13 @@ func (us *UserService) CreateUser(name, username, email string) (uint64, error) time.Now(), ) if err != nil { - return 0, err + return 0, fmt.Errorf("error creating user: %w", err) } - if err := us.userRepository.Save(user); err != nil { - return 0, err + err = us.userRepository.Save(user) + if err != nil { + return 0, fmt.Errorf("error saving user: %w", err) } + return user.ID, nil } diff --git a/app/aggregate/user/service/create_user_test.go b/app/aggregate/user/service/create_user_test.go index c02dcd8..2b38535 100644 --- a/app/aggregate/user/service/create_user_test.go +++ b/app/aggregate/user/service/create_user_test.go @@ -1,9 +1,10 @@ package userservice import ( - "net/http" "testing" + "github.com/F0rzend/simple-go-webserver/app/tests" + "github.com/F0rzend/simple-go-webserver/app/common" "github.com/stretchr/testify/assert" @@ -24,7 +25,7 @@ func TestUserService_CreateUser(t *testing.T) { name string cmd command saveCallsAmount int - err error + checkErr tests.ErrorChecker }{ { name: "success", @@ -34,7 +35,7 @@ func TestUserService_CreateUser(t *testing.T) { email: "test@mail.com", }, saveCallsAmount: 1, - err: nil, + checkErr: assert.NoError, }, { name: "empty name", @@ -43,10 +44,7 @@ func TestUserService_CreateUser(t *testing.T) { username: "test", email: "test@mail.com", }, - err: common.NewApplicationError( - http.StatusBadRequest, - "Name cannot be empty", - ), + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "empty username", @@ -55,10 +53,7 @@ func TestUserService_CreateUser(t *testing.T) { username: "", email: "test@mail.com", }, - err: common.NewApplicationError( - http.StatusBadRequest, - "Username cannot be empty", - ), + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "invalid email", @@ -67,10 +62,7 @@ func TestUserService_CreateUser(t *testing.T) { username: "test", email: "test", }, - err: common.NewApplicationError( - http.StatusBadRequest, - "You must provide a valid email", - ), + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, } @@ -94,7 +86,7 @@ func TestUserService_CreateUser(t *testing.T) { tc.cmd.email, ) - assert.Equal(t, tc.err, err) + tc.checkErr(t, err) assert.Len(t, userRepository.SaveCalls(), tc.saveCallsAmount) }) } diff --git a/app/aggregate/user/service/get_user.go b/app/aggregate/user/service/get_user.go index 2943e9d..bd128f8 100644 --- a/app/aggregate/user/service/get_user.go +++ b/app/aggregate/user/service/get_user.go @@ -1,9 +1,16 @@ package userservice import ( + "fmt" + "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" ) func (us *UserService) GetUser(userID uint64) (*userentity.User, error) { - return us.userRepository.Get(userID) + user, err := us.userRepository.Get(userID) + if err != nil { + return nil, fmt.Errorf("error getting user: %w", err) + } + + return user, nil } diff --git a/app/aggregate/user/service/get_user_balance.go b/app/aggregate/user/service/get_user_balance.go index 0faa507..69d5908 100644 --- a/app/aggregate/user/service/get_user_balance.go +++ b/app/aggregate/user/service/get_user_balance.go @@ -1,13 +1,19 @@ package userservice import ( + "fmt" + "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" ) func (us *UserService) GetUserBalance(userID uint64) (bitcoinentity.USD, error) { user, err := us.userRepository.Get(userID) if err != nil { - return bitcoinentity.USD{}, err + return bitcoinentity.USD{}, fmt.Errorf("error getting user: %w", err) } - return user.Balance.Total(us.priceGetter.GetPrice()), nil + + currentBTCPrice := us.priceGetter.GetPrice() + userBalance := user.Balance.Total(currentBTCPrice) + + return userBalance, nil } diff --git a/app/aggregate/user/service/get_user_balance_test.go b/app/aggregate/user/service/get_user_balance_test.go index a5f8f16..71ae1b1 100644 --- a/app/aggregate/user/service/get_user_balance_test.go +++ b/app/aggregate/user/service/get_user_balance_test.go @@ -1,10 +1,9 @@ package userservice import ( - "net/http" "testing" - userrepositories "github.com/F0rzend/simple-go-webserver/app/aggregate/user/repositories" + "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" @@ -21,7 +20,7 @@ func TestUserService_GetUserBalance(t *testing.T) { case 1: return &userentity.User{}, nil default: - return nil, userrepositories.ErrUserNotFound + return nil, common.NewFlaggedError("user not found", common.FlagNotFound) } } @@ -33,20 +32,18 @@ func TestUserService_GetUserBalance(t *testing.T) { name string userID uint64 getBitcoinPriceCallsAmount int - err error + checkErr tests.ErrorChecker }{ { name: "success", getBitcoinPriceCallsAmount: 1, userID: 1, + checkErr: assert.NoError, }, { - name: "user not found", - userID: 0, - err: common.NewApplicationError( - http.StatusNotFound, - "User not found", - ), + name: "user not found", + userID: 0, + checkErr: tests.AssertErrorFlag(common.FlagNotFound), }, } @@ -62,7 +59,7 @@ func TestUserService_GetUserBalance(t *testing.T) { _, err := service.GetUserBalance(tc.userID) - assert.Equal(t, tc.err, err) + tc.checkErr(t, err) assert.Len(t, userRepository.GetCalls(), 1) assert.Len(t, btcPriceGetter.GetPriceCalls(), tc.getBitcoinPriceCallsAmount) }) diff --git a/app/aggregate/user/service/get_user_test.go b/app/aggregate/user/service/get_user_test.go index 93feeaf..a9767c2 100644 --- a/app/aggregate/user/service/get_user_test.go +++ b/app/aggregate/user/service/get_user_test.go @@ -1,10 +1,9 @@ package userservice import ( - "net/http" "testing" - userrepositories "github.com/F0rzend/simple-go-webserver/app/aggregate/user/repositories" + "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/stretchr/testify/assert" @@ -21,26 +20,24 @@ func TestUserService_GetUser(t *testing.T) { case 1: return &userentity.User{}, nil default: - return nil, userrepositories.ErrUserNotFound + return nil, common.NewFlaggedError("user not found", common.FlagNotFound) } } testCases := []struct { - name string - userID uint64 - err error + name string + userID uint64 + checkErr tests.ErrorChecker }{ { - name: "success", - userID: 1, + name: "success", + userID: 1, + checkErr: assert.NoError, }, { - name: "user not found", - userID: 0, - err: common.NewApplicationError( - http.StatusNotFound, - "User not found", - ), + name: "user not found", + userID: 0, + checkErr: tests.AssertErrorFlag(common.FlagNotFound), }, } @@ -56,7 +53,7 @@ func TestUserService_GetUser(t *testing.T) { _, err := service.GetUser(tc.userID) - assert.Equal(t, tc.err, err) + tc.checkErr(t, err) assert.Len(t, userRepository.GetCalls(), 1) }) } diff --git a/app/aggregate/user/service/update_user.go b/app/aggregate/user/service/update_user.go index d37228c..b835a25 100644 --- a/app/aggregate/user/service/update_user.go +++ b/app/aggregate/user/service/update_user.go @@ -1,6 +1,7 @@ package userservice import ( + "fmt" "time" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/entity" @@ -9,7 +10,7 @@ import ( func (us *UserService) UpdateUser(userID uint64, name, email *string) error { user, err := us.userRepository.Get(userID) if err != nil { - return err + return fmt.Errorf("failed to get user: %w", err) } if name == nil && email == nil { @@ -23,12 +24,17 @@ func (us *UserService) UpdateUser(userID uint64, name, email *string) error { if email != nil { newEmail, err := userentity.ParseEmail(*email) if err != nil { - return err + return fmt.Errorf("failed to parse email: %w", err) } user.Email = newEmail } user.UpdatedAt = time.Now() - return us.userRepository.Save(user) + err = us.userRepository.Save(user) + if err != nil { + return fmt.Errorf("failed to save user: %w", err) + } + + return nil } diff --git a/app/aggregate/user/service/update_user_test.go b/app/aggregate/user/service/update_user_test.go index f610fec..a8ef713 100644 --- a/app/aggregate/user/service/update_user_test.go +++ b/app/aggregate/user/service/update_user_test.go @@ -1,10 +1,9 @@ package userservice import ( - "net/http" "testing" - userrepositories "github.com/F0rzend/simple-go-webserver/app/aggregate/user/repositories" + "github.com/F0rzend/simple-go-webserver/app/tests" "github.com/F0rzend/simple-go-webserver/app/common" @@ -21,7 +20,7 @@ func TestUserService_UpdateUser(t *testing.T) { case 1: return &userentity.User{}, nil default: - return nil, userrepositories.ErrUserNotFound + return nil, common.NewFlaggedError("user not found", common.FlagNotFound) } } saveUserFunc := func(user *userentity.User) error { @@ -39,7 +38,7 @@ func TestUserService_UpdateUser(t *testing.T) { cmd command getUserCallsAmount int saveUserCallsAmount int - err error + checkErr tests.ErrorChecker }{ { name: "user not found", @@ -50,10 +49,7 @@ func TestUserService_UpdateUser(t *testing.T) { }, getUserCallsAmount: 1, saveUserCallsAmount: 0, - err: common.NewApplicationError( - http.StatusNotFound, - "User not found", - ), + checkErr: tests.AssertErrorFlag(common.FlagNotFound), }, { name: "update name", @@ -64,7 +60,7 @@ func TestUserService_UpdateUser(t *testing.T) { }, getUserCallsAmount: 1, saveUserCallsAmount: 1, - err: nil, + checkErr: assert.NoError, }, { name: "invalid email", @@ -75,10 +71,7 @@ func TestUserService_UpdateUser(t *testing.T) { }, getUserCallsAmount: 1, saveUserCallsAmount: 0, - err: common.NewApplicationError( - http.StatusBadRequest, - "You must provide a valid email", - ), + checkErr: tests.AssertErrorFlag(common.FlagInvalidArgument), }, { name: "update email", @@ -89,7 +82,7 @@ func TestUserService_UpdateUser(t *testing.T) { }, getUserCallsAmount: 1, saveUserCallsAmount: 1, - err: nil, + checkErr: assert.NoError, }, { name: "update name and email", @@ -100,7 +93,7 @@ func TestUserService_UpdateUser(t *testing.T) { }, getUserCallsAmount: 1, saveUserCallsAmount: 1, - err: nil, + checkErr: assert.NoError, }, } @@ -120,7 +113,7 @@ func TestUserService_UpdateUser(t *testing.T) { tc.cmd.email, ) - assert.Equal(t, tc.err, err) + tc.checkErr(t, err) assert.Len(t, userRepository.GetCalls(), tc.getUserCallsAmount) assert.Len(t, userRepository.SaveCalls(), tc.saveUserCallsAmount) }) diff --git a/app/common/error.go b/app/common/error.go deleted file mode 100644 index 0f53b5f..0000000 --- a/app/common/error.go +++ /dev/null @@ -1,52 +0,0 @@ -package common - -import ( - "context" - "net/http" - - "github.com/go-chi/render" - "github.com/rs/zerolog/log" -) - -type ApplicationError struct { - httpStatus int - ErrorMessage string `json:"error"` -} - -func (e ApplicationError) Error() string { - return e.ErrorMessage -} - -func NewApplicationError(httpStatus int, message string) ApplicationError { - return ApplicationError{ - httpStatus: httpStatus, - ErrorMessage: message, - } -} - -func (e ApplicationError) Render(_ http.ResponseWriter, r *http.Request) error { - render.Status(r, e.httpStatus) - return nil -} - -func RenderHTTPError(w http.ResponseWriter, r *http.Request, err error) { - ctx := r.Context() - - switch err := err.(type) { - case nil: - case ApplicationError: - logError(ctx, render.Render(w, r, err)) - return - default: - logError(ctx, err) - w.WriteHeader(http.StatusInternalServerError) - return - } -} - -func logError(ctx context.Context, err error) { - logger := log.Ctx(ctx) - if err != nil { - logger.Error().Err(err).Send() - } -} diff --git a/app/common/errors_test.go b/app/common/errors_test.go deleted file mode 100644 index c4d017c..0000000 --- a/app/common/errors_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package common - -import ( - "encoding/json" - "fmt" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestLayerErrorMarshaling(t *testing.T) { - t.Parallel() - - const ( - errorMessage = "error message" - statusCode = http.StatusInternalServerError - ) - - expectJSON := []byte(fmt.Sprintf(`{"error":%q}`, errorMessage)) - - appError := NewApplicationError(statusCode, errorMessage) - actual, err := json.Marshal(appError) - - assert.NoError(t, err) - assert.Equal(t, expectJSON, actual) -} diff --git a/app/common/flagged_error.go b/app/common/flagged_error.go new file mode 100644 index 0000000..b8eb187 --- /dev/null +++ b/app/common/flagged_error.go @@ -0,0 +1,52 @@ +package common + +import "errors" + +type Flag string + +// I think the statuses suggested in GRPC are very versatile and easy to use. +// Based on them, you can give a fairly clear error message +// regardless of the type of interface, be it http or grpc. +// Therefore, these flags should correspond to grpc error status codes. +// This solution will make it easier to switch to grpc if necessary. +// +// See https://github.com/grpc/grpc/blob/master/doc/statuscodes.md +const ( + FlagInvalidArgument Flag = "INVALID_ARGUMENT" + FlagNotFound Flag = "NOT_FOUND" +) + +type FlaggedError interface { + error + Flag() Flag +} + +func NewFlaggedError(msg string, flag Flag) error { + return fault{error: errors.New(msg), flag: flag} +} + +func FlagError(err error, flag Flag) error { + return fault{error: err, flag: flag} +} + +func IsFlaggedError(err error, flag Flag) bool { + var flagged FlaggedError + if errors.As(err, &flagged) { + return flagged.Flag() == flag + } + + return false +} + +type fault struct { + error + flag Flag +} + +func (e fault) Unwrap() error { + return e.error +} + +func (e fault) Flag() Flag { + return e.flag +} diff --git a/app/common/http_error.go b/app/common/http_error.go new file mode 100644 index 0000000..b4dfc12 --- /dev/null +++ b/app/common/http_error.go @@ -0,0 +1,127 @@ +// Package common +// Module errors +// RFC-7807 error handling +package common + +import ( + "errors" + "fmt" + "net/http" + + "github.com/F0rzend/simple-go-webserver/pkg/hlog" + + "github.com/go-chi/render" +) + +type ErrorType string + +const ( + InternalServerErrorType ErrorType = "InternalServerError" + ValueErrorType ErrorType = "ValueError" + NotFoundErrorType ErrorType = "NotFoundError" +) + +type HTTPError struct { + Type ErrorType `json:"type"` + Status int `json:"status"` + Title string `json:"title,omitempty"` + Detail string `json:"detail,omitempty"` + Instance string `json:"instance,omitempty"` + + err error +} + +func NewInternalServerError(err error) error { + return &HTTPError{ + Type: InternalServerErrorType, + Status: http.StatusInternalServerError, + Title: "Error on our side.", + err: err, + } +} + +func (e *HTTPError) Error() string { + err := fmt.Sprintf("%s#%d", e.Type, e.Status) + + if e.Title != "" { + err = fmt.Sprintf("%s: %s", err, e.Title) + } + + return err +} + +func (e *HTTPError) Unwrap() error { + return e.err +} + +func (e *HTTPError) Render(_ http.ResponseWriter, r *http.Request) error { + render.Status(r, e.Status) + + return nil +} + +func (e *HTTPError) GetType() ErrorType { + return e.Type +} + +func (e *HTTPError) GetStatus() int { + return e.Status +} + +func (e *HTTPError) GetTitle() string { + return e.Title +} + +func (e *HTTPError) GetDetail() string { + return e.Detail +} + +func (e *HTTPError) GetInstance() string { + return e.Instance +} + +func ErrorHandler( + handler func(w http.ResponseWriter, r *http.Request) error, +) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if err := handler(w, r); err != nil { + renderError(w, r, err) + } + } +} + +func renderError(w http.ResponseWriter, r *http.Request, err error) { + var renderer rendererError + if !errors.As(err, &renderer) { + logger := hlog.GetLoggerFromContext(r.Context()) + logger.Error(err.Error()) + + renderError(w, r, NewInternalServerError(err)) + return + } + + if renderingError := render.Render(w, r, renderer); renderingError != nil { + renderError(w, r, NewInternalServerError(renderingError)) + } +} + +type rendererError interface { + error + Render(http.ResponseWriter, *http.Request) error +} + +func NewValidationError(detail string) error { + return &HTTPError{ + Type: ValueErrorType, + Status: http.StatusBadRequest, + Detail: fmt.Sprintf("Validation Error: %s", detail), + } +} + +func NewNotFoundError(detail string) error { + return &HTTPError{ + Type: NotFoundErrorType, + Status: http.StatusNotFound, + Detail: fmt.Sprintf("Not Found Error: %s", detail), + } +} diff --git a/app/main.go b/app/main.go index 705865a..7667ec2 100644 --- a/app/main.go +++ b/app/main.go @@ -1,41 +1,35 @@ package main import ( + "log/slog" "net/http" "os" + "time" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" + "github.com/lmittmann/tint" - "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/entity" "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/repositories" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/repositories" "github.com/F0rzend/simple-go-webserver/app/server" ) func main() { - logger := log. - Output(zerolog.ConsoleWriter{Out: os.Stderr}). - With().Caller(). - Logger() + logger := slog.New(tint.NewHandler(os.Stderr, nil)) address := getEnv("ADDRESS", ":8080") - logger.Info().Msgf("starting endpoints on %s", address) + logger.Info("run api", slog.String("address", address)) userRepository := userrepositories.NewMemoryUserRepository() - bitcoinRepository, err := bitcoinrepositories.NewMemoryBTCRepository(bitcoinentity.MustNewUSD(100)) - if err != nil { - log.Fatal().Err(err).Send() - } + bitcoinRepository := bitcoinrepositories.NewMemoryBTCRepository() apiServer := server.NewServer(userRepository, bitcoinRepository, bitcoinRepository) - if err := http.ListenAndServe( - address, - apiServer.GetHTTPHandler(&logger), - ); err != nil { - log.Error().Err(err).Send() + srv := &http.Server{ + Addr: address, + Handler: apiServer.GetHTTPHandler(logger), + ReadHeaderTimeout: 1 * time.Second, } + logger.Error("server stopped", srv.ListenAndServe()) } func getEnv(key, defaultValue string) string { diff --git a/app/server/server.go b/app/server/server.go index 1214983..98db399 100644 --- a/app/server/server.go +++ b/app/server/server.go @@ -1,20 +1,19 @@ package server import ( + "log/slog" "net/http" "strconv" - "time" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/rs/zerolog" - "github.com/rs/zerolog/hlog" + "github.com/F0rzend/simple-go-webserver/app/common" "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/handlers" "github.com/F0rzend/simple-go-webserver/app/aggregate/bitcoin/service" - "github.com/F0rzend/simple-go-webserver/app/aggregate/user/handlers" "github.com/F0rzend/simple-go-webserver/app/aggregate/user/service" + "github.com/F0rzend/simple-go-webserver/pkg/hlog" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" ) type Server struct { @@ -46,7 +45,7 @@ func NewServer( } func (s *Server) GetHTTPHandler( - logger *zerolog.Logger, + logger *slog.Logger, ) http.Handler { r := chi.NewRouter() @@ -54,35 +53,27 @@ func (s *Server) GetHTTPHandler( middleware.Recoverer, middleware.AllowContentType("application/json"), - hlog.NewHandler(*logger), - hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) { - hlog.FromRequest(r).Info(). - Str("method", r.Method). - Stringer("url", r.URL). - Int("status", status). - Int("size", size). - Dur("duration", duration). - Send() - }), - hlog.RemoteAddrHandler("ip"), - hlog.RequestIDHandler("req_id", "Request-Id"), + hlog.LoggerInjectionMiddleware(logger), + hlog.RequestID, + hlog.RequestMiddleware, ) r.Route("/users", func(r chi.Router) { - r.Post("/", s.userRoutes.CreateUser) + r.Post("/", common.ErrorHandler(s.userRoutes.CreateUser)) r.Route("/{id}", func(r chi.Router) { - r.Get("/", s.userRoutes.GetUser) - r.Put("/", s.userRoutes.UpdateUser) - r.Get("/balance", s.userRoutes.GetUserBalance) + r.Get("/", common.ErrorHandler(s.userRoutes.GetUser)) + r.Put("/", common.ErrorHandler(s.userRoutes.UpdateUser)) + r.Get("/balance", common.ErrorHandler(s.userRoutes.GetUserBalance)) - r.Post("/usd", s.userRoutes.ChangeUSDBalance) - r.Post("/bitcoin", s.userRoutes.ChangeBTCBalance) + r.Post("/usd", common.ErrorHandler(s.userRoutes.ChangeUSDBalance)) + r.Post("/btc", common.ErrorHandler(s.userRoutes.ChangeBTCBalance)) }) }) + r.Route("/bitcoin", func(r chi.Router) { - r.Get("/", s.bitcoinRoutes.GetBTCPrice) - r.Put("/", s.bitcoinRoutes.SetBTCPrice) + r.Get("/", common.ErrorHandler(s.bitcoinRoutes.GetBTCPrice)) + r.Put("/", common.ErrorHandler(s.bitcoinRoutes.SetBTCPrice)) }) return r diff --git a/app/tests/request_factory.go b/app/tests/request_factory.go new file mode 100644 index 0000000..5035d51 --- /dev/null +++ b/app/tests/request_factory.go @@ -0,0 +1,32 @@ +package tests + +import ( + "context" + "io" + "log/slog" + "net/http" + "os" + "testing" + + "github.com/F0rzend/simple-go-webserver/pkg/hlog" + + "github.com/gavv/httpexpect" +) + +type requestFactory struct { + t *testing.T +} + +func newRequestFactoryWithTestLogger(t *testing.T) httpexpect.RequestFactory { + return &requestFactory{t: t} +} + +func (rf *requestFactory) NewRequest(method, target string, body io.Reader) (*http.Request, error) { + handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + logger := slog.New(handler) + ctx := hlog.ContextWithLogger(context.Background(), logger) + + return http.NewRequestWithContext(ctx, method, target, body) +} diff --git a/app/tests/utils.go b/app/tests/utils.go index 01ad594..12c774a 100644 --- a/app/tests/utils.go +++ b/app/tests/utils.go @@ -1,67 +1,44 @@ package tests import ( - "bytes" - "encoding/json" - "fmt" - "io" "net/http" - "net/http/httptest" "testing" + "github.com/F0rzend/simple-go-webserver/app/common" + "github.com/gavv/httpexpect" "github.com/stretchr/testify/assert" ) -func AssertStatus( - t *testing.T, - w http.ResponseWriter, - r *http.Request, - expectedStatus int, -) { - t.Helper() +type ErrorChecker = func(assert.TestingT, error, ...any) bool - recorder, ok := w.(*httptest.ResponseRecorder) - if !ok { - t.Fatal("writer is not *httptest.ResponseRecorder") - } - - body, err := io.ReadAll(r.Body) - if err != nil { - t.Fatal(err) - } - actual := recorder.Code +func HTTPExpect(t *testing.T, handler http.HandlerFunc) *httpexpect.Expect { + return httpexpect.WithConfig(httpexpect.Config{ + RequestFactory: newRequestFactoryWithTestLogger(t), + Reporter: httpexpect.NewAssertReporter(t), + Client: &http.Client{ + Transport: httpexpect.NewBinder(handler), + }, + }) +} - if actual != expectedStatus { - errorMessage := fmt.Sprintf("Expected HTTP status code %d but received %d", expectedStatus, actual) - errorMessage += fmt.Sprintf("\n\nURL: %s", r.URL.String()) +type tHelper interface { + Helper() +} - if len(body) != 0 { - errorMessage += fmt.Sprintf("\nRequest: %s", body) - } - if recorder.Body.Len() > 0 { - errorMessage += fmt.Sprintf("\nResponse: %s", recorder.Body.String()) +func AssertErrorFlag(flag common.Flag) ErrorChecker { + return func(t assert.TestingT, err error, _ ...any) bool { + if h, ok := t.(tHelper); ok { + h.Helper() } - assert.Fail(t, errorMessage) - } -} -func PrepareHandlerArgs( - t *testing.T, - method string, - path string, - body any, -) (*httptest.ResponseRecorder, *http.Request) { - t.Helper() + if !assert.Error(t, err) { + return false + } - requestBody, err := json.Marshal(body) - if err != nil { - t.Fatal(err) + var flagged interface { + Flag() common.Flag + } + assert.ErrorAs(t, err, &flagged) + return assert.Equal(t, flag, flagged.Flag()) } - - r := httptest.NewRequest(method, path, bytes.NewReader(requestBody)) - r.Header.Set("Content-Type", "application/json") - - w := httptest.NewRecorder() - - return w, r } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0fc1c45..54eedfd 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -304,7 +304,7 @@ paths: 500: description: Error on server side - /users/{id}/bitcoin: + /users/{id}/btc: post: tags: - Finances diff --git a/go.mod b/go.mod index da1f92f..dfe8c51 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,76 @@ module github.com/F0rzend/simple-go-webserver -go 1.19 +go 1.21 require ( - github.com/go-chi/chi/v5 v5.0.7 - github.com/go-chi/render v1.0.2 - github.com/rs/zerolog v1.28.0 + github.com/gavv/httpexpect v2.0.0+incompatible + github.com/go-chi/chi/v5 v5.0.10 + github.com/go-chi/render v1.0.3 + github.com/lmittmann/tint v1.0.1 + github.com/rs/xid v1.5.0 github.com/shopspring/decimal v1.3.1 - github.com/stretchr/testify v1.8.0 + github.com/stretchr/testify v1.8.4 + github.com/testcontainers/testcontainers-go v0.23.0 ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Microsoft/hcsshim v0.11.0 // indirect github.com/ajg/form v1.5.1 // indirect + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/containerd/containerd v1.7.5 // indirect + github.com/cpuguy83/dockercfg v0.3.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/docker/distribution v2.8.2+incompatible // indirect + github.com/docker/docker v24.0.6+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072 // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.3.1 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/imkira/go-interpol v1.1.0 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/moul/http2curl v1.0.0 // indirect + github.com/onsi/ginkgo v1.16.5 // indirect + github.com/onsi/gomega v1.27.10 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc4 // indirect + github.com/opencontainers/runc v1.1.9 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rs/xid v1.4.0 // indirect - golang.org/x/sys v0.1.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/smartystreets/goconvey v1.8.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.49.0 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect + github.com/yudai/gojsondiff v1.0.0 // indirect + github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect + github.com/yudai/pp v2.0.1+incompatible // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/mod v0.12.0 // indirect + golang.org/x/net v0.15.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/tools v0.13.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect + google.golang.org/grpc v1.58.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c4781e4..11c1552 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,259 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.11.0 h1:7EFNIY4igHEXUdj1zXgAyU3fLc7QfOKHbkldRVTBdiM= +github.com/Microsoft/hcsshim v0.11.0/go.mod h1:OEthFdQv/AD2RAdzR6Mm1N1KPCztGKDurW1Z8b8VGMM= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= -github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/containerd v1.7.5 h1:i9T9XpAWMe11BHMN7pu1BZqOGjXaKTPyz2v+KYOZgkY= +github.com/containerd/containerd v1.7.5/go.mod h1:ieJNCSzASw2shSGYLHx8NAE7WsZ/gEigo5fQ78W5Zvw= +github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= +github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= -github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg= -github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.6+incompatible h1:hceabKCtUgDqPu+qm0NgsaXf28Ljf4/pWFL7xjWWDgE= +github.com/docker/docker v24.0.6+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072 h1:DddqAaWDpywytcG8w/qoQ5sAN8X12d3Z3koB0C3Rxsc= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= +github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= +github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lmittmann/tint v1.0.1 h1:qBZR+K8XzaVFvhHvvZ7t63oQ3FuzDKa6TuCRujASxK8= +github.com/lmittmann/tint v1.0.1/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc4 h1:oOxKUJWnFC4YGHCCMNql1x4YaDfYBTS5Y4x/Cgeo1E0= +github.com/opencontainers/image-spec v1.1.0-rc4/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/opencontainers/runc v1.1.9 h1:XR0VIHTGce5eWPkaPesqTBrhW2yAcaraWfsEalNwQLM= +github.com/opencontainers/runc v1.1.9/go.mod h1:CbUumNnWCuTGFukNXahoo/RFBZvDAgRh/smNYNOhA50= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= -github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= -github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/testcontainers/testcontainers-go v0.23.0 h1:ERYTSikX01QczBLPZpqsETTBO7lInqEP349phDOVJVs= +github.com/testcontainers/testcontainers-go v0.23.0/go.mod h1:3gzuZfb7T9qfcH2pHpV4RLlWrPjeWNQah6XlYQ32c4I= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.49.0 h1:9FdvCpmxB74LH4dPb7IJ1cOSsluR07XG3I1txXWwJpE= +github.com/valyala/fasthttp v1.49.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/grpc v1.58.0 h1:32JY8YpPMSR45K+c3o6b8VL73V+rR8k+DeMIr4vRH8o= +google.golang.org/grpc v1.58.0/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/pkg/hlog/context.go b/pkg/hlog/context.go new file mode 100644 index 0000000..1566837 --- /dev/null +++ b/pkg/hlog/context.go @@ -0,0 +1,21 @@ +package hlog + +import ( + "context" + "log/slog" +) + +type loggerContextKey struct{} + +func ContextWithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, loggerContextKey{}, logger) +} + +func GetLoggerFromContext(ctx context.Context) *slog.Logger { + logger, ok := ctx.Value(loggerContextKey{}).(*slog.Logger) + if !ok { + return slog.New(discardHandler{}) + } + + return logger +} diff --git a/pkg/hlog/discard_handler.go b/pkg/hlog/discard_handler.go new file mode 100644 index 0000000..4e84157 --- /dev/null +++ b/pkg/hlog/discard_handler.go @@ -0,0 +1,27 @@ +package hlog + +import ( + "context" + "log/slog" +) + +var _ slog.Handler = (*discardHandler)(nil) + +type discardHandler struct{} + +func (discardHandler) Enabled(_ context.Context, _ slog.Level) bool { + return false +} + +//nolint:hugeParam +func (discardHandler) Handle(_ context.Context, _ slog.Record) error { + return nil +} + +func (discardHandler) WithAttrs(_ []slog.Attr) slog.Handler { + return discardHandler{} +} + +func (discardHandler) WithGroup(_ string) slog.Handler { + return discardHandler{} +} diff --git a/pkg/hlog/middlewares.go b/pkg/hlog/middlewares.go new file mode 100644 index 0000000..3ff1a31 --- /dev/null +++ b/pkg/hlog/middlewares.go @@ -0,0 +1,73 @@ +package hlog + +import ( + "log/slog" + "net/http" + "time" + + "github.com/rs/xid" +) + +type ( + HTTPMiddleware = func(next http.Handler) http.Handler +) + +func LoggerInjectionMiddleware(logger *slog.Logger) HTTPMiddleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := ContextWithLogger(r.Context(), logger) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + }) + } +} + +func RequestMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + logger := GetLoggerFromContext(ctx) + + requestGroup := slog.Group( + "request", + slog.String("method", r.Method), + slog.String("url", r.URL.String()), + slog.String("remote_addr", r.RemoteAddr), + ) + + start := time.Now() + + ww := wrapWriter(w) + next.ServeHTTP(ww, r) + + responseGroup := slog.Group( + "response", + slog.Int("status_code", ww.StatusCode()), + slog.String("body", string(ww.Body())), + slog.Duration("duration", time.Since(start)), + ) + + logger.InfoContext(ctx, "request", requestGroup, responseGroup) + }) +} + +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + logger := GetLoggerFromContext(ctx) + + requestID, ok := GetRequestIDFromContext(ctx) + if !ok { + requestID = xid.New() + ctx = ContextWithRequestID(ctx, requestID) + } + + logger = logger.With(slog.String("request_id", requestID.String())) + ctx = ContextWithLogger(ctx, logger) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/hlog/requiest_id.go b/pkg/hlog/requiest_id.go new file mode 100644 index 0000000..f2cc792 --- /dev/null +++ b/pkg/hlog/requiest_id.go @@ -0,0 +1,19 @@ +package hlog + +import ( + "context" + + "github.com/rs/xid" +) + +type requestIDKey struct{} + +func ContextWithRequestID(ctx context.Context, requestID xid.ID) context.Context { + return context.WithValue(ctx, requestIDKey{}, requestID) +} + +func GetRequestIDFromContext(ctx context.Context) (xid.ID, bool) { + requestID, ok := ctx.Value(requestIDKey{}).(xid.ID) + + return requestID, ok +} diff --git a/pkg/hlog/wrapped_writer.go b/pkg/hlog/wrapped_writer.go new file mode 100644 index 0000000..f6b8e9a --- /dev/null +++ b/pkg/hlog/wrapped_writer.go @@ -0,0 +1,44 @@ +package hlog + +import "net/http" + +type WrappedWriter struct { + http.ResponseWriter + + wroteHeader bool + + statusCode int + body []byte +} + +func wrapWriter(w http.ResponseWriter) *WrappedWriter { + return &WrappedWriter{ResponseWriter: w} +} + +func (ww *WrappedWriter) WriteHeader(statusCode int) { + if ww.wroteHeader { + return + } + + ww.statusCode = statusCode + ww.wroteHeader = true + ww.ResponseWriter.WriteHeader(statusCode) +} + +func (ww *WrappedWriter) Write(b []byte) (int, error) { + if !ww.wroteHeader { + ww.WriteHeader(http.StatusOK) + } + + ww.body = append(ww.body, b...) + + return ww.ResponseWriter.Write(b) +} + +func (ww *WrappedWriter) StatusCode() int { + return ww.statusCode +} + +func (ww *WrappedWriter) Body() []byte { + return ww.body +} diff --git a/tests/bitcoin_test.go b/tests/bitcoin_test.go new file mode 100644 index 0000000..e67ab7a --- /dev/null +++ b/tests/bitcoin_test.go @@ -0,0 +1,29 @@ +package tests + +import ( + "net/http" +) + +func (s *TestSuite) TestBitcoinPriceManipulations() { + const endpoint = "/bitcoin" + + s.e().GET(endpoint). + Expect(). + Status(http.StatusOK). + JSON().Object(). + Value("btc").Object().ValueEqual("price", "100") + + s.e().PUT(endpoint). + WithJSON(JSON{ + "price": 1, + }). + Expect(). + Status(http.StatusNoContent). + ContentType(ContentType, Encoding) + + s.e().GET(endpoint). + Expect(). + Status(http.StatusOK). + JSON().Object(). + Value("btc").Object().ValueEqual("price", "1") +} diff --git a/tests/entrypoint_test.go b/tests/entrypoint_test.go new file mode 100644 index 0000000..c3cba1a --- /dev/null +++ b/tests/entrypoint_test.go @@ -0,0 +1,40 @@ +package tests + +import ( + "context" + "testing" + + "github.com/gavv/httpexpect" + "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/suite" +) + +type TestSuite struct { + suite.Suite + + e func() *httpexpect.Expect + tearDownSuite func() +} + +func (s *TestSuite) SetupSuite() { + ctx := context.Background() + + app, err := setupTestEnvironment(ctx) + require.NoError(s.T(), err) + + s.e = func() *httpexpect.Expect { + return httpexpect.New(s.T(), app.URI) + } + s.tearDownSuite = func() { + app.close(ctx) + } +} + +func (s *TestSuite) TearDownSuite() { + s.tearDownSuite() +} + +func TestApplication(t *testing.T) { + suite.Run(t, new(TestSuite)) +} diff --git a/tests/misc.go b/tests/misc.go new file mode 100644 index 0000000..a2b9f3e --- /dev/null +++ b/tests/misc.go @@ -0,0 +1,9 @@ +package tests + +type JSON map[string]any + +const ( + LocationHeader = "Location" + ContentType = "application/json" + Encoding = "" +) diff --git a/tests/setup.go b/tests/setup.go new file mode 100644 index 0000000..ccec60b --- /dev/null +++ b/tests/setup.go @@ -0,0 +1,63 @@ +package tests + +import ( + "context" + "fmt" + + containers "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + DockerContext = "../" + Dockerfile = "Dockerfile" + ContainerName = "testcontainers_app" +) + +type applicationContainer struct { + containers.Container + + URI string + close func(ctx context.Context) +} + +func setupTestEnvironment(ctx context.Context) (*applicationContainer, error) { + container, err := containers.GenericContainer( + ctx, + containers.GenericContainerRequest{ + ContainerRequest: containers.ContainerRequest{ + FromDockerfile: containers.FromDockerfile{ + Context: DockerContext, + Dockerfile: Dockerfile, + PrintBuildLog: true, + }, + ExposedPorts: []string{"8080"}, + WaitingFor: wait.ForLog("run api"), + Name: ContainerName, + }, + Started: true, + Reuse: true, + }, + ) + if err != nil { + return nil, err + } + + host, err := container.Host(ctx) + if err != nil { + return nil, err + } + + port, err := container.MappedPort(ctx, "8080") + if err != nil { + return nil, err + } + + return &applicationContainer{ + Container: container, + URI: fmt.Sprintf("http://%s:%s", host, port.Port()), + close: func(ctx context.Context) { + container.Terminate(ctx) //nolint:errcheck + }, + }, nil +} diff --git a/tests/user_test.go b/tests/user_test.go new file mode 100644 index 0000000..38377ad --- /dev/null +++ b/tests/user_test.go @@ -0,0 +1,180 @@ +package tests + +import ( + "fmt" + "net/http" +) + +const ( + btcBalanceKey = "btc_balance" + usdBalanceKey = "usd_balance" +) + +func (s *TestSuite) TestUser() { + user := JSON{ + "name": "John Doe", + "username": "john1234", + "email": "john1234@mail.com", + } + + var userLocation string + + s.Run("create user", func() { + userLocation = s.e().POST("/users"). + WithJSON(user). + Expect(). + Status(http.StatusCreated). + ContentType(ContentType, Encoding). + Header(LocationHeader).Raw() + }) + + s.Run("get created user", func() { + s.e().GET(userLocation). + Expect(). + Status(http.StatusOK). + ContentType(ContentType, Encoding). + JSON().Object().ContainsMap(user) + }) + + s.Run("check balance", func() { + s.e().GET(userLocation). + Expect(). + Status(http.StatusOK). + ContentType(ContentType, Encoding). + JSON().Object(). + ValueEqual("btc_balance", "0"). + ValueEqual("usd_balance", "0") + }) +} + +func (s *TestSuite) TestUserUSDManipulation() { + user := JSON{ + "name": "John Doe", + "username": "john1234", + "email": "john1234@mail.com", + } + + userLocation := s.e().POST("/users"). + WithJSON(user). + Expect(). + Status(http.StatusCreated). + ContentType(ContentType, Encoding). + Header(LocationHeader).Raw() + + path := fmt.Sprintf("%s/%s", userLocation, "usd") + + s.Run("deposit 100 USD", func() { + s.e().POST(path). + WithJSON(JSON{ + "action": "deposit", + "amount": 100, + }). + Expect(). + Status(http.StatusNoContent). + ContentType(ContentType, Encoding) + + s.e().GET(userLocation). + Expect(). + Status(http.StatusOK). + ContentType(ContentType, Encoding). + JSON().Object(). + ValueEqual(btcBalanceKey, "0"). + ValueEqual(usdBalanceKey, "100") + }) + + s.Run("withdraw 50 USD", func() { + s.e().POST(path). + WithJSON(JSON{ + "action": "withdraw", + "amount": 50, + }). + Expect(). + Status(http.StatusNoContent). + ContentType(ContentType, Encoding) + + s.e().GET(userLocation). + Expect(). + Status(http.StatusOK). + ContentType(ContentType, Encoding). + JSON().Object(). + ValueEqual(btcBalanceKey, "0"). + ValueEqual(usdBalanceKey, "50") + }) +} + +func (s *TestSuite) TestUserBTCManipulation() { + user := JSON{ + "name": "John Doe", + "username": "john1234", + "email": "john1234@mail.com", + } + + s.e().PUT("/bitcoin"). + WithJSON(JSON{ + "price": 100, + }). + Expect(). + Status(http.StatusNoContent). + ContentType(ContentType, Encoding) + + userLocation := s.e().POST("/users"). + WithJSON(user). + Expect(). + Status(http.StatusCreated). + ContentType(ContentType, Encoding). + Header(LocationHeader).Raw() + + usdPath := fmt.Sprintf("%s/%s", userLocation, "usd") + btcPath := fmt.Sprintf("%s/%s", userLocation, "btc") + + s.e().POST(usdPath). + WithJSON(JSON{ + "action": "deposit", + "amount": 100, + }). + Expect(). + Status(http.StatusNoContent). + ContentType(ContentType, Encoding) + + s.e().POST(btcPath). + WithJSON(JSON{ + "action": "buy", + "amount": 1, + }). + Expect(). + Status(http.StatusNoContent). + ContentType(ContentType, Encoding) + + s.e().GET(userLocation). + Expect(). + Status(http.StatusOK). + ContentType(ContentType, Encoding). + JSON().Object(). + ValueEqual(btcBalanceKey, "1"). + ValueEqual(usdBalanceKey, "0") + + s.e().PUT("/bitcoin"). + WithJSON(JSON{ + "price": 200, + }). + Expect(). + Status(http.StatusNoContent). + ContentType(ContentType, Encoding) + + s.e().POST(btcPath). + WithJSON(JSON{ + "action": "sell", + "amount": 0.5, + }). + Expect(). + Status(http.StatusNoContent). + ContentType(ContentType, Encoding) + + s.e().GET(userLocation). + Expect(). + Status(http.StatusOK). + ContentType(ContentType, Encoding). + JSON().Object(). + ValueEqual(btcBalanceKey, "0.5"). + ValueEqual(usdBalanceKey, "100") +}