diff --git a/common/db.go b/common/db.go index 349d3aa..625a1b6 100644 --- a/common/db.go +++ b/common/db.go @@ -29,6 +29,10 @@ const ( DATA_TEMPORARY_BAL DATA_ACTIVE_BAL DATA_PUBLIC_KEY_REVERSE + DATA_TEMPORARY_DECIMAL + DATA_ACTIVE_DECIMAL + DATA_TEMPORARY_ADDRESS + DATA_ACTIVE_ADDRESS ) func typToBytes(typ DataTyp) []byte { diff --git a/go.mod b/go.mod index 0b30354..391c1a5 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,8 @@ require ( gopkg.in/leonelquinteros/gotext.v1 v1.3.1 ) +require github.com/grassrootseconomics/ussd-data-service v0.0.0-20241003123429-4904b4438a3a // indirect + require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index d566e2c..0ba38c1 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1 github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/grassrootseconomics/eth-custodial v1.3.0-beta h1:twrMBhl89GqDUL9PlkzQxMP/6OST1BByrNDj+rqXDmU= github.com/grassrootseconomics/eth-custodial v1.3.0-beta/go.mod h1:7uhRcdnJplX4t6GKCEFkbeDhhjlcaGJeJqevbcvGLZo= +github.com/grassrootseconomics/ussd-data-service v0.0.0-20241003123429-4904b4438a3a h1:q/YH7nE2j8epNmFnTu0tU1vwtCxtQ6nH+d7hRVV5krU= +github.com/grassrootseconomics/ussd-data-service v0.0.0-20241003123429-4904b4438a3a/go.mod h1:hdKaKwqiW6/kphK4j/BhmuRlZDLo1+DYo3gYw5O0siw= github.com/graygnuorg/go-gdbm v0.0.0-20220711140707-71387d66dce4 h1:U4kkNYryi/qfbBF8gh7Vsbuz+cVmhf5kt6pE9bYYyLo= github.com/graygnuorg/go-gdbm v0.0.0-20220711140707-71387d66dce4/go.mod h1:zpZDgZFzeq9s0MIeB1P50NIEWDFFHSFBohI/NbaTD/Y= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= diff --git a/internal/handlers/ussd/menuhandler.go b/internal/handlers/ussd/menuhandler.go index aa5c1aa..f568e6a 100644 --- a/internal/handlers/ussd/menuhandler.go +++ b/internal/handlers/ussd/menuhandler.go @@ -66,6 +66,7 @@ type Handlers struct { userdataStore common.DataStore flagManager *asm.FlagParser accountService remote.AccountServiceInterface + prefixDb storage.PrefixDb } func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, accountService remote.AccountServiceInterface) (*Handlers, error) { @@ -75,10 +76,14 @@ func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, accountService r userDb := &common.UserDataStore{ Db: userdataStore, } + // Instantiate the SubPrefixDb with "vouchers" prefix + prefixDb := storage.NewSubPrefixDb(userdataStore, []byte("vouchers")) + h := &Handlers{ userdataStore: userDb, flagManager: appFlags, accountService: accountService, + prefixDb: prefixDb, } return h, nil } @@ -1051,13 +1056,13 @@ func (h *Handlers) SetDefaultVoucher(ctx context.Context, sym string, input []by if db.IsNotFound(err) { publicKey, err := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) if err != nil { - return res, nil + return res, err } // Fetch vouchers from the API using the public key vouchersResp, err := h.accountService.FetchVouchers(ctx, string(publicKey)) if err != nil { - return res, nil + return res, err } // Return if there is no voucher @@ -1114,54 +1119,33 @@ func (h *Handlers) CheckVouchers(ctx context.Context, sym string, input []byte) return res, nil } - // process voucher data - voucherSymbolList, voucherBalanceList := ProcessVouchers(vouchersResp.Result.Holdings) + data := utils.ProcessVouchers(vouchersResp.Result.Holdings) - prefixdb := storage.NewSubPrefixDb(store, []byte("vouchers")) - err = prefixdb.Put(ctx, []byte("sym"), []byte(voucherSymbolList)) - if err != nil { - return res, nil + // Store all voucher data + dataMap := map[string]string{ + "sym": data.Symbols, + "bal": data.Balances, + "deci": data.Decimals, + "addr": data.Addresses, } - err = prefixdb.Put(ctx, []byte("bal"), []byte(voucherBalanceList)) - if err != nil { - return res, nil + for key, value := range dataMap { + if err := h.prefixDb.Put(ctx, []byte(key), []byte(value)); err != nil { + return res, nil + } } return res, nil } -// ProcessVouchers formats the holdings into symbol and balance lists. -func ProcessVouchers(holdings []struct { - ContractAddress string `json:"contractAddress"` - TokenSymbol string `json:"tokenSymbol"` - TokenDecimals string `json:"tokenDecimals"` - Balance string `json:"balance"` -}) (string, string) { - var numberedSymbols, numberedBalances []string - - for i, voucher := range holdings { - numberedSymbols = append(numberedSymbols, fmt.Sprintf("%d:%s", i+1, voucher.TokenSymbol)) - numberedBalances = append(numberedBalances, fmt.Sprintf("%d:%s", i+1, voucher.Balance)) - } - - voucherSymbolList := strings.Join(numberedSymbols, "\n") - voucherBalanceList := strings.Join(numberedBalances, "\n") - - return voucherSymbolList, voucherBalanceList -} - // GetVoucherList fetches the list of vouchers and formats them func (h *Handlers) GetVoucherList(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result // Read vouchers from the store - store := h.userdataStore - prefixdb := storage.NewSubPrefixDb(store, []byte("vouchers")) - - voucherData, err := prefixdb.Get(ctx, []byte("sym")) + voucherData, err := h.prefixDb.Get(ctx, []byte("sym")) if err != nil { - return res, nil + return res, err } res.Content = string(voucherData) @@ -1172,8 +1156,6 @@ func (h *Handlers) GetVoucherList(ctx context.Context, sym string, input []byte) // ViewVoucher retrieves the token holding and balance from the subprefixDB func (h *Handlers) ViewVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result - store := h.userdataStore - sessionId, ok := ctx.Value("SessionId").(string) if !ok { return res, fmt.Errorf("missing session") @@ -1182,126 +1164,52 @@ func (h *Handlers) ViewVoucher(ctx context.Context, sym string, input []byte) (r flag_incorrect_voucher, _ := h.flagManager.GetFlag("flag_incorrect_voucher") inputStr := string(input) - if inputStr == "0" || inputStr == "99" { res.FlagReset = append(res.FlagReset, flag_incorrect_voucher) return res, nil } - prefixdb := storage.NewSubPrefixDb(store, []byte("vouchers")) - - // Retrieve the voucher symbol list - voucherSymbolList, err := prefixdb.Get(ctx, []byte("sym")) + metadata, err := utils.GetVoucherData(ctx, h.prefixDb, inputStr) if err != nil { - return res, fmt.Errorf("failed to retrieve voucher symbol list: %v", err) + return res, fmt.Errorf("failed to retrieve voucher data: %v", err) } - // Retrieve the voucher balance list - voucherBalanceList, err := prefixdb.Get(ctx, []byte("bal")) - if err != nil { - return res, fmt.Errorf("failed to retrieve voucher balance list: %v", err) - } - - // match the voucher symbol and balance with the input - matchedSymbol, matchedBalance := MatchVoucher(inputStr, string(voucherSymbolList), string(voucherBalanceList)) - - // If a match is found, write the temporary sym and balance - if matchedSymbol != "" && matchedBalance != "" { - err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_SYM, []byte(matchedSymbol)) - if err != nil { - return res, err - } - err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_BAL, []byte(matchedBalance)) - if err != nil { - return res, err - } - - res.FlagReset = append(res.FlagReset, flag_incorrect_voucher) - res.Content = fmt.Sprintf("%s\n%s", matchedSymbol, matchedBalance) - } else { + if metadata == nil { res.FlagSet = append(res.FlagSet, flag_incorrect_voucher) + return res, nil } + if err := utils.StoreTemporaryVoucher(ctx, h.userdataStore, sessionId, metadata); err != nil { + return res, err + } + + res.FlagReset = append(res.FlagReset, flag_incorrect_voucher) + res.Content = fmt.Sprintf("%s\n%s", metadata.TokenSymbol, metadata.Balance) + return res, nil } -// MatchVoucher finds the matching voucher symbol and balance based on the input. -func MatchVoucher(inputStr string, voucherSymbols, voucherBalances string) (string, string) { - // Split the lists into slices for processing - symbols := strings.Split(voucherSymbols, "\n") - balances := strings.Split(voucherBalances, "\n") - - var matchedSymbol, matchedBalance string - - for i, symbol := range symbols { - symbolParts := strings.SplitN(symbol, ":", 2) - if len(symbolParts) != 2 { - continue - } - voucherNum := symbolParts[0] - voucherSymbol := symbolParts[1] - - // Check if input matches either the number or the symbol - if inputStr == voucherNum || strings.EqualFold(inputStr, voucherSymbol) { - matchedSymbol = voucherSymbol - // Ensure there's a corresponding balance - if i < len(balances) { - matchedBalance = strings.SplitN(balances[i], ":", 2)[1] - } - break - } - } - - return matchedSymbol, matchedBalance -} - -// SetVoucher retrieves the temporary sym and balance, -// sets them as the active data and -// clears the temporary data +// SetVoucher retrieves the temp voucher data and sets it as the active data func (h *Handlers) SetVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result - var err error - store := h.userdataStore sessionId, ok := ctx.Value("SessionId").(string) if !ok { return res, fmt.Errorf("missing session") } - // get the current temporary symbol - temporarySym, err := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_SYM) - if err != nil { - return res, err - } - // get the current temporary balance - temporaryBal, err := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_BAL) + + // Get temporary data + tempData, err := utils.GetTemporaryVoucherData(ctx, h.userdataStore, sessionId) if err != nil { return res, err } - // set the active symbol - err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_SYM, []byte(temporarySym)) - if err != nil { - return res, err - } - // set the active balance - err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_BAL, []byte(temporaryBal)) - if err != nil { + // Set as active and clear temporary data + if err := utils.UpdateVoucherData(ctx, h.userdataStore, sessionId, tempData); err != nil { return res, err } - // reset the temporary symbol - err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_SYM, []byte("")) - if err != nil { - return res, err - } - // reset the temporary balance - err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_BAL, []byte("")) - if err != nil { - return res, err - } - - res.Content = string(temporarySym) - + res.Content = tempData.TokenSymbol return res, nil } diff --git a/internal/handlers/ussd/menuhandler_test.go b/internal/handlers/ussd/menuhandler_test.go index b83fa61..bae95a8 100644 --- a/internal/handlers/ussd/menuhandler_test.go +++ b/internal/handlers/ussd/menuhandler_test.go @@ -24,6 +24,8 @@ import ( "github.com/grassrootseconomics/eth-custodial/pkg/api" testdataloader "github.com/peteole/testdata-loader" "github.com/stretchr/testify/require" + + dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" ) var ( @@ -1488,8 +1490,8 @@ func TestValidateAmount(t *testing.T) { }, }, { - name: "Test with invalid amount format", - input: []byte("0.02ms"), + name: "Test with invalid amount format", + input: []byte("0.02ms"), activeBal: []byte("5"), expectedResult: resource.Result{ FlagSet: []uint32{flag_invalid_amount}, @@ -1818,40 +1820,6 @@ func TestConfirmPin(t *testing.T) { } } -func TestSetVoucher(t *testing.T) { - mockDataStore := new(mocks.MockUserDataStore) - - sessionId := "session123" - ctx := context.WithValue(context.Background(), "SessionId", sessionId) - - temporarySym := []byte("tempSym") - temporaryBal := []byte("tempBal") - - // Set expectations for the mock data store - mockDataStore.On("ReadEntry", ctx, sessionId, common.DATA_TEMPORARY_SYM).Return(temporarySym, nil) - mockDataStore.On("ReadEntry", ctx, sessionId, common.DATA_TEMPORARY_BAL).Return(temporaryBal, nil) - mockDataStore.On("WriteEntry", ctx, sessionId, common.DATA_ACTIVE_SYM, temporarySym).Return(nil) - mockDataStore.On("WriteEntry", ctx, sessionId, common.DATA_ACTIVE_BAL, temporaryBal).Return(nil) - mockDataStore.On("WriteEntry", ctx, sessionId, common.DATA_TEMPORARY_SYM, []byte("")).Return(nil) - mockDataStore.On("WriteEntry", ctx, sessionId, common.DATA_TEMPORARY_BAL, []byte("")).Return(nil) - - h := &Handlers{ - userdataStore: mockDataStore, - } - - // Call the method under test - res, err := h.SetVoucher(ctx, "someSym", []byte{}) - - // Assert that no errors occurred - assert.NoError(t, err) - - // Assert that the result content is correct - assert.Equal(t, string(temporarySym), res.Content) - - // Assert that expectations were met - mockDataStore.AssertExpectations(t) -} - func TestFetchCustodialBalances(t *testing.T) { fm, err := NewFlagManager(flagsPath) if err != nil { @@ -1933,3 +1901,256 @@ func TestFetchCustodialBalances(t *testing.T) { }) } } + +func TestSetDefaultVoucher(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + flag_no_active_voucher, err := fm.GetFlag("flag_no_active_voucher") + if err != nil { + t.Logf(err.Error()) + } + // Define session ID and mock data + sessionId := "session123" + publicKey := "0X13242618721" + notFoundErr := db.ErrNotFound{} + ctx := context.WithValue(context.Background(), "SessionId", sessionId) + + tests := []struct { + name string + vouchersResp *models.VoucherHoldingResponse + expectedResult resource.Result + }{ + { + name: "Test set default voucher when no active voucher exists", + vouchersResp: &models.VoucherHoldingResponse{ + Ok: true, + Description: "Vouchers fetched successfully", + Result: models.VoucherResult{ + Holdings: []dataserviceapi.TokenHoldings{ + { + ContractAddress: "0x123", + TokenSymbol: "TOKEN1", + TokenDecimals: "18", + Balance: "100", + }, + }, + }, + }, + expectedResult: resource.Result{}, + }, + { + name: "Test no vouchers available", + vouchersResp: &models.VoucherHoldingResponse{ + Ok: true, + Description: "No vouchers available", + Result: models.VoucherResult{ + Holdings: []dataserviceapi.TokenHoldings{}, + }, + }, + expectedResult: resource.Result{ + FlagSet: []uint32{flag_no_active_voucher}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockDataStore := new(mocks.MockUserDataStore) + mockAccountService := new(mocks.MockAccountService) + + h := &Handlers{ + userdataStore: mockDataStore, + accountService: mockAccountService, + flagManager: fm.parser, + } + + mockDataStore.On("ReadEntry", ctx, sessionId, common.DATA_ACTIVE_SYM).Return([]byte(""), notFoundErr) + mockDataStore.On("ReadEntry", ctx, sessionId, common.DATA_PUBLIC_KEY).Return([]byte(publicKey), nil) + + mockAccountService.On("FetchVouchers", string(publicKey)).Return(tt.vouchersResp, nil) + + if len(tt.vouchersResp.Result.Holdings) > 0 { + firstVoucher := tt.vouchersResp.Result.Holdings[0] + mockDataStore.On("WriteEntry", ctx, sessionId, common.DATA_ACTIVE_SYM, []byte(firstVoucher.TokenSymbol)).Return(nil) + mockDataStore.On("WriteEntry", ctx, sessionId, common.DATA_ACTIVE_BAL, []byte(firstVoucher.Balance)).Return(nil) + } + + res, err := h.SetDefaultVoucher(ctx, "set_default_voucher", []byte("some-input")) + + assert.NoError(t, err) + + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + + mockDataStore.AssertExpectations(t) + mockAccountService.AssertExpectations(t) + }) + } +} + +func TestCheckVouchers(t *testing.T) { + mockDataStore := new(mocks.MockUserDataStore) + mockAccountService := new(mocks.MockAccountService) + mockSubPrefixDb := new(mocks.MockSubPrefixDb) + + sessionId := "session123" + publicKey := "0X13242618721" + + h := &Handlers{ + userdataStore: mockDataStore, + accountService: mockAccountService, + prefixDb: mockSubPrefixDb, + } + + ctx := context.WithValue(context.Background(), "SessionId", sessionId) + + mockDataStore.On("ReadEntry", ctx, sessionId, common.DATA_PUBLIC_KEY).Return([]byte(publicKey), nil) + + mockVouchersResponse := &models.VoucherHoldingResponse{} + mockVouchersResponse.Result.Holdings = []dataserviceapi.TokenHoldings{ + {ContractAddress: "0xd4c288865Ce", TokenSymbol: "SRF", TokenDecimals: "6", Balance: "100"}, + {ContractAddress: "0x41c188d63Qa", TokenSymbol: "MILO", TokenDecimals: "4", Balance: "200"}, + } + + mockAccountService.On("FetchVouchers", string(publicKey)).Return(mockVouchersResponse, nil) + + mockSubPrefixDb.On("Put", ctx, []byte("sym"), []byte("1:SRF\n2:MILO")).Return(nil) + mockSubPrefixDb.On("Put", ctx, []byte("bal"), []byte("1:100\n2:200")).Return(nil) + mockSubPrefixDb.On("Put", ctx, []byte("deci"), []byte("1:6\n2:4")).Return(nil) + mockSubPrefixDb.On("Put", ctx, []byte("addr"), []byte("1:0xd4c288865Ce\n2:0x41c188d63Qa")).Return(nil) + + _, err := h.CheckVouchers(ctx, "check_vouchers", []byte("")) + + assert.NoError(t, err) + + mockDataStore.AssertExpectations(t) + mockAccountService.AssertExpectations(t) +} + +func TestGetVoucherList(t *testing.T) { + mockSubPrefixDb := new(mocks.MockSubPrefixDb) + + sessionId := "session123" + ctx := context.WithValue(context.Background(), "SessionId", sessionId) + + h := &Handlers{ + prefixDb: mockSubPrefixDb, + } + + mockSubPrefixDb.On("Get", ctx, []byte("sym")).Return([]byte("1:SRF\n2:MILO"), nil) + + res, err := h.GetVoucherList(ctx, "", []byte("")) + assert.NoError(t, err) + assert.Contains(t, res.Content, "1:SRF\n2:MILO") + + mockSubPrefixDb.AssertExpectations(t) +} + +func TestViewVoucher(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + mockDataStore := new(mocks.MockUserDataStore) + mockSubPrefixDb := new(mocks.MockSubPrefixDb) + + sessionId := "session123" + ctx := context.WithValue(context.Background(), "SessionId", sessionId) + + h := &Handlers{ + userdataStore: mockDataStore, + flagManager: fm.parser, + prefixDb: mockSubPrefixDb, + } + + // Define mock voucher data + mockVoucherData := map[string]string{ + "sym": "1:SRF", + "bal": "1:100", + "deci": "1:6", + "addr": "1:0xd4c288865Ce", + } + + for key, value := range mockVoucherData { + mockSubPrefixDb.On("Get", ctx, []byte(key)).Return([]byte(value), nil) + } + + // Set up expectations for mockDataStore + expectedData := map[common.DataTyp]string{ + common.DATA_TEMPORARY_SYM: "SRF", + common.DATA_TEMPORARY_BAL: "100", + common.DATA_TEMPORARY_DECIMAL: "6", + common.DATA_TEMPORARY_ADDRESS: "0xd4c288865Ce", + } + + for dataType, dataValue := range expectedData { + mockDataStore.On("WriteEntry", ctx, sessionId, dataType, []byte(dataValue)).Return(nil) + } + + res, err := h.ViewVoucher(ctx, "view_voucher", []byte("1")) + assert.NoError(t, err) + assert.Contains(t, res.Content, "SRF\n100") + + mockDataStore.AssertExpectations(t) + mockSubPrefixDb.AssertExpectations(t) +} + +func TestSetVoucher(t *testing.T) { + mockDataStore := new(mocks.MockUserDataStore) + + sessionId := "session123" + ctx := context.WithValue(context.Background(), "SessionId", sessionId) + + // Define the temporary voucher data + tempData := &dataserviceapi.TokenHoldings{ + TokenSymbol: "SRF", + Balance: "200", + TokenDecimals: "6", + ContractAddress: "0xd4c288865Ce0985a481Eef3be02443dF5E2e4Ea9", + } + + // Define the expected active entries + activeEntries := map[common.DataTyp][]byte{ + common.DATA_ACTIVE_SYM: []byte(tempData.TokenSymbol), + common.DATA_ACTIVE_BAL: []byte(tempData.Balance), + common.DATA_ACTIVE_DECIMAL: []byte(tempData.TokenDecimals), + common.DATA_ACTIVE_ADDRESS: []byte(tempData.ContractAddress), + } + + // Define the temporary entries to be cleared + tempEntries := map[common.DataTyp][]byte{ + common.DATA_TEMPORARY_SYM: []byte(""), + common.DATA_TEMPORARY_BAL: []byte(""), + common.DATA_TEMPORARY_DECIMAL: []byte(""), + common.DATA_TEMPORARY_ADDRESS: []byte(""), + } + + // Mocking ReadEntry calls for temporary data retrieval + mockDataStore.On("ReadEntry", ctx, sessionId, common.DATA_TEMPORARY_SYM).Return([]byte(tempData.TokenSymbol), nil) + mockDataStore.On("ReadEntry", ctx, sessionId, common.DATA_TEMPORARY_BAL).Return([]byte(tempData.Balance), nil) + mockDataStore.On("ReadEntry", ctx, sessionId, common.DATA_TEMPORARY_DECIMAL).Return([]byte(tempData.TokenDecimals), nil) + mockDataStore.On("ReadEntry", ctx, sessionId, common.DATA_TEMPORARY_ADDRESS).Return([]byte(tempData.ContractAddress), nil) + + // Mocking WriteEntry calls for setting active data + for key, value := range activeEntries { + mockDataStore.On("WriteEntry", ctx, sessionId, key, value).Return(nil) + } + + // Mocking WriteEntry calls for clearing temporary data + for key, value := range tempEntries { + mockDataStore.On("WriteEntry", ctx, sessionId, key, value).Return(nil) + } + + h := &Handlers{ + userdataStore: mockDataStore, + } + + res, err := h.SetVoucher(ctx, "someSym", []byte{}) + + assert.NoError(t, err) + + assert.Equal(t, string(tempData.TokenSymbol), res.Content) + + mockDataStore.AssertExpectations(t) +} diff --git a/internal/models/vouchersresponse.go b/internal/models/vouchersresponse.go index 010730f..09b085d 100644 --- a/internal/models/vouchersresponse.go +++ b/internal/models/vouchersresponse.go @@ -1,15 +1,14 @@ package models -// VoucherHoldingResponse represents a single voucher holding +import dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" + type VoucherHoldingResponse struct { - Ok bool `json:"ok"` - Description string `json:"description"` - Result struct { - Holdings []struct { - ContractAddress string `json:"contractAddress"` - TokenSymbol string `json:"tokenSymbol"` - TokenDecimals string `json:"tokenDecimals"` - Balance string `json:"balance"` - } `json:"holdings"` - } `json:"result"` + Ok bool `json:"ok"` + Description string `json:"description"` + Result VoucherResult `json:"result"` +} + +// VoucherResult holds the list of token holdings +type VoucherResult struct { + Holdings []dataserviceapi.TokenHoldings `json:"holdings"` } diff --git a/internal/storage/db.go b/internal/storage/db.go index 060f0c0..8c9ff35 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -10,6 +10,14 @@ const ( DATATYPE_USERSUB = 64 ) +// PrefixDb interface abstracts the database operations. +type PrefixDb interface { + Get(ctx context.Context, key []byte) ([]byte, error) + Put(ctx context.Context, key []byte, val []byte) error +} + +var _ PrefixDb = (*SubPrefixDb)(nil) + type SubPrefixDb struct { store db.Db pfx []byte @@ -29,11 +37,7 @@ func (s *SubPrefixDb) toKey(k []byte) []byte { func (s *SubPrefixDb) Get(ctx context.Context, key []byte) ([]byte, error) { s.store.SetPrefix(DATATYPE_USERSUB) key = s.toKey(key) - v, err := s.store.Get(ctx, key) - if err != nil { - return nil, err - } - return v, nil + return s.store.Get(ctx, key) } func (s *SubPrefixDb) Put(ctx context.Context, key []byte, val []byte) error { diff --git a/internal/testutil/mocks/subprefixdbmock.go b/internal/testutil/mocks/subprefixdbmock.go new file mode 100644 index 0000000..e98bcb6 --- /dev/null +++ b/internal/testutil/mocks/subprefixdbmock.go @@ -0,0 +1,21 @@ +package mocks + +import ( + "context" + + "github.com/stretchr/testify/mock" +) + +type MockSubPrefixDb struct { + mock.Mock +} + +func (m *MockSubPrefixDb) Get(ctx context.Context, key []byte) ([]byte, error) { + args := m.Called(ctx, key) + return args.Get(0).([]byte), args.Error(1) +} + +func (m *MockSubPrefixDb) Put(ctx context.Context, key, val []byte) error { + args := m.Called(ctx, key, val) + return args.Error(0) +} diff --git a/internal/testutil/testservice/TestAccountService.go b/internal/testutil/testservice/TestAccountService.go index 6332345..745b80d 100644 --- a/internal/testutil/testservice/TestAccountService.go +++ b/internal/testutil/testservice/TestAccountService.go @@ -7,6 +7,7 @@ import ( "git.grassecon.net/urdt/ussd/internal/models" "github.com/grassrootseconomics/eth-custodial/pkg/api" + dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" ) type TestAccountService struct { @@ -75,20 +76,8 @@ func (tas *TestAccountService) TrackAccountStatus(ctx context.Context, publicKey func (tas *TestAccountService) FetchVouchers(ctx context.Context, publicKey string) (*models.VoucherHoldingResponse, error) { return &models.VoucherHoldingResponse{ Ok: true, - Result: struct { - Holdings []struct { - ContractAddress string `json:"contractAddress"` - TokenSymbol string `json:"tokenSymbol"` - TokenDecimals string `json:"tokenDecimals"` - Balance string `json:"balance"` - } `json:"holdings"` - }{ - Holdings: []struct { - ContractAddress string `json:"contractAddress"` - TokenSymbol string `json:"tokenSymbol"` - TokenDecimals string `json:"tokenDecimals"` - Balance string `json:"balance"` - }{ + Result: models.VoucherResult{ + Holdings: []dataserviceapi.TokenHoldings{ { ContractAddress: "0x6CC75A06ac72eB4Db2eE22F781F5D100d8ec03ee", TokenSymbol: "SRF", diff --git a/internal/utils/vouchers.go b/internal/utils/vouchers.go new file mode 100644 index 0000000..b4fae8d --- /dev/null +++ b/internal/utils/vouchers.go @@ -0,0 +1,180 @@ +package utils + +import ( + "context" + "fmt" + "strings" + + "git.grassecon.net/urdt/ussd/internal/storage" + "git.grassecon.net/urdt/ussd/common" + dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" +) + +// VoucherMetadata helps organize data fields +type VoucherMetadata struct { + Symbols string + Balances string + Decimals string + Addresses string +} + +// ProcessVouchers converts holdings into formatted strings +func ProcessVouchers(holdings []dataserviceapi.TokenHoldings) VoucherMetadata { + var data VoucherMetadata + var symbols, balances, decimals, addresses []string + + for i, h := range holdings { + symbols = append(symbols, fmt.Sprintf("%d:%s", i+1, h.TokenSymbol)) + balances = append(balances, fmt.Sprintf("%d:%s", i+1, h.Balance)) + decimals = append(decimals, fmt.Sprintf("%d:%s", i+1, h.TokenDecimals)) + addresses = append(addresses, fmt.Sprintf("%d:%s", i+1, h.ContractAddress)) + } + + data.Symbols = strings.Join(symbols, "\n") + data.Balances = strings.Join(balances, "\n") + data.Decimals = strings.Join(decimals, "\n") + data.Addresses = strings.Join(addresses, "\n") + + return data +} + +// GetVoucherData retrieves and matches voucher data +func GetVoucherData(ctx context.Context, db storage.PrefixDb, input string) (*dataserviceapi.TokenHoldings, error) { + keys := []string{"sym", "bal", "deci", "addr"} + data := make(map[string]string) + + for _, key := range keys { + value, err := db.Get(ctx, []byte(key)) + if err != nil { + return nil, fmt.Errorf("failed to get %s: %v", key, err) + } + data[key] = string(value) + } + + symbol, balance, decimal, address := MatchVoucher(input, + data["sym"], + data["bal"], + data["deci"], + data["addr"]) + + if symbol == "" { + return nil, nil + } + + return &dataserviceapi.TokenHoldings{ + TokenSymbol: string(symbol), + Balance: string(balance), + TokenDecimals: string(decimal), + ContractAddress: string(address), + }, nil +} + +// MatchVoucher finds the matching voucher symbol, balance, decimals and contract address based on the input. +func MatchVoucher(input, symbols, balances, decimals, addresses string) (symbol, balance, decimal, address string) { + symList := strings.Split(symbols, "\n") + balList := strings.Split(balances, "\n") + decList := strings.Split(decimals, "\n") + addrList := strings.Split(addresses, "\n") + + for i, sym := range symList { + parts := strings.SplitN(sym, ":", 2) + if len(parts) != 2 { + continue + } + + if input == parts[0] || strings.EqualFold(input, parts[1]) { + symbol = parts[1] + if i < len(balList) { + balance = strings.SplitN(balList[i], ":", 2)[1] + } + if i < len(decList) { + decimal = strings.SplitN(decList[i], ":", 2)[1] + } + if i < len(addrList) { + address = strings.SplitN(addrList[i], ":", 2)[1] + } + break + } + } + return +} + +// StoreTemporaryVoucher saves voucher metadata as temporary entries in the common.DataStore. +func StoreTemporaryVoucher(ctx context.Context, store common.DataStore, sessionId string, data *dataserviceapi.TokenHoldings) error { + entries := map[common.DataTyp][]byte{ + common.DATA_TEMPORARY_SYM: []byte(data.TokenSymbol), + common.DATA_TEMPORARY_BAL: []byte(data.Balance), + common.DATA_TEMPORARY_DECIMAL: []byte(data.TokenDecimals), + common.DATA_TEMPORARY_ADDRESS: []byte(data.ContractAddress), + } + + for key, value := range entries { + if err := store.WriteEntry(ctx, sessionId, key, value); err != nil { + return err + } + } + return nil +} + +// GetTemporaryVoucherData retrieves temporary voucher metadata from the common.DataStore. +func GetTemporaryVoucherData(ctx context.Context, store common.DataStore, sessionId string) (*dataserviceapi.TokenHoldings, error) { + keys := []common.DataTyp{ + common.DATA_TEMPORARY_SYM, + common.DATA_TEMPORARY_BAL, + common.DATA_TEMPORARY_DECIMAL, + common.DATA_TEMPORARY_ADDRESS, + } + + data := &dataserviceapi.TokenHoldings{} + values := make([][]byte, len(keys)) + + for i, key := range keys { + value, err := store.ReadEntry(ctx, sessionId, key) + if err != nil { + return nil, err + } + values[i] = value + } + + data.TokenSymbol = string(values[0]) + data.Balance = string(values[1]) + data.TokenDecimals = string(values[2]) + data.ContractAddress = string(values[3]) + + return data, nil +} + +// UpdateVoucherData sets the active voucher data and clears the temporary voucher data in the common.DataStore. +func UpdateVoucherData(ctx context.Context, store common.DataStore, sessionId string, data *dataserviceapi.TokenHoldings) error { + // Active voucher data entries + activeEntries := map[common.DataTyp][]byte{ + common.DATA_ACTIVE_SYM: []byte(data.TokenSymbol), + common.DATA_ACTIVE_BAL: []byte(data.Balance), + common.DATA_ACTIVE_DECIMAL: []byte(data.TokenDecimals), + common.DATA_ACTIVE_ADDRESS: []byte(data.ContractAddress), + } + + // Clear temporary voucher data entries + tempEntries := map[common.DataTyp][]byte{ + common.DATA_TEMPORARY_SYM: []byte(""), + common.DATA_TEMPORARY_BAL: []byte(""), + common.DATA_TEMPORARY_DECIMAL: []byte(""), + common.DATA_TEMPORARY_ADDRESS: []byte(""), + } + + // Write active data + for key, value := range activeEntries { + if err := store.WriteEntry(ctx, sessionId, key, value); err != nil { + return err + } + } + + // Clear temporary data + for key, value := range tempEntries { + if err := store.WriteEntry(ctx, sessionId, key, value); err != nil { + return err + } + } + + return nil +} diff --git a/internal/utils/vouchers_test.go b/internal/utils/vouchers_test.go new file mode 100644 index 0000000..76d1815 --- /dev/null +++ b/internal/utils/vouchers_test.go @@ -0,0 +1,223 @@ +package utils + +import ( + "context" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/stretchr/testify/require" + + "git.grassecon.net/urdt/ussd/internal/storage" + "git.grassecon.net/urdt/ussd/common" + memdb "git.defalsify.org/vise.git/db/mem" + dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" +) + +// InitializeTestDb sets up and returns an in-memory database and store. +func InitializeTestDb(t *testing.T) (context.Context, *common.UserDataStore) { + ctx := context.Background() + + // Initialize memDb + db := memdb.NewMemDb() + err := db.Connect(ctx, "") + require.NoError(t, err, "Failed to connect to memDb") + + // Create common.UserDataStore with memDb + store := &common.UserDataStore{Db: db} + + t.Cleanup(func() { + db.Close() // Ensure the DB is closed after each test + }) + + return ctx, store +} + +// AssertEmptyValue checks if a value is empty/nil/zero +func AssertEmptyValue(t *testing.T, value []byte, msgAndArgs ...interface{}) { + assert.Equal(t, len(value), 0, msgAndArgs...) +} + +func TestMatchVoucher(t *testing.T) { + symbols := "1:SRF\n2:MILO" + balances := "1:100\n2:200" + decimals := "1:6\n2:4" + addresses := "1:0xd4c288865Ce\n2:0x41c188d63Qa" + + // Test for valid voucher + symbol, balance, decimal, address := MatchVoucher("2", symbols, balances, decimals, addresses) + + // Assertions for valid voucher + assert.Equal(t, "MILO", symbol) + assert.Equal(t, "200", balance) + assert.Equal(t, "4", decimal) + assert.Equal(t, "0x41c188d63Qa", address) + + // Test for non-existent voucher + symbol, balance, decimal, address = MatchVoucher("3", symbols, balances, decimals, addresses) + + // Assertions for non-match + assert.Equal(t, "", symbol) + assert.Equal(t, "", balance) + assert.Equal(t, "", decimal) + assert.Equal(t, "", address) +} + +func TestProcessVouchers(t *testing.T) { + holdings := []dataserviceapi.TokenHoldings{ + {ContractAddress: "0xd4c288865Ce", TokenSymbol: "SRF", TokenDecimals: "6", Balance: "100"}, + {ContractAddress: "0x41c188d63Qa", TokenSymbol: "MILO", TokenDecimals: "4", Balance: "200"}, + } + + expectedResult := VoucherMetadata{ + Symbols: "1:SRF\n2:MILO", + Balances: "1:100\n2:200", + Decimals: "1:6\n2:4", + Addresses: "1:0xd4c288865Ce\n2:0x41c188d63Qa", + } + + result := ProcessVouchers(holdings) + + assert.Equal(t, expectedResult, result) +} + +func TestGetVoucherData(t *testing.T) { + ctx := context.Background() + + db := memdb.NewMemDb() + err := db.Connect(ctx, "") + if err != nil { + t.Fatal(err) + } + spdb := storage.NewSubPrefixDb(db, []byte("vouchers")) + + // Test voucher data + mockData := map[string][]byte{ + "sym": []byte("1:SRF\n2:MILO"), + "bal": []byte("1:100\n2:200"), + "deci": []byte("1:6\n2:4"), + "addr": []byte("1:0xd4c288865Ce\n2:0x41c188d63Qa"), + } + + // Put the data + for key, value := range mockData { + err = spdb.Put(ctx, []byte(key), []byte(value)) + if err != nil { + t.Fatal(err) + } + } + + result, err := GetVoucherData(ctx, spdb, "1") + + assert.NoError(t, err) + assert.Equal(t, "SRF", result.TokenSymbol) + assert.Equal(t, "100", result.Balance) + assert.Equal(t, "6", result.TokenDecimals) + assert.Equal(t, "0xd4c288865Ce", result.ContractAddress) +} + +func TestStoreTemporaryVoucher(t *testing.T) { + ctx, store := InitializeTestDb(t) + sessionId := "session123" + + // Test data + voucherData := &dataserviceapi.TokenHoldings{ + TokenSymbol: "SRF", + Balance: "200", + TokenDecimals: "6", + ContractAddress: "0xd4c288865Ce0985a481Eef3be02443dF5E2e4Ea9", + } + + // Execute the function being tested + err := StoreTemporaryVoucher(ctx, store, sessionId, voucherData) + require.NoError(t, err) + + // Verify stored data + expectedEntries := map[common.DataTyp][]byte{ + common.DATA_TEMPORARY_SYM: []byte("SRF"), + common.DATA_TEMPORARY_BAL: []byte("200"), + common.DATA_TEMPORARY_DECIMAL: []byte("6"), + common.DATA_TEMPORARY_ADDRESS: []byte("0xd4c288865Ce0985a481Eef3be02443dF5E2e4Ea9"), + } + + for key, expectedValue := range expectedEntries { + storedValue, err := store.ReadEntry(ctx, sessionId, key) + require.NoError(t, err) + require.Equal(t, expectedValue, storedValue, "Mismatch for key %v", key) + } +} + +func TestGetTemporaryVoucherData(t *testing.T) { + ctx, store := InitializeTestDb(t) + sessionId := "session123" + + // Test voucher data + tempData := &dataserviceapi.TokenHoldings{ + TokenSymbol: "SRF", + Balance: "200", + TokenDecimals: "6", + ContractAddress: "0xd4c288865Ce0985a481Eef3be02443dF5E2e4Ea9", + } + + // Store the data + err := StoreTemporaryVoucher(ctx, store, sessionId, tempData) + require.NoError(t, err) + + // Execute the function being tested + data, err := GetTemporaryVoucherData(ctx, store, sessionId) + require.NoError(t, err) + require.Equal(t, tempData, data) +} + +func TestUpdateVoucherData(t *testing.T) { + ctx, store := InitializeTestDb(t) + sessionId := "session123" + + // New voucher data + newData := &dataserviceapi.TokenHoldings{ + TokenSymbol: "SRF", + Balance: "200", + TokenDecimals: "6", + ContractAddress: "0xd4c288865Ce0985a481Eef3be02443dF5E2e4Ea9", + } + + // Old temporary data + tempData := &dataserviceapi.TokenHoldings{ + TokenSymbol: "OLD", + Balance: "100", + TokenDecimals: "8", + ContractAddress: "0xold", + } + require.NoError(t, StoreTemporaryVoucher(ctx, store, sessionId, tempData)) + + // Execute update + err := UpdateVoucherData(ctx, store, sessionId, newData) + require.NoError(t, err) + + // Verify active data was stored correctly + activeEntries := map[common.DataTyp][]byte{ + common.DATA_ACTIVE_SYM: []byte(newData.TokenSymbol), + common.DATA_ACTIVE_BAL: []byte(newData.Balance), + common.DATA_ACTIVE_DECIMAL: []byte(newData.TokenDecimals), + common.DATA_ACTIVE_ADDRESS: []byte(newData.ContractAddress), + } + + for key, expectedValue := range activeEntries { + storedValue, err := store.ReadEntry(ctx, sessionId, key) + require.NoError(t, err) + require.Equal(t, expectedValue, storedValue, "Active data mismatch for key %v", key) + } + + // Verify temporary data was cleared + tempKeys := []common.DataTyp{ + common.DATA_TEMPORARY_SYM, + common.DATA_TEMPORARY_BAL, + common.DATA_TEMPORARY_DECIMAL, + common.DATA_TEMPORARY_ADDRESS, + } + + for _, key := range tempKeys { + storedValue, err := store.ReadEntry(ctx, sessionId, key) + require.NoError(t, err) + AssertEmptyValue(t, storedValue, "Temporary data not cleared for key %v", key) + } +}