diff --git a/CMakeLists.txt b/CMakeLists.txt index 4db75fe2..899c315a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,7 @@ target_sources(clio PRIVATE src/rpc/ngHandlers/NFTSellOffers.cpp src/rpc/ngHandlers/NFTHistory.cpp src/rpc/ngHandlers/LedgerData.cpp + src/rpc/ngHandlers/AccountNFTs.cpp ## RPC Methods # Account src/rpc/handlers/AccountChannels.cpp @@ -156,6 +157,7 @@ if(BUILD_TESTS) unittests/rpc/handlers/AccountOffersTest.cpp unittests/rpc/handlers/AccountInfoTest.cpp unittests/rpc/handlers/AccountChannelsTest.cpp + unittests/rpc/handlers/AccountNFTsTest.cpp unittests/rpc/handlers/BookOffersTest.cpp unittests/rpc/handlers/GatewayBalancesTest.cpp unittests/rpc/handlers/TxTest.cpp diff --git a/src/rpc/ngHandlers/AccountNFTs.cpp b/src/rpc/ngHandlers/AccountNFTs.cpp new file mode 100644 index 00000000..5c92c520 --- /dev/null +++ b/src/rpc/ngHandlers/AccountNFTs.cpp @@ -0,0 +1,143 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2023, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include + +#include + +namespace RPCng { + +AccountNFTsHandler::Result +AccountNFTsHandler::process(AccountNFTsHandler::Input input, Context const& ctx) const +{ + auto const range = sharedPtrBackend_->fetchLedgerRange(); + auto const lgrInfoOrStatus = RPC::getLedgerInfoFromHashOrSeq( + *sharedPtrBackend_, ctx.yield, input.ledgerHash, input.ledgerIndex, range->maxSequence); + + if (auto const status = std::get_if(&lgrInfoOrStatus)) + return Error{*status}; + + auto const lgrInfo = std::get(lgrInfoOrStatus); + + auto const accountID = RPC::accountFromStringStrict(input.account); + auto const accountLedgerObject = + sharedPtrBackend_->fetchLedgerObject(ripple::keylet::account(*accountID).key, lgrInfo.seq, ctx.yield); + if (!accountLedgerObject) + return Error{RPC::Status{RPC::RippledError::rpcACT_NOT_FOUND, "accountNotFound"}}; + + Output response; + response.account = input.account; + response.limit = input.limit; + response.ledgerHash = ripple::strHex(lgrInfo.hash); + response.ledgerIndex = lgrInfo.seq; + + // if a marker was passed, start at the page specified in marker. Else, + // start at the max page + auto const pageKey = + input.marker ? ripple::uint256{input.marker->c_str()} : ripple::keylet::nftpage_max(*accountID).key; + + auto const blob = sharedPtrBackend_->fetchLedgerObject(pageKey, lgrInfo.seq, ctx.yield); + if (!blob) + return response; + + std::optional page{ripple::SLE{ripple::SerialIter{blob->data(), blob->size()}, pageKey}}; + auto numPages = 0; + + while (page) + { + auto const arr = page->getFieldArray(ripple::sfNFTokens); + + for (auto const& nft : arr) + { + auto const nftokenID = nft[ripple::sfNFTokenID]; + + response.nfts.push_back(RPC::toBoostJson(nft.getJson(ripple::JsonOptions::none))); + auto& obj = response.nfts.back().as_object(); + + // Pull out the components of the nft ID. + obj[SFS(sfFlags)] = ripple::nft::getFlags(nftokenID); + obj[SFS(sfIssuer)] = to_string(ripple::nft::getIssuer(nftokenID)); + obj[SFS(sfNFTokenTaxon)] = ripple::nft::toUInt32(ripple::nft::getTaxon(nftokenID)); + obj[JS(nft_serial)] = ripple::nft::getSerial(nftokenID); + + if (std::uint16_t xferFee = {ripple::nft::getTransferFee(nftokenID)}) + obj[SFS(sfTransferFee)] = xferFee; + } + + ++numPages; + if (auto const npm = (*page)[~ripple::sfPreviousPageMin]) + { + auto const nextKey = ripple::Keylet(ripple::ltNFTOKEN_PAGE, *npm); + if (numPages == input.limit) + { + response.marker = to_string(nextKey.key); + return response; + } + auto const nextBlob = sharedPtrBackend_->fetchLedgerObject(nextKey.key, lgrInfo.seq, ctx.yield); + + page.emplace(ripple::SLE{ripple::SerialIter{nextBlob->data(), nextBlob->size()}, nextKey.key}); + } + else + { + page.reset(); + } + } + return response; +} + +void +tag_invoke(boost::json::value_from_tag, boost::json::value& jv, AccountNFTsHandler::Output const& output) +{ + jv = { + {JS(ledger_hash), output.ledgerHash}, + {JS(ledger_index), output.ledgerIndex}, + {JS(validated), output.validated}, + {JS(account), output.account}, + {JS(account_nfts), output.nfts}, + {JS(limit), output.limit}}; + if (output.marker) + jv.as_object()[JS(marker)] = *output.marker; +} + +AccountNFTsHandler::Input +tag_invoke(boost::json::value_to_tag, boost::json::value const& jv) +{ + auto const& jsonObject = jv.as_object(); + AccountNFTsHandler::Input input; + input.account = jsonObject.at(JS(account)).as_string().c_str(); + if (jsonObject.contains(JS(ledger_hash))) + input.ledgerHash = jsonObject.at(JS(ledger_hash)).as_string().c_str(); + + if (jsonObject.contains(JS(ledger_index))) + { + if (!jsonObject.at(JS(ledger_index)).is_string()) + input.ledgerIndex = jsonObject.at(JS(ledger_index)).as_int64(); + else if (jsonObject.at(JS(ledger_index)).as_string() != "validated") + input.ledgerIndex = std::stoi(jsonObject.at(JS(ledger_index)).as_string().c_str()); + } + if (jsonObject.contains(JS(limit))) + input.limit = jsonObject.at(JS(limit)).as_int64(); + + if (jsonObject.contains(JS(marker))) + input.marker = jsonObject.at(JS(marker)).as_string().c_str(); + + return input; +} + +} // namespace RPCng diff --git a/src/rpc/ngHandlers/AccountNFTs.h b/src/rpc/ngHandlers/AccountNFTs.h new file mode 100644 index 00000000..a674669c --- /dev/null +++ b/src/rpc/ngHandlers/AccountNFTs.h @@ -0,0 +1,84 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2023, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +#include +#include +#include + +namespace RPCng { +class AccountNFTsHandler +{ + std::shared_ptr sharedPtrBackend_; + +public: + struct Output + { + std::string account; + std::string ledgerHash; + uint32_t ledgerIndex; + // TODO: use better type than json + boost::json::array nfts; + uint32_t limit; + std::optional marker; + bool validated = true; + }; + + struct Input + { + std::string account; + std::optional ledgerHash; + std::optional ledgerIndex; + uint32_t limit = 100; // Limit the number of token pages to retrieve. [20,400] + std::optional marker; + }; + + using Result = RPCng::HandlerReturnType; + + AccountNFTsHandler(std::shared_ptr const& sharedPtrBackend) : sharedPtrBackend_(sharedPtrBackend) + { + } + + RpcSpecConstRef + spec() const + { + static auto const rpcSpec = RpcSpec{ + {JS(account), validation::Required{}, validation::AccountValidator}, + {JS(ledger_hash), validation::Uint256HexStringValidator}, + {JS(ledger_index), validation::LedgerIndexValidator}, + {JS(marker), validation::Uint256HexStringValidator}, + {JS(limit), validation::Type{}, validation::Between{20, 400}}, + }; + + return rpcSpec; + } + + Result + process(Input input, Context const& ctx) const; + +private: + friend void + tag_invoke(boost::json::value_from_tag, boost::json::value& jv, Output const& output); + + friend Input + tag_invoke(boost::json::value_to_tag, boost::json::value const& jv); +}; +} // namespace RPCng diff --git a/unittests/rpc/handlers/AccountNFTsTest.cpp b/unittests/rpc/handlers/AccountNFTsTest.cpp new file mode 100644 index 00000000..ed7f0539 --- /dev/null +++ b/unittests/rpc/handlers/AccountNFTsTest.cpp @@ -0,0 +1,405 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2023, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include + +#include + +constexpr static auto ACCOUNT = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"; +constexpr static auto LEDGERHASH = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; +constexpr static auto TOKENID = "000827103B94ECBB7BF0A0A6ED62B3607801A27B65F4679F4AD1D4850000C0EA"; +constexpr static auto ISSUER = "raSsG8F6KePke7sqw2MXYZ3mu7p68GvFma"; +constexpr static auto SERIAL = 49386; +constexpr static auto TAXON = 0; +constexpr static auto FLAG = 8; +constexpr static auto TXNID = "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC321"; +constexpr static auto PAGE = "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC322"; +constexpr static auto MAXSEQ = 30; +constexpr static auto MINSEQ = 10; + +using namespace RPCng; +namespace json = boost::json; +using namespace testing; + +class RPCAccountNFTsHandlerTest : public HandlerBaseTest +{ +}; + +struct AccountNFTParamTestCaseBundle +{ + std::string testName; + std::string testJson; + std::string expectedError; + std::string expectedErrorMessage; +}; + +// parameterized test cases for parameters check +struct AccountNFTParameterTest : public RPCAccountNFTsHandlerTest, + public WithParamInterface +{ + struct NameGenerator + { + template + std::string + operator()(const testing::TestParamInfo& info) const + { + auto bundle = static_cast(info.param); + return bundle.testName; + } + }; +}; + +static auto +generateTestValuesForParametersTest() +{ + return std::vector{ + { + "AccountMissing", + R"({})", + "invalidParams", + "Required field 'account' missing", + }, + { + "AccountNotString", + R"({"account": 123})", + "invalidParams", + "accountNotString", + }, + { + "AccountInvalid", + R"({"account": "123"})", + "actMalformed", + "accountMalformed", + }, + { + "LedgerHashInvalid", + R"({"account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "ledger_hash": "x"})", + "invalidParams", + "ledger_hashMalformed", + }, + { + "LedgerHashNotString", + R"({"account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "ledger_hash": 123})", + "invalidParams", + "ledger_hashNotString", + }, + { + "LedgerIndexNotInt", + R"({"account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "ledger_index": "x"})", + "invalidParams", + "ledgerIndexMalformed", + }, + { + "LimitNotInt", + R"({"account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "limit": "x"})", + "invalidParams", + "Invalid parameters.", + }, + { + "LimitOverMax", + R"({"account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "limit": 401})", + "invalidParams", + "Invalid parameters.", + }, + { + "LimitLessThanMin", + R"({"account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "limit": 19})", + "invalidParams", + "Invalid parameters.", + }, + { + "MarkerNotString", + R"({"account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "marker": 123})", + "invalidParams", + "markerNotString", + }, + { + "MarkerInvalid", + R"({"account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "marker": "12;xxx"})", + "invalidParams", + "markerMalformed", + }, + }; +} + +INSTANTIATE_TEST_CASE_P( + RPCAccountNFTsGroup1, + AccountNFTParameterTest, + ValuesIn(generateTestValuesForParametersTest()), + AccountNFTParameterTest::NameGenerator{}); + +TEST_P(AccountNFTParameterTest, InvalidParams) +{ + auto const testBundle = GetParam(); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{AccountNFTsHandler{mockBackendPtr}}; + auto const req = json::parse(testBundle.testJson); + auto const output = handler.process(req, Context{std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + std::cout << err << std::endl; + EXPECT_EQ(err.at("error").as_string(), testBundle.expectedError); + EXPECT_EQ(err.at("error_message").as_string(), testBundle.expectedErrorMessage); + }); +} + +TEST_F(RPCAccountNFTsHandlerTest, LedgerNotFoundViaHash) +{ + auto const rawBackendPtr = static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(MINSEQ); + mockBackendPtr->updateRange(MAXSEQ); + EXPECT_CALL(*rawBackendPtr, fetchLedgerByHash).Times(1); + // return empty ledgerinfo + ON_CALL(*rawBackendPtr, fetchLedgerByHash(ripple::uint256{LEDGERHASH}, _)) + .WillByDefault(Return(std::optional{})); + + auto const static input = boost::json::parse(fmt::format( + R"({{ + "account":"{}", + "ledger_hash":"{}" + }})", + ACCOUNT, + LEDGERHASH)); + auto const handler = AnyHandler{AccountNFTsHandler{mockBackendPtr}}; + runSpawn([&](auto& yield) { + auto const output = handler.process(input, Context{std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +TEST_F(RPCAccountNFTsHandlerTest, LedgerNotFoundViaStringIndex) +{ + auto constexpr seq = 12; + auto const rawBackendPtr = static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(MINSEQ); + mockBackendPtr->updateRange(MAXSEQ); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + // return empty ledgerinfo + ON_CALL(*rawBackendPtr, fetchLedgerBySequence(seq, _)).WillByDefault(Return(std::optional{})); + + auto const static input = boost::json::parse(fmt::format( + R"({{ + "account":"{}", + "ledger_index":"{}" + }})", + ACCOUNT, + seq)); + auto const handler = AnyHandler{AccountNFTsHandler{mockBackendPtr}}; + runSpawn([&](auto& yield) { + auto const output = handler.process(input, Context{std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +TEST_F(RPCAccountNFTsHandlerTest, LedgerNotFoundViaIntIndex) +{ + auto constexpr seq = 12; + auto const rawBackendPtr = static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(MINSEQ); + mockBackendPtr->updateRange(MAXSEQ); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + // return empty ledgerinfo + ON_CALL(*rawBackendPtr, fetchLedgerBySequence(seq, _)).WillByDefault(Return(std::optional{})); + + auto const static input = boost::json::parse(fmt::format( + R"({{ + "account":"{}", + "ledger_index":{} + }})", + ACCOUNT, + seq)); + auto const handler = AnyHandler{AccountNFTsHandler{mockBackendPtr}}; + runSpawn([&](auto& yield) { + auto const output = handler.process(input, Context{std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +TEST_F(RPCAccountNFTsHandlerTest, AccountNotFound) +{ + auto const rawBackendPtr = static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(MINSEQ); + mockBackendPtr->updateRange(MAXSEQ); + auto const ledgerinfo = CreateLedgerInfo(LEDGERHASH, MAXSEQ); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + + ON_CALL(*rawBackendPtr, fetchLedgerBySequence).WillByDefault(Return(ledgerinfo)); + ON_CALL(*rawBackendPtr, doFetchLedgerObject).WillByDefault(Return(std::optional{})); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObject).Times(1); + + auto const static input = boost::json::parse(fmt::format( + R"({{ + "account":"{}" + }})", + ACCOUNT)); + auto const handler = AnyHandler{AccountNFTsHandler{mockBackendPtr}}; + runSpawn([&](auto& yield) { + auto const output = handler.process(input, Context{std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "actNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "accountNotFound"); + }); +} + +TEST_F(RPCAccountNFTsHandlerTest, NormalPath) +{ + static auto const expectedOutput = fmt::format( + R"({{ + "ledger_hash":"{}", + "ledger_index":30, + "validated":true, + "account":"{}", + "account_nfts":[ + {{ + "NFTokenID":"{}", + "URI":"7777772E6F6B2E636F6D", + "Flags":{}, + "Issuer":"{}", + "NFTokenTaxon":{}, + "nft_serial":{}, + "TransferFee":10000 + }} + ], + "limit":100 + }})", + LEDGERHASH, + ACCOUNT, + TOKENID, + FLAG, + ISSUER, + TAXON, + SERIAL); + auto const rawBackendPtr = static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(MINSEQ); + mockBackendPtr->updateRange(MAXSEQ); + auto const ledgerinfo = CreateLedgerInfo(LEDGERHASH, MAXSEQ); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + ON_CALL(*rawBackendPtr, fetchLedgerBySequence).WillByDefault(Return(ledgerinfo)); + + auto const accountObject = CreateAccountRootObject(ACCOUNT, 0, 1, 10, 2, TXNID, 3); + auto const accountID = GetAccountIDWithString(ACCOUNT); + ON_CALL(*rawBackendPtr, doFetchLedgerObject(ripple::keylet::account(accountID).key, 30, _)) + .WillByDefault(Return(accountObject.getSerializer().peekData())); + + auto const firstPage = ripple::keylet::nftpage_max(accountID).key; + auto const pageObject = + CreateNFTTokenPage(std::vector{std::make_pair(TOKENID, "www.ok.com")}, std::nullopt); + ON_CALL(*rawBackendPtr, doFetchLedgerObject(firstPage, 30, _)) + .WillByDefault(Return(pageObject.getSerializer().peekData())); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObject).Times(2); + + auto const static input = boost::json::parse(fmt::format( + R"({{ + "account":"{}" + }})", + ACCOUNT)); + auto const handler = AnyHandler{AccountNFTsHandler{mockBackendPtr}}; + runSpawn([&](auto& yield) { + auto const output = handler.process(input, Context{std::ref(yield)}); + ASSERT_TRUE(output); + std::cout << output.value() << std::endl; + EXPECT_EQ(*output, json::parse(expectedOutput)); + }); +} + +TEST_F(RPCAccountNFTsHandlerTest, Limit) +{ + static auto constexpr limit = 20; + auto const rawBackendPtr = static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(MINSEQ); + mockBackendPtr->updateRange(MAXSEQ); + auto const ledgerinfo = CreateLedgerInfo(LEDGERHASH, MAXSEQ); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + ON_CALL(*rawBackendPtr, fetchLedgerBySequence).WillByDefault(Return(ledgerinfo)); + + auto const accountObject = CreateAccountRootObject(ACCOUNT, 0, 1, 10, 2, TXNID, 3); + auto const accountID = GetAccountIDWithString(ACCOUNT); + ON_CALL(*rawBackendPtr, doFetchLedgerObject(ripple::keylet::account(accountID).key, 30, _)) + .WillByDefault(Return(accountObject.getSerializer().peekData())); + + auto const firstPage = ripple::keylet::nftpage_max(accountID).key; + auto const pageObject = + CreateNFTTokenPage(std::vector{std::make_pair(TOKENID, "www.ok.com")}, firstPage); + ON_CALL(*rawBackendPtr, doFetchLedgerObject(firstPage, 30, _)) + .WillByDefault(Return(pageObject.getSerializer().peekData())); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObject).Times(1 + limit); + + auto const static input = boost::json::parse(fmt::format( + R"({{ + "account":"{}", + "limit":{} + }})", + ACCOUNT, + limit)); + auto const handler = AnyHandler{AccountNFTsHandler{mockBackendPtr}}; + runSpawn([&](auto& yield) { + auto const output = handler.process(input, Context{std::ref(yield)}); + ASSERT_TRUE(output); + EXPECT_EQ(output->as_object().at("account_nfts").as_array().size(), 20); + EXPECT_EQ(output->as_object().at("marker").as_string(), ripple::strHex(firstPage)); + }); +} + +TEST_F(RPCAccountNFTsHandlerTest, Marker) +{ + auto const rawBackendPtr = static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(MINSEQ); + mockBackendPtr->updateRange(MAXSEQ); + auto const ledgerinfo = CreateLedgerInfo(LEDGERHASH, MAXSEQ); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + ON_CALL(*rawBackendPtr, fetchLedgerBySequence).WillByDefault(Return(ledgerinfo)); + + auto const accountObject = CreateAccountRootObject(ACCOUNT, 0, 1, 10, 2, TXNID, 3); + auto const accountID = GetAccountIDWithString(ACCOUNT); + ON_CALL(*rawBackendPtr, doFetchLedgerObject(ripple::keylet::account(accountID).key, 30, _)) + .WillByDefault(Return(accountObject.getSerializer().peekData())); + + auto const pageObject = + CreateNFTTokenPage(std::vector{std::make_pair(TOKENID, "www.ok.com")}, std::nullopt); + ON_CALL(*rawBackendPtr, doFetchLedgerObject(ripple::uint256{PAGE}, 30, _)) + .WillByDefault(Return(pageObject.getSerializer().peekData())); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObject).Times(2); + + auto const static input = boost::json::parse(fmt::format( + R"({{ + "account":"{}", + "marker":"{}" + }})", + ACCOUNT, + PAGE)); + auto const handler = AnyHandler{AccountNFTsHandler{mockBackendPtr}}; + runSpawn([&](auto& yield) { + auto const output = handler.process(input, Context{std::ref(yield)}); + ASSERT_TRUE(output); + EXPECT_EQ(output->as_object().at("account_nfts").as_array().size(), 1); + }); +} diff --git a/unittests/util/TestObject.cpp b/unittests/util/TestObject.cpp index 273c1977..e14e88be 100644 --- a/unittests/util/TestObject.cpp +++ b/unittests/util/TestObject.cpp @@ -481,9 +481,33 @@ CreateSignerLists(std::vector> const& signers) entry.setAccountID(ripple::sfAccount, GetAccountIDWithString(signer.first)); entry.setFieldU16(ripple::sfSignerWeight, signer.second); quorum += signer.second; - list.push_back(entry); + list.push_back(std::move(entry)); } signerlists.setFieldU32(ripple::sfSignerQuorum, quorum); signerlists.setFieldArray(ripple::sfSignerEntries, list); return signerlists; } + +ripple::STObject +CreateNFTTokenPage( + std::vector> const& tokens, + std::optional previousPage) +{ + auto tokenPage = ripple::STObject(ripple::sfLedgerEntry); + tokenPage.setFieldU16(ripple::sfLedgerEntryType, ripple::ltNFTOKEN_PAGE); + tokenPage.setFieldU32(ripple::sfFlags, 0); + tokenPage.setFieldH256(ripple::sfPreviousTxnID, ripple::uint256()); + tokenPage.setFieldU32(ripple::sfPreviousTxnLgrSeq, 0); + if (previousPage) + tokenPage.setFieldH256(ripple::sfPreviousPageMin, *previousPage); + ripple::STArray list; + for (auto const& token : tokens) + { + auto entry = ripple::STObject(ripple::sfNFToken); + entry.setFieldH256(ripple::sfNFTokenID, ripple::uint256{token.first.c_str()}); + entry.setFieldVL(ripple::sfURI, ripple::Slice(token.second.c_str(), token.second.size())); + list.push_back(std::move(entry)); + } + tokenPage.setFieldArray(ripple::sfNFTokens, list); + return tokenPage; +} diff --git a/unittests/util/TestObject.h b/unittests/util/TestObject.h index 9102b159..fc5fcb96 100644 --- a/unittests/util/TestObject.h +++ b/unittests/util/TestObject.h @@ -212,5 +212,10 @@ CreateNFTBuyOffer(std::string_view tokenID, std::string_view account); [[nodiscard]] ripple::STObject CreateNFTSellOffer(std::string_view tokenID, std::string_view account); -ripple::STObject +[[nodiscard]] ripple::STObject CreateSignerLists(std::vector> const& signers); + +[[nodiscard]] ripple::STObject +CreateNFTTokenPage( + std::vector> const& tokens, + std::optional previousPage);