From 1cbffb31c69c42b7007ce0d454cc110522b42982 Mon Sep 17 00:00:00 2001 From: cdamian <17934949+cdamian@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:53:31 +0300 Subject: [PATCH 1/3] investor: Retrieve active and created loans when authenticating --- http/auth/access/errors.go | 1 + http/auth/access/investor.go | 65 ++++++++++--- pallets/loans/api.go | 174 +++++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 12 deletions(-) diff --git a/http/auth/access/errors.go b/http/auth/access/errors.go index f7a639887..65985f7ca 100644 --- a/http/auth/access/errors.go +++ b/http/auth/access/errors.go @@ -18,4 +18,5 @@ const ( ErrIdentityNotFound = errors.Error("identity not found") ErrInvalidProxyType = errors.Error("invalid proxy type") ErrInvalidAuthorizationHeader = errors.Error("invalid authorization header") + ErrLoanCollateralNotFound = errors.Error("loan collateral not found") ) diff --git a/http/auth/access/investor.go b/http/auth/access/investor.go index 82f96c986..63158527f 100644 --- a/http/auth/access/investor.go +++ b/http/auth/access/investor.go @@ -2,10 +2,12 @@ package access import ( "bytes" + "errors" "fmt" "net/http" "strconv" + loanTypes "github.com/centrifuge/chain-custom-types/pkg/loans" "github.com/centrifuge/go-substrate-rpc-client/v4/types" authToken "github.com/centrifuge/pod/http/auth/token" "github.com/centrifuge/pod/http/coreapi" @@ -17,6 +19,17 @@ import ( logging "github.com/ipfs/go-log" ) +type InvestorAccessParams struct { + AssetID []byte + PoolID types.U64 + LoanID types.U64 +} + +type LoanCollateral struct { + Asset loanTypes.Asset + Borrower types.AccountID +} + type investorAccessValidator struct { log *logging.ZapEventLogger loansAPI loans.API @@ -65,12 +78,6 @@ func (i *investorAccessValidator) Validate(req *http.Request, token *authToken.J return i.validateDocument(params) } -type InvestorAccessParams struct { - AssetID []byte - PoolID types.U64 - LoanID types.U64 -} - func getInvestorAccessParams(req *http.Request) (*InvestorAccessParams, error) { poolID, err := strconv.Atoi(req.URL.Query().Get(coreapi.PoolIDQueryParam)) @@ -120,17 +127,17 @@ func (i *investorAccessValidator) validatePoolPermissions( } func (i *investorAccessValidator) validateDocument(params *InvestorAccessParams) (*types.AccountID, error) { - loan, err := i.loansAPI.GetCreatedLoan(params.PoolID, params.LoanID) + collateral, err := i.getLoanCollateral(params) if err != nil { - i.log.Errorf("Couldn't get loan: %s", err) + i.log.Errorf("Couldn't get collateral for loan: %s", err) - return nil, ErrCreatedLoanRetrieval + return nil, err } documentID, err := i.uniquesAPI.GetItemAttribute( - loan.Info.Collateral.CollectionID, - loan.Info.Collateral.ItemID, + collateral.Asset.CollectionID, + collateral.Asset.ItemID, []byte(nftv3.DocumentIDAttributeKey), ) @@ -146,5 +153,39 @@ func (i *investorAccessValidator) validateDocument(params *InvestorAccessParams) return nil, ErrDocumentIDMismatch } - return &loan.Borrower, nil + return &collateral.Borrower, nil +} + +func (i *investorAccessValidator) getLoanCollateral(params *InvestorAccessParams) (*LoanCollateral, error) { + activeLoan, err := i.loansAPI.GetActiveLoan(params.PoolID, params.LoanID) + + if err != nil && !errors.Is(err, loans.ErrActiveLoanNotFound) { + i.log.Errorf("Couldn't get active loan: %s", err) + + return nil, err + } + + if activeLoan != nil { + return &LoanCollateral{ + Asset: activeLoan.Collateral, + Borrower: activeLoan.Borrower, + }, nil + } + + createdLoan, err := i.loansAPI.GetCreatedLoan(params.PoolID, params.LoanID) + + if err != nil && !errors.Is(err, loans.ErrCreatedLoanNotFound) { + i.log.Errorf("Couldn't get created loan: %s", err) + + return nil, err + } + + if createdLoan != nil { + return &LoanCollateral{ + Asset: createdLoan.Info.Collateral, + Borrower: createdLoan.Borrower, + }, nil + } + + return nil, ErrLoanCollateralNotFound } diff --git a/pallets/loans/api.go b/pallets/loans/api.go index a7a1e9dcb..07d845d61 100644 --- a/pallets/loans/api.go +++ b/pallets/loans/api.go @@ -19,11 +19,17 @@ const ( ErrLoanIDEncoding = errors.Error("loan ID encoding") ErrCreatedLoanRetrieval = errors.Error("created loan retrieval") ErrCreatedLoanNotFound = errors.Error("created loan not found") + ErrActiveLoansRetrieval = errors.Error("active loans retrieval") + ErrActiveLoanNotFound = errors.Error("active loan not found") + ErrClosedLoanRetrieval = errors.Error("closed loan retrieval") + ErrClosedLoanNotFound = errors.Error("closed loan not found") ) const ( PalletName = "Loans" CreatedLoanStorageName = "CreatedLoan" + ActiveLoansStorageName = "ActiveLoans" + ClosedLoanStorageName = "ClosedLoan" ) type CreatedLoanStorageEntry struct { @@ -31,10 +37,43 @@ type CreatedLoanStorageEntry struct { Borrower types.AccountID } +type ActiveLoanStorageEntry struct { + LoanID types.U64 + ActiveLoan ActiveLoan +} + +type ActiveLoan struct { + Schedule loans.RepaymentSchedule + Collateral loans.Asset + Restrictions loans.LoanRestrictions + Borrower types.AccountID + WriteOffPercentage types.U128 + OriginationDate types.U64 + Pricing loans.Pricing + TotalBorrowed types.U128 + TotalRepaid RepaidAmount + RepaymentsOnScheduleUntil types.U64 +} + +type RepaidAmount struct { + Principal types.U128 + Interest types.U128 + Unscheduled types.U128 +} + +type ClosedLoan struct { + ClosedAt types.U32 + Info loans.LoanInfo + TotalBorrowed types.U128 + TotalRepaid RepaidAmount +} + //go:generate mockery --name API --structname APIMock --filename api_mock.go --inpackage type API interface { GetCreatedLoan(poolID types.U64, loanID types.U64) (*CreatedLoanStorageEntry, error) + GetActiveLoan(poolID types.U64, loanID types.U64) (*ActiveLoan, error) + GetClosedLoan(poolID types.U64, loanID types.U64) (*ClosedLoan, error) } type api struct { @@ -112,3 +151,138 @@ func (a *api) GetCreatedLoan(poolID types.U64, loanID types.U64) (*CreatedLoanSt return &createdLoan, nil } + +func (a *api) GetActiveLoan(poolID types.U64, loanID types.U64) (*ActiveLoan, error) { + err := validation.Validate( + validation.NewValidator(poolID, validation.U64ValidationFn), + ) + + if err != nil { + log.Errorf("Validation error: %s", err) + + return nil, err + } + + meta, err := a.centAPI.GetMetadataLatest() + + if err != nil { + log.Errorf("Couldn't retrieve latest metadata: %s", err) + + return nil, errors.ErrMetadataRetrieval + } + + encodedPoolID, err := codec.Encode(poolID) + + if err != nil { + log.Errorf("Couldn't encode pool ID: %s", err) + + return nil, ErrPoolIDEncoding + } + + storageKey, err := types.CreateStorageKey( + meta, + PalletName, + ActiveLoansStorageName, + encodedPoolID, + ) + + if err != nil { + log.Errorf("Couldn't create storage key: %s", err) + + return nil, errors.ErrStorageKeyCreation + } + + var activeLoans []ActiveLoanStorageEntry + + ok, err := a.centAPI.GetStorageLatest(storageKey, &activeLoans) + + if err != nil { + log.Errorf("Couldn't retrieve active loans from storage: %s", err) + + return nil, ErrActiveLoansRetrieval + } + + if !ok { + log.Errorf("Active loans not found for pool ID %d", poolID) + + return nil, ErrActiveLoanNotFound + } + + for _, activeLoan := range activeLoans { + if activeLoan.LoanID == loanID { + return &activeLoan.ActiveLoan, nil + } + } + + log.Errorf("Loan with ID - %d not found in the active loans of pool - %d", loanID, poolID) + + return nil, ErrActiveLoanNotFound +} + +func (a *api) GetClosedLoan(poolID types.U64, loanID types.U64) (*ClosedLoan, error) { + err := validation.Validate( + validation.NewValidator(poolID, validation.U64ValidationFn), + ) + + if err != nil { + log.Errorf("Validation error: %s", err) + + return nil, err + } + + meta, err := a.centAPI.GetMetadataLatest() + + if err != nil { + log.Errorf("Couldn't retrieve latest metadata: %s", err) + + return nil, errors.ErrMetadataRetrieval + } + + encodedPoolID, err := codec.Encode(poolID) + + if err != nil { + log.Errorf("Couldn't encode pool ID: %s", err) + + return nil, ErrPoolIDEncoding + } + + encodedLoanID, err := codec.Encode(loanID) + + if err != nil { + log.Errorf("Couldn't encode loan ID: %s", err) + + return nil, ErrLoanIDEncoding + } + + storageKey, err := types.CreateStorageKey( + meta, + PalletName, + ClosedLoanStorageName, + encodedPoolID, + encodedLoanID, + ) + + if err != nil { + log.Errorf("Couldn't create storage key: %s", err) + + return nil, errors.ErrStorageKeyCreation + } + + var closedLoan ClosedLoan + + ok, err := a.centAPI.GetStorageLatest(storageKey, &closedLoan) + + if err != nil { + log.Errorf("Couldn't retrieve closed loan from storage: %s", err) + + return nil, ErrClosedLoanRetrieval + } + + if !ok { + log.Errorf("Closed loan not found for pool ID %d and loan ID %d", poolID, loanID) + + return nil, ErrClosedLoanNotFound + } + + return &closedLoan, nil +} From 613ca54a5b85beb23979c656b6f08c60c995809b Mon Sep 17 00:00:00 2001 From: cdamian <17934949+cdamian@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:54:03 +0300 Subject: [PATCH 2/3] CI: Enable build for branch --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bea8cab45..a8c814e69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,7 @@ # build workflow builds docker images, pushes images to docker hub and updates swagger API on: push: - branches: [main] + branches: [main, invest-endpoint-loan-retrieval-fix] name: Build jobs: build: From 9ba6a05c72d7ffd87293473284e8d6c6ee3de874 Mon Sep 17 00:00:00 2001 From: cdamian <17934949+cdamian@users.noreply.github.com> Date: Wed, 10 Apr 2024 16:09:56 +0300 Subject: [PATCH 3/3] loans: Update mocks and add more unit tests --- .github/workflows/build.yml | 2 +- go.mod | 2 +- go.sum | 4 +- http/auth/access/errors.go | 1 - http/auth/access/investor_test.go | 165 ++++++++++-- pallets/loans/api.go | 38 +-- pallets/loans/api_mock.go | 47 ++++ pallets/loans/api_test.go | 399 +++++++++++++++++++++++++++++- 8 files changed, 598 insertions(+), 60 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a8c814e69..bea8cab45 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,7 @@ # build workflow builds docker images, pushes images to docker hub and updates swagger API on: push: - branches: [main, invest-endpoint-loan-retrieval-fix] + branches: [main] name: Build jobs: build: diff --git a/go.mod b/go.mod index c2f436a16..ad3eec0b5 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/ChainSafe/go-schnorrkel v1.0.0 github.com/Masterminds/semver v1.5.0 github.com/centrifuge/centrifuge-protobufs v1.0.0 - github.com/centrifuge/chain-custom-types v1.0.8 + github.com/centrifuge/chain-custom-types v1.0.9 github.com/centrifuge/go-substrate-rpc-client/v4 v4.1.0 github.com/centrifuge/gocelery/v2 v2.0.0-20221101190423-3b07af1b49a6 github.com/centrifuge/precise-proofs v1.0.0 diff --git a/go.sum b/go.sum index 2ef5cd099..dc5c4e30a 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,8 @@ github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QH github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/centrifuge/centrifuge-protobufs v1.0.0 h1:ZPg0XpkTrGrjQu8scXjMGs7jjqsWPiXmOXdV/bz30ng= github.com/centrifuge/centrifuge-protobufs v1.0.0/go.mod h1:VL6mcnK6vTRiFljHP39J0WBI3Uu5BHQjhdFkCxY9/9I= -github.com/centrifuge/chain-custom-types v1.0.8 h1:JcXQNzjzs1y/xEBK23XlRJeD1OxLZByfnwstQkORuIg= -github.com/centrifuge/chain-custom-types v1.0.8/go.mod h1:kSUJ3O83vaLutJIiaEfqwn3lfTaisn/G/baS8WrycTg= +github.com/centrifuge/chain-custom-types v1.0.9 h1:utkYu/8Tgze6xktHMZ9wgcDHXUsM2yWuwSHL2YqfZ+8= +github.com/centrifuge/chain-custom-types v1.0.9/go.mod h1:kSUJ3O83vaLutJIiaEfqwn3lfTaisn/G/baS8WrycTg= github.com/centrifuge/go-merkle v0.0.0-20190727075423-0ac78bbbc01b h1:TPvvMcGAc3TVBVgQ4XYYEWTXxYls8YuylZ8JzrVxPzc= github.com/centrifuge/go-merkle v0.0.0-20190727075423-0ac78bbbc01b/go.mod h1:0voJY6Qzxvr2S0LeDSFQiCnJzGq5gORg2SwCmn8602I= github.com/centrifuge/go-substrate-rpc-client/v4 v4.1.0 h1:GEvub7kU5YFAcn5A2uOo4AZSM1/cWZCOvfu7E3gQmK8= diff --git a/http/auth/access/errors.go b/http/auth/access/errors.go index 65985f7ca..1f21e0aa8 100644 --- a/http/auth/access/errors.go +++ b/http/auth/access/errors.go @@ -13,7 +13,6 @@ const ( ErrInvestorAccessParamsRetrieval = errors.Error("investor access params retrieval") ErrDocumentIDRetrieval = errors.Error("document ID retrieval") ErrDocumentIDMismatch = errors.Error("document IDs do not match") - ErrCreatedLoanRetrieval = errors.Error("created loan retrieval") ErrNoValidationServiceForPath = errors.Error("no validator service for request path") ErrIdentityNotFound = errors.Error("identity not found") ErrInvalidProxyType = errors.Error("invalid proxy type") diff --git a/http/auth/access/investor_test.go b/http/auth/access/investor_test.go index bad12b932..aad809950 100644 --- a/http/auth/access/investor_test.go +++ b/http/auth/access/investor_test.go @@ -27,7 +27,7 @@ import ( "github.com/vedhavyas/go-subkey" ) -func TestInvestorAccessValidator_Validate(t *testing.T) { +func TestInvestorAccessValidator_Validate_WithActiveLoan(t *testing.T) { loansAPIMock := loans.NewAPIMock(t) permissionsAPIMock := permissions.NewAPIMock(t) uniquesAPIMock := uniques.NewAPIMock(t) @@ -74,6 +74,83 @@ func TestInvestorAccessValidator_Validate(t *testing.T) { collectionID := types.U64(rand.Uint32()) itemID := types.NewU128(*big.NewInt(rand.Int63())) + activeLoan := &loanTypes.ActiveLoan{ + Collateral: loanTypes.Asset{ + CollectionID: collectionID, + ItemID: itemID, + }, + Borrower: *borrowerAccountID, + } + + loansAPIMock.On("GetActiveLoan", poolID, loanID). + Return(activeLoan, nil). + Once() + + uniquesAPIMock.On( + "GetItemAttribute", + collectionID, + itemID, + []byte(nftv3.DocumentIDAttributeKey), + ). + Return([]byte(documentID), nil). + Once() + + res, err := investorAccessValidator.Validate(req, token) + assert.NoError(t, err) + assert.Equal(t, borrowerAccountID, res) +} + +func TestInvestorAccessValidator_Validate_WithCreatedLoan(t *testing.T) { + loansAPIMock := loans.NewAPIMock(t) + permissionsAPIMock := permissions.NewAPIMock(t) + uniquesAPIMock := uniques.NewAPIMock(t) + + investorAccessValidator := NewInvestorAccessValidator(loansAPIMock, permissionsAPIMock, uniquesAPIMock) + + poolID := types.U64(rand.Uint32()) + loanID := types.U64(rand.Uint32()) + documentID := "document_id" + + reqURL, err := url.Parse("http://localhost/v3/investors") + assert.NoError(t, err) + + reqURL.RawQuery = fmt.Sprintf( + "%s=%d&%s=%d&%s=%s", + coreapi.PoolIDQueryParam, poolID, + coreapi.LoanIDQueryParam, loanID, + coreapi.AssetIDQueryParam, hexutil.Encode([]byte(documentID)), + ) + + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) + assert.NoError(t, err) + + investorAccountID, err := types.NewAccountID(utils.RandomSlice(32)) + assert.NoError(t, err) + + investSSS58Address := subkey.SS58Encode(investorAccountID.ToBytes(), authToken.CentrifugeNetworkID) + + token := &authToken.JW3Token{ + Payload: &authToken.JW3TPayload{ + Address: investSSS58Address, + }, + } + + permissionRoles := &permissions.PermissionRoles{PoolAdmin: permissions.PodReadAccess} + + permissionsAPIMock.On("GetPermissionRoles", investorAccountID, poolID). + Return(permissionRoles, nil). + Once() + + borrowerAccountID, err := types.NewAccountID(utils.RandomSlice(32)) + assert.NoError(t, err) + + collectionID := types.U64(rand.Uint32()) + itemID := types.NewU128(*big.NewInt(rand.Int63())) + + loansAPIMock.On("GetActiveLoan", poolID, loanID). + Return(nil, loans.ErrActiveLoanNotFound). + Once() + loan := &loans.CreatedLoanStorageEntry{ Info: loanTypes.LoanInfo{ Collateral: loanTypes.Asset{ @@ -341,6 +418,56 @@ func TestInvestorAccessValidator_Validate_InvalidPoolPermissions(t *testing.T) { assert.ErrorIs(t, err, ErrInvalidPoolPermissions) } +func TestInvestorAccessValidator_Validate_ActiveLoanRetrievalError(t *testing.T) { + loansAPIMock := loans.NewAPIMock(t) + permissionsAPIMock := permissions.NewAPIMock(t) + uniquesAPIMock := uniques.NewAPIMock(t) + + investorAccessValidator := NewInvestorAccessValidator(loansAPIMock, permissionsAPIMock, uniquesAPIMock) + + poolID := types.U64(rand.Uint32()) + loanID := types.U64(rand.Uint32()) + documentID := "document_id" + + reqURL, err := url.Parse("http://localhost/v3/investors") + assert.NoError(t, err) + + reqURL.RawQuery = fmt.Sprintf( + "%s=%d&%s=%d&%s=%s", + coreapi.PoolIDQueryParam, poolID, + coreapi.LoanIDQueryParam, loanID, + coreapi.AssetIDQueryParam, hexutil.Encode([]byte(documentID)), + ) + + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) + assert.NoError(t, err) + + investorAccountID, err := types.NewAccountID(utils.RandomSlice(32)) + assert.NoError(t, err) + + investorSSS58Address := subkey.SS58Encode(investorAccountID.ToBytes(), authToken.CentrifugeNetworkID) + + token := &authToken.JW3Token{ + Payload: &authToken.JW3TPayload{ + Address: investorSSS58Address, + }, + } + + permissionRoles := &permissions.PermissionRoles{PoolAdmin: permissions.PodReadAccess} + + permissionsAPIMock.On("GetPermissionRoles", investorAccountID, poolID). + Return(permissionRoles, nil). + Once() + + loansAPIMock.On("GetActiveLoan", poolID, loanID). + Return(nil, loans.ErrActiveLoansRetrieval). + Once() + + res, err := investorAccessValidator.Validate(req, token) + assert.Nil(t, res) + assert.ErrorIs(t, err, loans.ErrActiveLoansRetrieval) +} + func TestInvestorAccessValidator_Validate_CreatedLoanRetrievalError(t *testing.T) { loansAPIMock := loans.NewAPIMock(t) permissionsAPIMock := permissions.NewAPIMock(t) @@ -382,13 +509,17 @@ func TestInvestorAccessValidator_Validate_CreatedLoanRetrievalError(t *testing.T Return(permissionRoles, nil). Once() + loansAPIMock.On("GetActiveLoan", poolID, loanID). + Return(nil, loans.ErrActiveLoanNotFound). + Once() + loansAPIMock.On("GetCreatedLoan", poolID, loanID). - Return(nil, errors.New("error")). + Return(nil, loans.ErrCreatedLoanRetrieval). Once() res, err := investorAccessValidator.Validate(req, token) assert.Nil(t, res) - assert.ErrorIs(t, err, ErrCreatedLoanRetrieval) + assert.ErrorIs(t, err, loans.ErrCreatedLoanRetrieval) } func TestInvestorAccessValidator_Validate_DocumentIDRetrievalError(t *testing.T) { @@ -438,18 +569,16 @@ func TestInvestorAccessValidator_Validate_DocumentIDRetrievalError(t *testing.T) collectionID := types.U64(rand.Uint32()) itemID := types.NewU128(*big.NewInt(rand.Int63())) - loan := &loans.CreatedLoanStorageEntry{ - Info: loanTypes.LoanInfo{ - Collateral: loanTypes.Asset{ - CollectionID: collectionID, - ItemID: itemID, - }, + activeLoan := &loanTypes.ActiveLoan{ + Collateral: loanTypes.Asset{ + CollectionID: collectionID, + ItemID: itemID, }, Borrower: *borrowerAccountID, } - loansAPIMock.On("GetCreatedLoan", poolID, loanID). - Return(loan, nil). + loansAPIMock.On("GetActiveLoan", poolID, loanID). + Return(activeLoan, nil). Once() uniquesAPIMock.On( @@ -513,18 +642,16 @@ func TestInvestorAccessValidator_Validate_DocumentIDMismatch(t *testing.T) { collectionID := types.U64(rand.Uint32()) itemID := types.NewU128(*big.NewInt(rand.Int63())) - loan := &loans.CreatedLoanStorageEntry{ - Info: loanTypes.LoanInfo{ - Collateral: loanTypes.Asset{ - CollectionID: collectionID, - ItemID: itemID, - }, + activeLoan := &loanTypes.ActiveLoan{ + Collateral: loanTypes.Asset{ + CollectionID: collectionID, + ItemID: itemID, }, Borrower: *borrowerAccountID, } - loansAPIMock.On("GetCreatedLoan", poolID, loanID). - Return(loan, nil). + loansAPIMock.On("GetActiveLoan", poolID, loanID). + Return(activeLoan, nil). Once() uniquesAPIMock.On( diff --git a/pallets/loans/api.go b/pallets/loans/api.go index 07d845d61..786e0d11c 100644 --- a/pallets/loans/api.go +++ b/pallets/loans/api.go @@ -39,41 +39,15 @@ type CreatedLoanStorageEntry struct { type ActiveLoanStorageEntry struct { LoanID types.U64 - ActiveLoan ActiveLoan -} - -type ActiveLoan struct { - Schedule loans.RepaymentSchedule - Collateral loans.Asset - Restrictions loans.LoanRestrictions - Borrower types.AccountID - WriteOffPercentage types.U128 - OriginationDate types.U64 - Pricing loans.Pricing - TotalBorrowed types.U128 - TotalRepaid RepaidAmount - RepaymentsOnScheduleUntil types.U64 -} - -type RepaidAmount struct { - Principal types.U128 - Interest types.U128 - Unscheduled types.U128 -} - -type ClosedLoan struct { - ClosedAt types.U32 - Info loans.LoanInfo - TotalBorrowed types.U128 - TotalRepaid RepaidAmount + ActiveLoan loans.ActiveLoan } //go:generate mockery --name API --structname APIMock --filename api_mock.go --inpackage type API interface { GetCreatedLoan(poolID types.U64, loanID types.U64) (*CreatedLoanStorageEntry, error) - GetActiveLoan(poolID types.U64, loanID types.U64) (*ActiveLoan, error) - GetClosedLoan(poolID types.U64, loanID types.U64) (*ClosedLoan, error) + GetActiveLoan(poolID types.U64, loanID types.U64) (*loans.ActiveLoan, error) + GetClosedLoan(poolID types.U64, loanID types.U64) (*loans.ClosedLoan, error) } type api struct { @@ -152,7 +126,7 @@ func (a *api) GetCreatedLoan(poolID types.U64, loanID types.U64) (*CreatedLoanSt return &createdLoan, nil } -func (a *api) GetActiveLoan(poolID types.U64, loanID types.U64) (*ActiveLoan, error) { +func (a *api) GetActiveLoan(poolID types.U64, loanID types.U64) (*loans.ActiveLoan, error) { err := validation.Validate( validation.NewValidator(poolID, validation.U64ValidationFn), ) @@ -219,7 +193,7 @@ func (a *api) GetActiveLoan(poolID types.U64, loanID types.U64) (*ActiveLoan, er return nil, ErrActiveLoanNotFound } -func (a *api) GetClosedLoan(poolID types.U64, loanID types.U64) (*ClosedLoan, error) { +func (a *api) GetClosedLoan(poolID types.U64, loanID types.U64) (*loans.ClosedLoan, error) { err := validation.Validate( validation.NewValidator(poolID, validation.U64ValidationFn), ) @@ -268,7 +242,7 @@ func (a *api) GetClosedLoan(poolID types.U64, loanID types.U64) (*ClosedLoan, er return nil, errors.ErrStorageKeyCreation } - var closedLoan ClosedLoan + var closedLoan loans.ClosedLoan ok, err := a.centAPI.GetStorageLatest(storageKey, &closedLoan) diff --git a/pallets/loans/api_mock.go b/pallets/loans/api_mock.go index 0f06fa174..8490be03a 100644 --- a/pallets/loans/api_mock.go +++ b/pallets/loans/api_mock.go @@ -3,6 +3,7 @@ package loans import ( + pkgloans "github.com/centrifuge/chain-custom-types/pkg/loans" types "github.com/centrifuge/go-substrate-rpc-client/v4/types" mock "github.com/stretchr/testify/mock" ) @@ -12,6 +13,52 @@ type APIMock struct { mock.Mock } +// GetActiveLoan provides a mock function with given fields: poolID, loanID +func (_m *APIMock) GetActiveLoan(poolID types.U64, loanID types.U64) (*pkgloans.ActiveLoan, error) { + ret := _m.Called(poolID, loanID) + + var r0 *pkgloans.ActiveLoan + if rf, ok := ret.Get(0).(func(types.U64, types.U64) *pkgloans.ActiveLoan); ok { + r0 = rf(poolID, loanID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*pkgloans.ActiveLoan) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(types.U64, types.U64) error); ok { + r1 = rf(poolID, loanID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetClosedLoan provides a mock function with given fields: poolID, loanID +func (_m *APIMock) GetClosedLoan(poolID types.U64, loanID types.U64) (*pkgloans.ClosedLoan, error) { + ret := _m.Called(poolID, loanID) + + var r0 *pkgloans.ClosedLoan + if rf, ok := ret.Get(0).(func(types.U64, types.U64) *pkgloans.ClosedLoan); ok { + r0 = rf(poolID, loanID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*pkgloans.ClosedLoan) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(types.U64, types.U64) error); ok { + r1 = rf(poolID, loanID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCreatedLoan provides a mock function with given fields: poolID, loanID func (_m *APIMock) GetCreatedLoan(poolID types.U64, loanID types.U64) (*CreatedLoanStorageEntry, error) { ret := _m.Called(poolID, loanID) diff --git a/pallets/loans/api_test.go b/pallets/loans/api_test.go index fb4461dc6..7e1dc4bc7 100644 --- a/pallets/loans/api_test.go +++ b/pallets/loans/api_test.go @@ -5,14 +5,13 @@ package loans import ( "testing" - "github.com/centrifuge/pod/errors" - - "github.com/centrifuge/pod/validation" - + "github.com/centrifuge/chain-custom-types/pkg/loans" "github.com/centrifuge/go-substrate-rpc-client/v4/types" "github.com/centrifuge/go-substrate-rpc-client/v4/types/codec" "github.com/centrifuge/pod/centchain" + "github.com/centrifuge/pod/errors" "github.com/centrifuge/pod/testingutils" + "github.com/centrifuge/pod/validation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -193,3 +192,395 @@ func TestApi_GetCreatedLoan_StorageEntryNotFound(t *testing.T) { assert.ErrorIs(t, err, ErrCreatedLoanNotFound) assert.Nil(t, res) } + +func TestApi_GetActiveLoan(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + meta, err := testingutils.GetTestMetadata() + assert.NoError(t, err) + + centAPIMock. + On("GetMetadataLatest"). + Return(meta, nil). + Once() + + encodedPoolID, err := codec.Encode(poolID) + assert.NoError(t, err) + + storageKey, err := types.CreateStorageKey(meta, PalletName, ActiveLoansStorageName, encodedPoolID) + assert.NoError(t, err) + + testStorageEntry := ActiveLoanStorageEntry{ + LoanID: loanID, + ActiveLoan: loans.ActiveLoan{}, + } + + centAPIMock. + On("GetStorageLatest", storageKey, mock.Anything). + Run(func(args mock.Arguments) { + storageEntry, ok := args.Get(1).(*[]ActiveLoanStorageEntry) + assert.True(t, ok) + + *storageEntry = append(*storageEntry, testStorageEntry) + }). + Return(true, nil). + Once() + + res, err := api.GetActiveLoan(poolID, loanID) + assert.NoError(t, err) + assert.Equal(t, &testStorageEntry.ActiveLoan, res) +} + +func TestApi_GetActiveLoan_InvalidPoolID(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(0) + loanID := types.U64(0) + + res, err := api.GetActiveLoan(poolID, loanID) + assert.ErrorIs(t, err, validation.ErrInvalidU64) + assert.Nil(t, res) +} + +func TestApi_GetActiveLoan_MetadataRetrievalError(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + centAPIMock. + On("GetMetadataLatest"). + Return(nil, errors.New("error")). + Once() + + res, err := api.GetActiveLoan(poolID, loanID) + assert.ErrorIs(t, err, errors.ErrMetadataRetrieval) + assert.Nil(t, res) +} + +func TestApi_GetActiveLoan_StorageKeyCreationError(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + var meta types.Metadata + + // NOTE - types.MetadataV14Data does not have info on the Loans pallet, + // causing types.CreateStorageKey to fail. + err := codec.DecodeFromHex(types.MetadataV14Data, &meta) + assert.NoError(t, err) + + centAPIMock. + On("GetMetadataLatest"). + Return(&meta, nil). + Once() + + res, err := api.GetActiveLoan(poolID, loanID) + assert.ErrorIs(t, err, errors.ErrStorageKeyCreation) + assert.Nil(t, res) +} + +func TestApi_GetActiveLoan_StorageRetrievalError(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + meta, err := testingutils.GetTestMetadata() + assert.NoError(t, err) + + centAPIMock. + On("GetMetadataLatest"). + Return(meta, nil). + Once() + + encodedPoolID, err := codec.Encode(poolID) + assert.NoError(t, err) + + storageKey, err := types.CreateStorageKey(meta, PalletName, ActiveLoansStorageName, encodedPoolID) + assert.NoError(t, err) + + centAPIMock. + On("GetStorageLatest", storageKey, mock.Anything). + Run(func(args mock.Arguments) { + _, ok := args.Get(1).(*[]ActiveLoanStorageEntry) + assert.True(t, ok) + }). + Return(false, errors.New("error")). + Once() + + res, err := api.GetActiveLoan(poolID, loanID) + assert.ErrorIs(t, err, ErrActiveLoansRetrieval) + assert.Nil(t, res) +} + +func TestApi_GetActiveLoan_StorageEntryNotFound(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + meta, err := testingutils.GetTestMetadata() + assert.NoError(t, err) + + centAPIMock. + On("GetMetadataLatest"). + Return(meta, nil). + Once() + + encodedPoolID, err := codec.Encode(poolID) + assert.NoError(t, err) + + storageKey, err := types.CreateStorageKey(meta, PalletName, ActiveLoansStorageName, encodedPoolID) + assert.NoError(t, err) + + centAPIMock. + On("GetStorageLatest", storageKey, mock.Anything). + Run(func(args mock.Arguments) { + _, ok := args.Get(1).(*[]ActiveLoanStorageEntry) + assert.True(t, ok) + }). + Return(false, nil). + Once() + + res, err := api.GetActiveLoan(poolID, loanID) + assert.ErrorIs(t, err, ErrActiveLoanNotFound) + assert.Nil(t, res) +} + +func TestApi_GetActiveLoan_ActiveLoanNotFound(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + meta, err := testingutils.GetTestMetadata() + assert.NoError(t, err) + + centAPIMock. + On("GetMetadataLatest"). + Return(meta, nil). + Once() + + encodedPoolID, err := codec.Encode(poolID) + assert.NoError(t, err) + + storageKey, err := types.CreateStorageKey(meta, PalletName, ActiveLoansStorageName, encodedPoolID) + assert.NoError(t, err) + + testStorageEntry := ActiveLoanStorageEntry{ + // Return an entry for a different loan ID. + LoanID: loanID + 1, + ActiveLoan: loans.ActiveLoan{}, + } + + centAPIMock. + On("GetStorageLatest", storageKey, mock.Anything). + Run(func(args mock.Arguments) { + storageEntry, ok := args.Get(1).(*[]ActiveLoanStorageEntry) + assert.True(t, ok) + + *storageEntry = append(*storageEntry, testStorageEntry) + }). + Return(true, nil). + Once() + + res, err := api.GetActiveLoan(poolID, loanID) + assert.ErrorIs(t, err, ErrActiveLoanNotFound) + assert.Nil(t, res) +} + +func TestApi_GetClosedLoan(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + meta, err := testingutils.GetTestMetadata() + assert.NoError(t, err) + + centAPIMock. + On("GetMetadataLatest"). + Return(meta, nil). + Once() + + encodedPoolID, err := codec.Encode(poolID) + assert.NoError(t, err) + + encodedLoanID, err := codec.Encode(loanID) + assert.NoError(t, err) + + storageKey, err := types.CreateStorageKey(meta, PalletName, ClosedLoanStorageName, encodedPoolID, encodedLoanID) + assert.NoError(t, err) + + testStorageEntry := loans.ClosedLoan{} + + centAPIMock. + On("GetStorageLatest", storageKey, mock.Anything). + Run(func(args mock.Arguments) { + storageEntry, ok := args.Get(1).(*loans.ClosedLoan) + assert.True(t, ok) + + *storageEntry = testStorageEntry + }). + Return(true, nil). + Once() + + res, err := api.GetClosedLoan(poolID, loanID) + assert.NoError(t, err) + assert.Equal(t, &testStorageEntry, res) +} + +func TestApi_GetClosedLoan_InvalidPoolID(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(0) + loanID := types.U64(0) + + res, err := api.GetClosedLoan(poolID, loanID) + assert.ErrorIs(t, err, validation.ErrInvalidU64) + assert.Nil(t, res) +} + +func TestApi_GetClosedLoan_MetadataRetrievalError(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + centAPIMock. + On("GetMetadataLatest"). + Return(nil, errors.New("error")). + Once() + + res, err := api.GetClosedLoan(poolID, loanID) + assert.ErrorIs(t, err, errors.ErrMetadataRetrieval) + assert.Nil(t, res) +} + +func TestApi_GetClosedLoan_StorageKeyCreationError(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + var meta types.Metadata + + // NOTE - types.MetadataV14Data does not have info on the Loans pallet, + // causing types.CreateStorageKey to fail. + err := codec.DecodeFromHex(types.MetadataV14Data, &meta) + assert.NoError(t, err) + + centAPIMock. + On("GetMetadataLatest"). + Return(&meta, nil). + Once() + + res, err := api.GetClosedLoan(poolID, loanID) + assert.ErrorIs(t, err, errors.ErrStorageKeyCreation) + assert.Nil(t, res) +} + +func TestApi_GetClosedLoan_StorageRetrievalError(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + meta, err := testingutils.GetTestMetadata() + assert.NoError(t, err) + + centAPIMock. + On("GetMetadataLatest"). + Return(meta, nil). + Once() + + encodedPoolID, err := codec.Encode(poolID) + assert.NoError(t, err) + + encodedLoanID, err := codec.Encode(loanID) + assert.NoError(t, err) + + storageKey, err := types.CreateStorageKey(meta, PalletName, ClosedLoanStorageName, encodedPoolID, encodedLoanID) + assert.NoError(t, err) + + centAPIMock. + On("GetStorageLatest", storageKey, mock.Anything). + Run(func(args mock.Arguments) { + _, ok := args.Get(1).(*loans.ClosedLoan) + assert.True(t, ok) + }). + Return(false, errors.New("error")). + Once() + + res, err := api.GetClosedLoan(poolID, loanID) + assert.ErrorIs(t, err, ErrClosedLoanRetrieval) + assert.Nil(t, res) +} + +func TestApi_GetClosedLoan_StorageEntryNotFound(t *testing.T) { + centAPIMock := centchain.NewAPIMock(t) + + api := NewAPI(centAPIMock) + + poolID := types.U64(123) + loanID := types.U64(0) + + meta, err := testingutils.GetTestMetadata() + assert.NoError(t, err) + + centAPIMock. + On("GetMetadataLatest"). + Return(meta, nil). + Once() + + encodedPoolID, err := codec.Encode(poolID) + assert.NoError(t, err) + + encodedLoanID, err := codec.Encode(loanID) + assert.NoError(t, err) + + storageKey, err := types.CreateStorageKey(meta, PalletName, ClosedLoanStorageName, encodedPoolID, encodedLoanID) + assert.NoError(t, err) + + centAPIMock. + On("GetStorageLatest", storageKey, mock.Anything). + Run(func(args mock.Arguments) { + _, ok := args.Get(1).(*loans.ClosedLoan) + assert.True(t, ok) + }). + Return(false, nil). + Once() + + res, err := api.GetClosedLoan(poolID, loanID) + assert.ErrorIs(t, err, ErrClosedLoanNotFound) + assert.Nil(t, res) +}