From 0bc84fefbf1de3188e0b1f0580afb17d89e1a0bd Mon Sep 17 00:00:00 2001 From: cyan317 <120398799+cindyyan317@users.noreply.github.com> Date: Thu, 13 Apr 2023 14:14:11 +0100 Subject: [PATCH] Subscribe handler (#591) Fixes #593 --- CMakeLists.txt | 1 + src/rpc/common/Types.h | 3 +- src/rpc/common/Validators.cpp | 69 +- src/rpc/common/Validators.h | 18 + src/rpc/ngHandlers/Subscribe.h | 248 +++++ src/subscriptions/SubscriptionManager.cpp | 29 +- src/subscriptions/SubscriptionManager.h | 50 +- unittests/rpc/BaseTests.cpp | 91 ++ .../rpc/handlers/AccountChannelsTest.cpp | 2 +- unittests/rpc/handlers/AccountInfoTest.cpp | 4 +- unittests/rpc/handlers/AccountLinesTest.cpp | 2 +- unittests/rpc/handlers/AccountOffersTest.cpp | 2 +- unittests/rpc/handlers/BookOffersTest.cpp | 2 +- .../rpc/handlers/GatewayBalancesTest.cpp | 2 +- unittests/rpc/handlers/NoRippleCheckTest.cpp | 2 +- unittests/rpc/handlers/SubscribeTest.cpp | 885 ++++++++++++++++++ unittests/util/Fixtures.h | 1 + 17 files changed, 1365 insertions(+), 46 deletions(-) create mode 100644 src/rpc/ngHandlers/Subscribe.h create mode 100644 unittests/rpc/handlers/SubscribeTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c4a07c60..c89892f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -169,6 +169,7 @@ if(BUILD_TESTS) unittests/rpc/handlers/NFTBuyOffersTest.cpp unittests/rpc/handlers/NFTSellOffersTest.cpp unittests/rpc/handlers/NFTHistoryTest.cpp + unittests/rpc/handlers/SubscribeTest.cpp # Backend unittests/backend/cassandra/BaseTests.cpp unittests/backend/cassandra/BackendTests.cpp diff --git a/src/rpc/common/Types.h b/src/rpc/common/Types.h index 586fa3bc..dca3482b 100644 --- a/src/rpc/common/Types.h +++ b/src/rpc/common/Types.h @@ -26,6 +26,7 @@ #include class WsBase; +class SubscriptionManager; namespace RPCng { /** @@ -64,7 +65,7 @@ struct Context // TODO: we shall change yield_context to const yield_context after we // update backend interfaces to use const& yield std::reference_wrapper yield; - std::shared_ptr session; + std::shared_ptr session; bool isAdmin = false; std::string clientIp; }; diff --git a/src/rpc/common/Validators.cpp b/src/rpc/common/Validators.cpp index a554f514..97173f42 100644 --- a/src/rpc/common/Validators.cpp +++ b/src/rpc/common/Validators.cpp @@ -134,7 +134,7 @@ CustomValidator AccountValidator = // remove all old handler, this function can be moved to here if (!RPC::accountFromStringStrict(value.as_string().c_str())) { - return Error{RPC::Status{RPC::RippledError::rpcINVALID_PARAMS, std::string(key) + "Malformed"}}; + return Error{RPC::Status{RPC::RippledError::rpcACT_MALFORMED, std::string(key) + "Malformed"}}; } return MaybeError{}; }}; @@ -199,4 +199,71 @@ CustomValidator IssuerValidator = return MaybeError{}; }}; +CustomValidator SubscribeStreamValidator = + CustomValidator{[](boost::json::value const& value, std::string_view key) -> MaybeError { + static std::unordered_set const validStreams = { + "ledger", "transactions", "transactions_proposed", "book_changes", "manifests", "validations"}; + if (!value.is_array()) + { + return Error{RPC::Status{RPC::RippledError::rpcINVALID_PARAMS, std::string(key) + "NotArray"}}; + } + for (auto const& v : value.as_array()) + { + if (!v.is_string()) + return Error{RPC::Status{RPC::RippledError::rpcINVALID_PARAMS, "streamNotString"}}; + if (not validStreams.contains(v.as_string().c_str())) + return Error{RPC::Status{RPC::RippledError::rpcSTREAM_MALFORMED}}; + } + return MaybeError{}; + }}; + +CustomValidator SubscribeAccountsValidator = + CustomValidator{[](boost::json::value const& value, std::string_view key) -> MaybeError { + if (!value.is_array()) + return Error{RPC::Status{RPC::RippledError::rpcINVALID_PARAMS, std::string(key) + "NotArray"}}; + if (value.as_array().size() == 0) + return Error{RPC::Status{RPC::RippledError::rpcACT_MALFORMED, std::string(key) + " malformed."}}; + + for (auto const& v : value.as_array()) + { + auto obj = boost::json::object(); + auto const keyItem = std::string(key) + "'sItem"; + obj[keyItem] = v; + if (auto const err = AccountValidator.verify(obj, keyItem); !err) + return err; + } + return MaybeError{}; + }}; + +CustomValidator SubscribeBooksValidator = + CustomValidator{[](boost::json::value const& value, std::string_view key) -> MaybeError { + if (!value.is_array()) + return Error{RPC::Status{RPC::RippledError::rpcINVALID_PARAMS, std::string(key) + "NotArray"}}; + for (auto const& book : value.as_array()) + { + if (!book.is_object()) + return Error{RPC::Status{RPC::RippledError::rpcINVALID_PARAMS, std::string(key) + "ItemNotObject"}}; + if (book.as_object().contains("both") && !book.as_object().at("both").is_bool()) + { + return Error{RPC::Status{RPC::RippledError::rpcINVALID_PARAMS, "bothNotBool"}}; + } + + if (book.as_object().contains("snapshot") && !book.as_object().at("snapshot").is_bool()) + { + return Error{RPC::Status{RPC::RippledError::rpcINVALID_PARAMS, "snapshotNotBool"}}; + } + + if (book.as_object().contains("taker")) + { + if (auto const err = AccountValidator.verify(book.as_object(), "taker"); !err) + return err; + } + + auto const parsedBook = RPC::parseBook(book.as_object()); + if (auto const status = std::get_if(&parsedBook)) + return Error(*status); + } + return MaybeError{}; + }}; + } // namespace RPCng::validation diff --git a/src/rpc/common/Validators.h b/src/rpc/common/Validators.h index b7b1da1e..8e1b75af 100644 --- a/src/rpc/common/Validators.h +++ b/src/rpc/common/Validators.h @@ -482,4 +482,22 @@ extern CustomValidator CurrencyValidator; */ extern CustomValidator IssuerValidator; +/** + * @brief Provide a validator for validating valid streams used in + * subscribe/unsubscribe + */ +extern CustomValidator SubscribeStreamValidator; + +/** + * @brief Provide a validator for validating valid accounts used in + * subscribe/unsubscribe + */ +extern CustomValidator SubscribeAccountsValidator; + +/** + * @brief Provide a validator for validating valid books used in + * subscribe/unsubscribe + */ +extern CustomValidator SubscribeBooksValidator; + } // namespace RPCng::validation diff --git a/src/rpc/ngHandlers/Subscribe.h b/src/rpc/ngHandlers/Subscribe.h new file mode 100644 index 00000000..73a99eca --- /dev/null +++ b/src/rpc/ngHandlers/Subscribe.h @@ -0,0 +1,248 @@ +//------------------------------------------------------------------------------ +/* + 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 { +template +class BaseSubscribeHandler +{ + std::shared_ptr sharedPtrBackend_; + std::shared_ptr subscriptions_; + +public: + struct Output + { + // response of stream "ledger" + // TODO: use better type than json, this type will be used in the stream as well + std::optional ledger; + // books returns nothing by default, if snapshot is true, it returns offers + // TODO: use better type than json + std::optional offers; + bool validated = true; + }; + + struct OrderBook + { + ripple::Book book; + std::optional taker; + bool snapshot = false; + bool both = false; + }; + + struct Input + { + std::optional> accounts; + std::optional> streams; + std::optional> accountsProposed; + std::optional> books; + }; + + using Result = RPCng::HandlerReturnType; + + BaseSubscribeHandler( + std::shared_ptr const& sharedPtrBackend, + std::shared_ptr const& subscriptions) + : sharedPtrBackend_(sharedPtrBackend), subscriptions_(subscriptions) + { + } + + RpcSpecConstRef + spec() const + { + static auto const rpcSpec = RpcSpec{ + {JS(streams), validation::SubscribeStreamValidator}, + {JS(accounts), validation::SubscribeAccountsValidator}, + {JS(accounts_proposed), validation::SubscribeAccountsValidator}, + {JS(books), validation::SubscribeBooksValidator}}; + return rpcSpec; + } + + Result + process(Input input, Context const& ctx) const + { + Output output; + if (input.streams) + { + auto const ledger = subscribeToStreams(ctx.yield, *(input.streams), ctx.session); + if (!ledger.empty()) + output.ledger = ledger; + } + if (input.accounts) + subscribeToAccounts(*(input.accounts), ctx.session); + if (input.accountsProposed) + subscribeToAccountsProposed(*(input.accountsProposed), ctx.session); + if (input.books) + { + auto const offers = subscribeToBooks(*(input.books), ctx.session, ctx.yield); + if (!offers.empty()) + output.offers = offers; + }; + return output; + } + +private: + boost::json::object + subscribeToStreams( + boost::asio::yield_context& yield, + std::vector const& streams, + std::shared_ptr const& session) const + { + boost::json::object response; + for (auto const& stream : streams) + { + if (stream == "ledger") + response = subscriptions_->subLedger(yield, session); + else if (stream == "transactions") + subscriptions_->subTransactions(session); + else if (stream == "transactions_proposed") + subscriptions_->subProposedTransactions(session); + else if (stream == "validations") + subscriptions_->subValidation(session); + else if (stream == "manifests") + subscriptions_->subManifest(session); + else if (stream == "book_changes") + subscriptions_->subBookChanges(session); + } + return response; + } + + void + subscribeToAccounts(std::vector const& accounts, std::shared_ptr const& session) const + { + for (auto const& account : accounts) + { + auto const accountID = RPC::accountFromStringStrict(account); + subscriptions_->subAccount(*accountID, session); + } + } + void + subscribeToAccountsProposed(std::vector const& accounts, std::shared_ptr const& session) const + { + for (auto const& account : accounts) + { + auto const accountID = RPC::accountFromStringStrict(account); + subscriptions_->subProposedAccount(*accountID, session); + } + } + + boost::json::array + subscribeToBooks( + std::vector const& books, + std::shared_ptr const& session, + boost::asio::yield_context& yield) const + { + boost::json::array snapshots; + std::optional rng; + static auto constexpr fetchLimit = 200; + + for (auto const& internalBook : books) + { + if (internalBook.snapshot) + { + if (!rng) + rng = sharedPtrBackend_->fetchLedgerRange(); + auto const getOrderBook = [&](auto const& book) { + auto const bookBase = getBookBase(book); + auto const [offers, _] = + sharedPtrBackend_->fetchBookOffers(bookBase, rng->maxSequence, fetchLimit, yield); + + // the taker is not really uesed, same issue with + // https://github.com/XRPLF/xrpl-dev-portal/issues/1818 + auto const takerID = + internalBook.taker ? RPC::accountFromStringStrict(*(internalBook.taker)) : beast::zero; + + auto const orderBook = + RPC::postProcessOrderBook(offers, book, *takerID, *sharedPtrBackend_, rng->maxSequence, yield); + std::copy(orderBook.begin(), orderBook.end(), std::back_inserter(snapshots)); + }; + getOrderBook(internalBook.book); + if (internalBook.both) + getOrderBook(ripple::reversed(internalBook.book)); + } + + subscriptions_->subBook(internalBook.book, session); + if (internalBook.both) + subscriptions_->subBook(ripple::reversed(internalBook.book), session); + } + + return snapshots; + } + + friend void + tag_invoke(boost::json::value_from_tag, boost::json::value& jv, Output const& output) + { + jv = output.ledger ? *(output.ledger) : boost::json::object(); + if (output.offers) + jv.as_object().emplace(JS(offers), *(output.offers)); + } + + friend Input + tag_invoke(boost::json::value_to_tag, boost::json::value const& jv) + { + auto const& jsonObject = jv.as_object(); + Input input; + if (auto const& streams = jsonObject.find(JS(streams)); streams != jsonObject.end()) + { + input.streams = std::vector(); + for (auto const& stream : streams->value().as_array()) + input.streams->push_back(stream.as_string().c_str()); + } + if (auto const& accounts = jsonObject.find(JS(accounts)); accounts != jsonObject.end()) + { + input.accounts = std::vector(); + for (auto const& account : accounts->value().as_array()) + input.accounts->push_back(account.as_string().c_str()); + } + if (auto const& accountsProposed = jsonObject.find(JS(accounts_proposed)); accountsProposed != jsonObject.end()) + { + input.accountsProposed = std::vector(); + for (auto const& account : accountsProposed->value().as_array()) + input.accountsProposed->push_back(account.as_string().c_str()); + } + if (auto const& books = jsonObject.find(JS(books)); books != jsonObject.end()) + { + input.books = std::vector(); + for (auto const& book : books->value().as_array()) + { + auto const& bookObject = book.as_object(); + OrderBook internalBook; + if (auto const& taker = bookObject.find(JS(taker)); taker != bookObject.end()) + internalBook.taker = taker->value().as_string().c_str(); + if (auto const& both = bookObject.find(JS(both)); both != bookObject.end()) + internalBook.both = both->value().as_bool(); + if (auto const& snapshot = bookObject.find(JS(snapshot)); snapshot != bookObject.end()) + internalBook.snapshot = snapshot->value().as_bool(); + auto const parsedBookMaybe = RPC::parseBook(book.as_object()); + internalBook.book = std::get(parsedBookMaybe); + input.books->push_back(internalBook); + } + } + return input; + } +}; + +using SubscribeHandler = BaseSubscribeHandler; + +} // namespace RPCng diff --git a/src/subscriptions/SubscriptionManager.cpp b/src/subscriptions/SubscriptionManager.cpp index 5da953f3..e387bf3a 100644 --- a/src/subscriptions/SubscriptionManager.cpp +++ b/src/subscriptions/SubscriptionManager.cpp @@ -66,7 +66,7 @@ getLedgerPubMessage( boost::json::object SubscriptionManager::subLedger(boost::asio::yield_context& yield, std::shared_ptr session) { - subscribeHelper(session, ledgerSubscribers_, [this](session_ptr session) { unsubLedger(session); }); + subscribeHelper(session, ledgerSubscribers_, [this](SessionPtrType session) { unsubLedger(session); }); auto ledgerRange = backend_->fetchLedgerRange(); assert(ledgerRange); @@ -94,7 +94,7 @@ SubscriptionManager::unsubLedger(std::shared_ptr session) void SubscriptionManager::subTransactions(std::shared_ptr session) { - subscribeHelper(session, txSubscribers_, [this](session_ptr session) { unsubTransactions(session); }); + subscribeHelper(session, txSubscribers_, [this](SessionPtrType session) { unsubTransactions(session); }); } void @@ -104,15 +104,15 @@ SubscriptionManager::unsubTransactions(std::shared_ptr session) } void -SubscriptionManager::subAccount(ripple::AccountID const& account, std::shared_ptr& session) +SubscriptionManager::subAccount(ripple::AccountID const& account, std::shared_ptr const& session) { - subscribeHelper(session, account, accountSubscribers_, [this, account](session_ptr session) { + subscribeHelper(session, account, accountSubscribers_, [this, account](SessionPtrType session) { unsubAccount(account, session); }); } void -SubscriptionManager::unsubAccount(ripple::AccountID const& account, std::shared_ptr& session) +SubscriptionManager::unsubAccount(ripple::AccountID const& account, std::shared_ptr const& session) { accountSubscribers_.unsubscribe(session, account); } @@ -120,7 +120,8 @@ SubscriptionManager::unsubAccount(ripple::AccountID const& account, std::shared_ void SubscriptionManager::subBook(ripple::Book const& book, std::shared_ptr session) { - subscribeHelper(session, book, bookSubscribers_, [this, book](session_ptr session) { unsubBook(book, session); }); + subscribeHelper( + session, book, bookSubscribers_, [this, book](SessionPtrType session) { unsubBook(book, session); }); } void @@ -132,7 +133,7 @@ SubscriptionManager::unsubBook(ripple::Book const& book, std::shared_ptr void SubscriptionManager::subBookChanges(std::shared_ptr session) { - subscribeHelper(session, bookChangesSubscribers_, [this](session_ptr session) { unsubBookChanges(session); }); + subscribeHelper(session, bookChangesSubscribers_, [this](SessionPtrType session) { unsubBookChanges(session); }); } void @@ -281,7 +282,7 @@ SubscriptionManager::forwardValidation(boost::json::object const& response) void SubscriptionManager::subProposedAccount(ripple::AccountID const& account, std::shared_ptr session) { - subscribeHelper(session, account, accountProposedSubscribers_, [this, account](session_ptr session) { + subscribeHelper(session, account, accountProposedSubscribers_, [this, account](SessionPtrType session) { unsubProposedAccount(account, session); }); } @@ -289,7 +290,7 @@ SubscriptionManager::subProposedAccount(ripple::AccountID const& account, std::s void SubscriptionManager::subManifest(std::shared_ptr session) { - subscribeHelper(session, manifestSubscribers_, [this](session_ptr session) { unsubManifest(session); }); + subscribeHelper(session, manifestSubscribers_, [this](SessionPtrType session) { unsubManifest(session); }); } void @@ -301,7 +302,7 @@ SubscriptionManager::unsubManifest(std::shared_ptr session) void SubscriptionManager::subValidation(std::shared_ptr session) { - subscribeHelper(session, validationsSubscribers_, [this](session_ptr session) { unsubValidation(session); }); + subscribeHelper(session, validationsSubscribers_, [this](SessionPtrType session) { unsubValidation(session); }); } void @@ -320,7 +321,7 @@ void SubscriptionManager::subProposedTransactions(std::shared_ptr session) { subscribeHelper( - session, txProposedSubscribers_, [this](session_ptr session) { unsubProposedTransactions(session); }); + session, txProposedSubscribers_, [this](SessionPtrType session) { unsubProposedTransactions(session); }); } void @@ -328,17 +329,19 @@ SubscriptionManager::unsubProposedTransactions(std::shared_ptr session) { txProposedSubscribers_.unsubscribe(session); } + void -SubscriptionManager::subscribeHelper(std::shared_ptr& session, Subscription& subs, CleanupFunction&& func) +SubscriptionManager::subscribeHelper(std::shared_ptr const& session, Subscription& subs, CleanupFunction&& func) { subs.subscribe(session); std::scoped_lock lk(cleanupMtx_); cleanupFuncs_[session].push_back(std::move(func)); } + template void SubscriptionManager::subscribeHelper( - std::shared_ptr& session, + std::shared_ptr const& session, Key const& k, SubscriptionMap& subs, CleanupFunction&& func) diff --git a/src/subscriptions/SubscriptionManager.h b/src/subscriptions/SubscriptionManager.h index ccd4158f..67c89f82 100644 --- a/src/subscriptions/SubscriptionManager.h +++ b/src/subscriptions/SubscriptionManager.h @@ -189,7 +189,7 @@ SubscriptionMap::publish(std::shared_ptr const& message, Key const class SubscriptionManager { - using session_ptr = std::shared_ptr; + using SessionPtrType = std::shared_ptr; clio::Logger log_{"Subscriptions"}; std::vector workers_; @@ -251,7 +251,7 @@ public: } boost::json::object - subLedger(boost::asio::yield_context& yield, session_ptr session); + subLedger(boost::asio::yield_context& yield, SessionPtrType session); void pubLedger( @@ -264,28 +264,28 @@ public: pubBookChanges(ripple::LedgerInfo const& lgrInfo, std::vector const& transactions); void - unsubLedger(session_ptr session); + unsubLedger(SessionPtrType session); void - subTransactions(session_ptr session); + subTransactions(SessionPtrType session); void - unsubTransactions(session_ptr session); + unsubTransactions(SessionPtrType session); void pubTransaction(Backend::TransactionAndMetadata const& blobs, ripple::LedgerInfo const& lgrInfo); void - subAccount(ripple::AccountID const& account, session_ptr& session); + subAccount(ripple::AccountID const& account, SessionPtrType const& session); void - unsubAccount(ripple::AccountID const& account, session_ptr& session); + unsubAccount(ripple::AccountID const& account, SessionPtrType const& session); void - subBook(ripple::Book const& book, session_ptr session); + subBook(ripple::Book const& book, SessionPtrType session); void - unsubBook(ripple::Book const& book, session_ptr session); + unsubBook(ripple::Book const& book, SessionPtrType session); void subBookChanges(std::shared_ptr session); @@ -294,16 +294,16 @@ public: unsubBookChanges(std::shared_ptr session); void - subManifest(session_ptr session); + subManifest(SessionPtrType session); void - unsubManifest(session_ptr session); + unsubManifest(SessionPtrType session); void - subValidation(session_ptr session); + subValidation(SessionPtrType session); void - unsubValidation(session_ptr session); + unsubValidation(SessionPtrType session); void forwardProposedTransaction(boost::json::object const& response); @@ -315,19 +315,19 @@ public: forwardValidation(boost::json::object const& response); void - subProposedAccount(ripple::AccountID const& account, session_ptr session); + subProposedAccount(ripple::AccountID const& account, SessionPtrType session); void - unsubProposedAccount(ripple::AccountID const& account, session_ptr session); + unsubProposedAccount(ripple::AccountID const& account, SessionPtrType session); void - subProposedTransactions(session_ptr session); + subProposedTransactions(SessionPtrType session); void - unsubProposedTransactions(session_ptr session); + unsubProposedTransactions(SessionPtrType session); void - cleanup(session_ptr session); + cleanup(SessionPtrType session); boost::json::object report() const @@ -349,16 +349,20 @@ public: private: void - sendAll(std::string const& pubMsg, std::unordered_set& subs); + sendAll(std::string const& pubMsg, std::unordered_set& subs); - using CleanupFunction = std::function; + using CleanupFunction = std::function; void - subscribeHelper(std::shared_ptr& session, Subscription& subs, CleanupFunction&& func); + subscribeHelper(std::shared_ptr const& session, Subscription& subs, CleanupFunction&& func); template void - subscribeHelper(std::shared_ptr& session, Key const& k, SubscriptionMap& subs, CleanupFunction&& func); + subscribeHelper( + std::shared_ptr const& session, + Key const& k, + SubscriptionMap& subs, + CleanupFunction&& func); /** * This is how we chose to cleanup subscriptions that have been closed. @@ -367,5 +371,5 @@ private: * closed. */ std::mutex cleanupMtx_; - std::unordered_map> cleanupFuncs_ = {}; + std::unordered_map> cleanupFuncs_ = {}; }; diff --git a/unittests/rpc/BaseTests.cpp b/unittests/rpc/BaseTests.cpp index 61aa0b55..3db3e203 100644 --- a/unittests/rpc/BaseTests.cpp +++ b/unittests/rpc/BaseTests.cpp @@ -440,3 +440,94 @@ TEST_F(RPCBaseTest, IssuerValidator) err = spec.validate(failingInput); ASSERT_FALSE(err); } + +TEST_F(RPCBaseTest, SubscribeStreamValidator) +{ + auto const spec = RpcSpec{{"streams", SubscribeStreamValidator}}; + auto passingInput = json::parse( + R"({ + "streams": + [ + "ledger", + "transactions_proposed", + "validations", + "transactions", + "manifests", + "transactions", + "book_changes" + ] + })"); + ASSERT_TRUE(spec.validate(passingInput)); + + auto failingInput = json::parse(R"({ "streams": 256})"); + auto err = spec.validate(failingInput); + ASSERT_FALSE(err); + + failingInput = json::parse(R"({ "streams": ["test"]})"); + err = spec.validate(failingInput); + ASSERT_FALSE(err); + + failingInput = json::parse(R"({ "streams": [123]})"); + err = spec.validate(failingInput); + ASSERT_FALSE(err); +} + +TEST_F(RPCBaseTest, SubscribeAccountsValidator) +{ + auto const spec = RpcSpec{{"accounts", SubscribeAccountsValidator}}; + auto passingInput = + json::parse(R"({ "accounts": ["rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn","rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun"]})"); + ASSERT_TRUE(spec.validate(passingInput)); + + auto failingInput = json::parse(R"({ "accounts": 256})"); + auto err = spec.validate(failingInput); + ASSERT_FALSE(err); + + failingInput = json::parse(R"({ "accounts": ["test"]})"); + err = spec.validate(failingInput); + ASSERT_FALSE(err); + + failingInput = json::parse(R"({ "accounts": [123]})"); + err = spec.validate(failingInput); + ASSERT_FALSE(err); +} + +TEST_F(RPCBaseTest, SubscribeBooksValidator) +{ + auto const spec = RpcSpec{{"books", SubscribeBooksValidator}}; + auto passingInput = json::parse( + R"({ + "books": [{ + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": + { + "currency": "USD", + "issuer": "rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq" + } + }] + })"); + ASSERT_TRUE(spec.validate(passingInput)); + + auto failingInput = json::parse(R"({ "books": 256})"); + auto err = spec.validate(failingInput); + ASSERT_FALSE(err); + + failingInput = json::parse(R"({ "books": ["test"]})"); + err = spec.validate(failingInput); + ASSERT_FALSE(err); + + failingInput = json::parse( + R"({ + "books": [{ + "taker_pays": + { + "currency": "XRP" + } + }] + })"); + err = spec.validate(failingInput); + ASSERT_FALSE(err); +} diff --git a/unittests/rpc/handlers/AccountChannelsTest.cpp b/unittests/rpc/handlers/AccountChannelsTest.cpp index 0b9d2f6c..2b0974e2 100644 --- a/unittests/rpc/handlers/AccountChannelsTest.cpp +++ b/unittests/rpc/handlers/AccountChannelsTest.cpp @@ -199,7 +199,7 @@ TEST_F(RPCAccountHandlerTest, AccountInvalidFormat) 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(), "invalidParams"); + EXPECT_EQ(err.at("error").as_string(), "actMalformed"); EXPECT_EQ(err.at("error_message").as_string(), "accountMalformed"); }); } diff --git a/unittests/rpc/handlers/AccountInfoTest.cpp b/unittests/rpc/handlers/AccountInfoTest.cpp index e6537503..220dc9dc 100644 --- a/unittests/rpc/handlers/AccountInfoTest.cpp +++ b/unittests/rpc/handlers/AccountInfoTest.cpp @@ -68,9 +68,9 @@ generateTestValuesForParametersTest() return std::vector{ AccountInfoParamTestCaseBundle{"MissingAccountAndIdent", R"({})", "actMalformed", "Account malformed."}, AccountInfoParamTestCaseBundle{"AccountNotString", R"({"account":1})", "invalidParams", "accountNotString"}, - AccountInfoParamTestCaseBundle{"AccountInvalid", R"({"account":"xxx"})", "invalidParams", "accountMalformed"}, + AccountInfoParamTestCaseBundle{"AccountInvalid", R"({"account":"xxx"})", "actMalformed", "accountMalformed"}, AccountInfoParamTestCaseBundle{"IdentNotString", R"({"ident":1})", "invalidParams", "identNotString"}, - AccountInfoParamTestCaseBundle{"IdentInvalid", R"({"ident":"xxx"})", "invalidParams", "identMalformed"}, + AccountInfoParamTestCaseBundle{"IdentInvalid", R"({"ident":"xxx"})", "actMalformed", "identMalformed"}, AccountInfoParamTestCaseBundle{ "SignerListsInvalid", R"({"ident":"rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", "signer_lists":1})", diff --git a/unittests/rpc/handlers/AccountLinesTest.cpp b/unittests/rpc/handlers/AccountLinesTest.cpp index beedb493..5c1067de 100644 --- a/unittests/rpc/handlers/AccountLinesTest.cpp +++ b/unittests/rpc/handlers/AccountLinesTest.cpp @@ -205,7 +205,7 @@ TEST_F(RPCAccountLinesHandlerTest, AccountInvalidFormat) 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(), "invalidParams"); + EXPECT_EQ(err.at("error").as_string(), "actMalformed"); EXPECT_EQ(err.at("error_message").as_string(), "accountMalformed"); }); } diff --git a/unittests/rpc/handlers/AccountOffersTest.cpp b/unittests/rpc/handlers/AccountOffersTest.cpp index cf350e03..25d2cb5d 100644 --- a/unittests/rpc/handlers/AccountOffersTest.cpp +++ b/unittests/rpc/handlers/AccountOffersTest.cpp @@ -80,7 +80,7 @@ generateTestValuesForParametersTest() { "AccountInvalid", R"({"account": "123"})", - "invalidParams", + "actMalformed", "accountMalformed", }, { diff --git a/unittests/rpc/handlers/BookOffersTest.cpp b/unittests/rpc/handlers/BookOffersTest.cpp index 05d0a8b8..c5c27f20 100644 --- a/unittests/rpc/handlers/BookOffersTest.cpp +++ b/unittests/rpc/handlers/BookOffersTest.cpp @@ -255,7 +255,7 @@ generateParameterBookOffersTestBundles() }, "taker": "123" })", - "invalidParams", + "actMalformed", "takerMalformed"}, ParameterTestBundle{ "TakerNotString", diff --git a/unittests/rpc/handlers/GatewayBalancesTest.cpp b/unittests/rpc/handlers/GatewayBalancesTest.cpp index f4edb63b..2130ea71 100644 --- a/unittests/rpc/handlers/GatewayBalancesTest.cpp +++ b/unittests/rpc/handlers/GatewayBalancesTest.cpp @@ -98,7 +98,7 @@ generateParameterTestBundles() R"({ "account": "1213" })", - "invalidParams", + "actMalformed", "accountMalformed"}, ParameterTestBundle{ "LedgerIndexInvalid", diff --git a/unittests/rpc/handlers/NoRippleCheckTest.cpp b/unittests/rpc/handlers/NoRippleCheckTest.cpp index 62ca89df..c97393e7 100644 --- a/unittests/rpc/handlers/NoRippleCheckTest.cpp +++ b/unittests/rpc/handlers/NoRippleCheckTest.cpp @@ -88,7 +88,7 @@ generateTestValuesForParametersTest() "account": "123", "role": "gateway" })", - "invalidParams", + "actMalformed", "accountMalformed"}, NoRippleParamTestCaseBundle{ "InvalidRole", diff --git a/unittests/rpc/handlers/SubscribeTest.cpp b/unittests/rpc/handlers/SubscribeTest.cpp new file mode 100644 index 00000000..7107e70e --- /dev/null +++ b/unittests/rpc/handlers/SubscribeTest.cpp @@ -0,0 +1,885 @@ +//------------------------------------------------------------------------------ +/* + 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 +#include + +#include + +#include + +using namespace std::chrono_literals; +using namespace RPCng; +namespace json = boost::json; +using namespace testing; + +constexpr static auto MINSEQ = 10; +constexpr static auto MAXSEQ = 30; +constexpr static auto ACCOUNT = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"; +constexpr static auto ACCOUNT2 = "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun"; +constexpr static auto LEDGERHASH = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; +constexpr static auto PAYS20USDGETS10XRPBOOKDIR = "43B83ADC452B85FCBADA6CAEAC5181C255A213630D58FFD455071AFD498D0000"; +constexpr static auto PAYS20XRPGETS10USDBOOKDIR = "7B1767D41DBCE79D9585CF9D0262A5FEC45E5206FF524F8B55071AFD498D0000"; +constexpr static auto INDEX1 = "1B8590C01B0006EDFA9ED60296DD052DC5E90F99659B25014D08E1BC983515BC"; +constexpr static auto INDEX2 = "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC321"; + +class RPCSubscribeHandlerTest : public HandlerBaseTest +{ +protected: + void + SetUp() override + { + HandlerBaseTest::SetUp(); + clio::Config cfg; + subManager_ = SubscriptionManager::make_SubscriptionManager(cfg, mockBackendPtr); + util::TagDecoratorFactory tagDecoratorFactory{cfg}; + session_ = std::make_shared(tagDecoratorFactory); + } + void + TearDown() override + { + HandlerBaseTest::TearDown(); + } + + std::shared_ptr subManager_; + std::shared_ptr session_; +}; + +struct SubscribeParamTestCaseBundle +{ + std::string testName; + std::string testJson; + std::string expectedError; + std::string expectedErrorMessage; +}; + +// parameterized test cases for parameters check +struct SubscribeParameterTest : public RPCSubscribeHandlerTest, 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{ + SubscribeParamTestCaseBundle{ + "AccountsNotArray", + R"({"accounts": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"})", + "invalidParams", + "accountsNotArray"}, + SubscribeParamTestCaseBundle{ + "AccountsItemNotString", R"({"accounts": [123]})", "invalidParams", "accounts'sItemNotString"}, + SubscribeParamTestCaseBundle{ + "AccountsItemInvalidString", R"({"accounts": ["123"]})", "actMalformed", "accounts'sItemMalformed"}, + SubscribeParamTestCaseBundle{ + "AccountsEmptyArray", R"({"accounts": []})", "actMalformed", "accounts malformed."}, + SubscribeParamTestCaseBundle{ + "AccountsProposedNotArray", + R"({"accounts_proposed": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"})", + "invalidParams", + "accounts_proposedNotArray"}, + SubscribeParamTestCaseBundle{ + "AccountsProposedItemNotString", + R"({"accounts_proposed": [123]})", + "invalidParams", + "accounts_proposed'sItemNotString"}, + SubscribeParamTestCaseBundle{ + "AccountsProposedItemInvalidString", + R"({"accounts_proposed": ["123"]})", + "actMalformed", + "accounts_proposed'sItemMalformed"}, + SubscribeParamTestCaseBundle{ + "AccountsProposedEmptyArray", + R"({"accounts_proposed": []})", + "actMalformed", + "accounts_proposed malformed."}, + SubscribeParamTestCaseBundle{"StreamsNotArray", R"({"streams": 1})", "invalidParams", "streamsNotArray"}, + SubscribeParamTestCaseBundle{"StreamNotString", R"({"streams": [1]})", "invalidParams", "streamNotString"}, + SubscribeParamTestCaseBundle{"StreamNotValid", R"({"streams": ["1"]})", "malformedStream", "Stream malformed."}, + SubscribeParamTestCaseBundle{"BooksNotArray", R"({"books": "1"})", "invalidParams", "booksNotArray"}, + SubscribeParamTestCaseBundle{ + "BooksItemNotObject", R"({"books": ["1"]})", "invalidParams", "booksItemNotObject"}, + SubscribeParamTestCaseBundle{ + "BooksItemMissingTakerPays", + R"({"books": [{"taker_gets": {"currency": "XRP"}}]})", + "invalidParams", + "Missing field 'taker_pays'"}, + SubscribeParamTestCaseBundle{ + "BooksItemMissingTakerGets", + R"({"books": [{"taker_pays": {"currency": "XRP"}}]})", + "invalidParams", + "Missing field 'taker_gets'"}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerGetsNotObject", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": "USD" + } + ] + })", + "invalidParams", + "Field 'taker_gets' is not an object"}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerPaysNotObject", + R"({ + "books": [ + { + "taker_gets": + { + "currency": "XRP" + }, + "taker_pays": "USD" + } + ] + })", + "invalidParams", + "Field 'taker_pays' is not an object"}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerPaysMissingCurrency", + R"({ + "books": [ + { + "taker_gets": + { + "currency": "XRP" + }, + "taker_pays": {} + } + ] + })", + "srcCurMalformed", + "Source currency is malformed."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerGetsMissingCurrency", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": {} + } + ] + })", + "dstAmtMalformed", + "Destination amount/currency/issuer is malformed."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerPaysCurrencyNotString", + R"({ + "books": [ + { + "taker_gets": + { + "currency": "XRP" + }, + "taker_pays": { + "currency": 1, + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + } + } + ] + })", + "srcCurMalformed", + "Source currency is malformed."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerGetsCurrencyNotString", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": 1, + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + } + } + ] + })", + "dstAmtMalformed", + "Destination amount/currency/issuer is malformed."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerPaysInvalidCurrency", + R"({ + "books": [ + { + "taker_gets": + { + "currency": "XRP" + }, + "taker_pays": { + "currency": "XXXXXX", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + } + } + ] + })", + "srcCurMalformed", + "Source currency is malformed."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerGetsInvalidCurrency", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": "xxxxxxx", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + } + } + ] + })", + "dstAmtMalformed", + "Destination amount/currency/issuer is malformed."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerPaysMissingIssuer", + R"({ + "books": [ + { + "taker_gets": + { + "currency": "XRP" + }, + "taker_pays": { + "currency": "USD" + } + } + ] + })", + "srcIsrMalformed", + "Invalid field 'taker_pays.issuer', expected non-XRP issuer."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerGetsMissingIssuer", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": "USD" + } + } + ] + })", + "dstIsrMalformed", + "Invalid field 'taker_gets.issuer', expected non-XRP issuer."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerPaysIssuerNotString", + R"({ + "books": [ + { + "taker_gets": + { + "currency": "XRP" + }, + "taker_pays": { + "currency": "USD", + "issuer": 1 + } + } + ] + })", + "invalidParams", + "takerPaysIssuerNotString"}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerGetsIssuerNotString", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": "USD", + "issuer": 1 + } + } + ] + })", + "invalidParams", + "taker_gets.issuer should be string"}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerPaysInvalidIssuer", + R"({ + "books": [ + { + "taker_gets": + { + "currency": "XRP" + }, + "taker_pays": { + "currency": "USD", + "issuer": "123" + } + } + ] + })", + "srcIsrMalformed", + "Source issuer is malformed."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerGetsInvalidIssuer", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": "USD", + "issuer": "123" + } + } + ] + })", + "dstIsrMalformed", + "Invalid field 'taker_gets.issuer', bad issuer."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerGetsXRPHasIssuer", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "USD", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + }, + "taker_gets": { + "currency": "XRP", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + } + } + ] + })", + "dstIsrMalformed", + "Unneeded field 'taker_gets.issuer' for XRP currency " + "specification."}, + SubscribeParamTestCaseBundle{ + "BooksItemTakerPaysXRPHasIssuer", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + }, + "taker_gets": { + "currency": "USD", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + } + } + ] + })", + "srcIsrMalformed", + "Unneeded field 'taker_pays.issuer' for XRP currency " + "specification."}, + SubscribeParamTestCaseBundle{ + "BooksItemBadMartket", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": "XRP" + } + } + ] + })", + "badMarket", + "badMarket"}, + SubscribeParamTestCaseBundle{ + "BooksItemInvalidSnapshot", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": "USD", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + }, + "snapshot": 0 + } + ] + })", + "invalidParams", + "snapshotNotBool"}, + SubscribeParamTestCaseBundle{ + "BooksItemInvalidBoth", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": "USD", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + }, + "both": 0 + } + ] + })", + "invalidParams", + "bothNotBool"}, + SubscribeParamTestCaseBundle{ + "BooksItemInvalidTakerNotString", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": "USD", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + }, + "taker": 0 + } + ] + })", + "invalidParams", + "takerNotString"}, + SubscribeParamTestCaseBundle{ + "BooksItemInvalidTaker", + R"({ + "books": [ + { + "taker_pays": + { + "currency": "XRP" + }, + "taker_gets": { + "currency": "USD", + "issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + }, + "taker": "xxxxxxx" + } + ] + })", + "actMalformed", + "takerMalformed"}, + }; +} + +INSTANTIATE_TEST_CASE_P( + RPCSubscribe, + SubscribeParameterTest, + ValuesIn(generateTestValuesForParametersTest()), + SubscribeParameterTest::NameGenerator{}); + +TEST_P(SubscribeParameterTest, InvalidParams) +{ + auto const testBundle = GetParam(); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{SubscribeHandler{mockBackendPtr, subManager_}}; + 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()); + EXPECT_EQ(err.at("error").as_string(), testBundle.expectedError); + EXPECT_EQ(err.at("error_message").as_string(), testBundle.expectedErrorMessage); + }); +} + +TEST_F(RPCSubscribeHandlerTest, EmptyResponse) +{ + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{SubscribeHandler{mockBackendPtr, subManager_}}; + auto const output = handler.process(json::parse(R"({})"), Context{std::ref(yield), session_}); + ASSERT_TRUE(output); + EXPECT_TRUE(output->as_object().empty()); + }); +} + +TEST_F(RPCSubscribeHandlerTest, StreamsWithoutLedger) +{ + // these streams don't return response + auto const input = json::parse( + R"({ + "streams": ["transactions_proposed","transactions","validations","manifests","book_changes"] + })"); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{SubscribeHandler{mockBackendPtr, subManager_}}; + auto const output = handler.process(input, Context{std::ref(yield), session_}); + ASSERT_TRUE(output); + EXPECT_TRUE(output->as_object().empty()); + std::this_thread::sleep_for(20ms); + auto const report = subManager_->report(); + EXPECT_EQ(report.at("transactions_proposed").as_uint64(), 1); + EXPECT_EQ(report.at("transactions").as_uint64(), 1); + EXPECT_EQ(report.at("validations").as_uint64(), 1); + EXPECT_EQ(report.at("manifests").as_uint64(), 1); + EXPECT_EQ(report.at("book_changes").as_uint64(), 1); + }); +} + +TEST_F(RPCSubscribeHandlerTest, StreamsLedger) +{ + static auto constexpr expectedOutput = + R"({ + "validated_ledgers":"10-30", + "ledger_index":30, + "ledger_hash":"4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652", + "ledger_time":0, + "fee_ref":4, + "fee_base":1, + "reserve_base":3, + "reserve_inc":2 + })"; + mockBackendPtr->updateRange(MINSEQ); + mockBackendPtr->updateRange(MAXSEQ); + auto const rawBackendPtr = static_cast(mockBackendPtr.get()); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + // return valid ledgerinfo + auto const ledgerinfo = CreateLedgerInfo(LEDGERHASH, MAXSEQ); + ON_CALL(*rawBackendPtr, fetchLedgerBySequence(MAXSEQ, _)).WillByDefault(Return(ledgerinfo)); + // fee + auto feeBlob = CreateFeeSettingBlob(1, 2, 3, 4, 0); + ON_CALL(*rawBackendPtr, doFetchLedgerObject).WillByDefault(Return(feeBlob)); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObject).Times(1); + + // ledger stream returns information about the ledgers on hand and current + // fee schedule. + auto const input = json::parse( + R"({ + "streams": ["ledger"] + })"); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{SubscribeHandler{mockBackendPtr, subManager_}}; + auto const output = handler.process(input, Context{std::ref(yield), session_}); + ASSERT_TRUE(output); + EXPECT_EQ(output->as_object(), json::parse(expectedOutput)); + std::this_thread::sleep_for(20ms); + auto const report = subManager_->report(); + EXPECT_EQ(report.at("ledger").as_uint64(), 1); + }); +} + +TEST_F(RPCSubscribeHandlerTest, Accounts) +{ + auto const input = json::parse(fmt::format( + R"({{ + "accounts": ["{}","{}","{}"] + }})", + ACCOUNT, + ACCOUNT2, + ACCOUNT2)); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{SubscribeHandler{mockBackendPtr, subManager_}}; + auto const output = handler.process(input, Context{std::ref(yield), session_}); + ASSERT_TRUE(output); + EXPECT_TRUE(output->as_object().empty()); + std::this_thread::sleep_for(20ms); + auto const report = subManager_->report(); + // filter the duplicates + EXPECT_EQ(report.at("account").as_uint64(), 2); + }); +} + +TEST_F(RPCSubscribeHandlerTest, AccountsProposed) +{ + auto const input = json::parse(fmt::format( + R"({{ + "accounts_proposed": ["{}","{}","{}"] + }})", + ACCOUNT, + ACCOUNT2, + ACCOUNT2)); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{SubscribeHandler{mockBackendPtr, subManager_}}; + auto const output = handler.process(input, Context{std::ref(yield), session_}); + ASSERT_TRUE(output); + EXPECT_TRUE(output->as_object().empty()); + std::this_thread::sleep_for(20ms); + auto const report = subManager_->report(); + // filter the duplicates + EXPECT_EQ(report.at("accounts_proposed").as_uint64(), 2); + }); +} + +TEST_F(RPCSubscribeHandlerTest, JustBooks) +{ + auto const input = json::parse(fmt::format( + R"({{ + "books": + [ + {{ + "taker_pays": + {{ + "currency": "XRP" + }}, + "taker_gets": + {{ + "currency": "USD", + "issuer": "{}" + }} + }} + ] + }})", + ACCOUNT)); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{SubscribeHandler{mockBackendPtr, subManager_}}; + auto const output = handler.process(input, Context{std::ref(yield), session_}); + ASSERT_TRUE(output); + EXPECT_TRUE(output->as_object().empty()); + std::this_thread::sleep_for(20ms); + auto const report = subManager_->report(); + EXPECT_EQ(report.at("books").as_uint64(), 1); + }); +} + +TEST_F(RPCSubscribeHandlerTest, BooksBothSet) +{ + auto const input = json::parse(fmt::format( + R"({{ + "books": + [ + {{ + "taker_pays": + {{ + "currency": "XRP" + }}, + "taker_gets": + {{ + "currency": "USD", + "issuer": "{}" + }}, + "both": true + }} + ] + }})", + ACCOUNT)); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{SubscribeHandler{mockBackendPtr, subManager_}}; + auto const output = handler.process(input, Context{std::ref(yield), session_}); + ASSERT_TRUE(output); + EXPECT_TRUE(output->as_object().empty()); + std::this_thread::sleep_for(20ms); + auto const report = subManager_->report(); + // original book + reverse book + EXPECT_EQ(report.at("books").as_uint64(), 2); + }); +} + +TEST_F(RPCSubscribeHandlerTest, BooksBothSnapshotSet) +{ + auto const input = json::parse(fmt::format( + R"({{ + "books": + [ + {{ + "taker_gets": + {{ + "currency": "XRP" + }}, + "taker_pays": + {{ + "currency": "USD", + "issuer": "{}" + }}, + "both": true, + "snapshot": true + }} + ] + }})", + ACCOUNT)); + mockBackendPtr->updateRange(MINSEQ); + mockBackendPtr->updateRange(MAXSEQ); + auto const rawBackendPtr = static_cast(mockBackendPtr.get()); + auto const issuer = GetAccountIDWithString(ACCOUNT); + + auto const getsXRPPaysUSDBook = getBookBase(std::get(RPC::parseBook( + ripple::to_currency("USD"), // pays + issuer, + ripple::xrpCurrency(), // gets + ripple::xrpAccount()))); + + auto const reversedBook = getBookBase(std::get(RPC::parseBook( + ripple::xrpCurrency(), // pays + ripple::xrpAccount(), + ripple::to_currency("USD"), // gets + issuer))); + + ON_CALL(*rawBackendPtr, doFetchSuccessorKey(getsXRPPaysUSDBook, MAXSEQ, _)) + .WillByDefault(Return(ripple::uint256{PAYS20USDGETS10XRPBOOKDIR})); + + ON_CALL(*rawBackendPtr, doFetchSuccessorKey(ripple::uint256{PAYS20USDGETS10XRPBOOKDIR}, MAXSEQ, _)) + .WillByDefault(Return(std::nullopt)); + + ON_CALL(*rawBackendPtr, doFetchSuccessorKey(reversedBook, MAXSEQ, _)) + .WillByDefault(Return(ripple::uint256{PAYS20XRPGETS10USDBOOKDIR})); + + EXPECT_CALL(*rawBackendPtr, doFetchSuccessorKey).Times(4); + + // 2 book dirs + 2 issuer global freeze + 2 transferRate + 1 owner root + 1 fee + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObject).Times(8); + + auto const indexes = std::vector(10, ripple::uint256{INDEX2}); + ON_CALL(*rawBackendPtr, doFetchLedgerObject(ripple::uint256{PAYS20USDGETS10XRPBOOKDIR}, MAXSEQ, _)) + .WillByDefault(Return(CreateOwnerDirLedgerObject(indexes, INDEX1).getSerializer().peekData())); + + // for reverse + auto const indexes2 = std::vector(10, ripple::uint256{INDEX1}); + ON_CALL(*rawBackendPtr, doFetchLedgerObject(ripple::uint256{PAYS20XRPGETS10USDBOOKDIR}, MAXSEQ, _)) + .WillByDefault(Return(CreateOwnerDirLedgerObject(indexes2, INDEX2).getSerializer().peekData())); + + // offer owner account root + ON_CALL( + *rawBackendPtr, doFetchLedgerObject(ripple::keylet::account(GetAccountIDWithString(ACCOUNT2)).key, MAXSEQ, _)) + .WillByDefault(Return(CreateAccountRootObject(ACCOUNT2, 0, 2, 200, 2, INDEX1, 2).getSerializer().peekData())); + + // issuer account root + ON_CALL( + *rawBackendPtr, doFetchLedgerObject(ripple::keylet::account(GetAccountIDWithString(ACCOUNT)).key, MAXSEQ, _)) + .WillByDefault(Return(CreateAccountRootObject(ACCOUNT, 0, 2, 200, 2, INDEX1, 2).getSerializer().peekData())); + + // fee + auto feeBlob = CreateFeeSettingBlob(1, 2, 3, 4, 0); + ON_CALL(*rawBackendPtr, doFetchLedgerObject(ripple::keylet::fees().key, MAXSEQ, _)).WillByDefault(Return(feeBlob)); + + auto const gets10XRPPays20USDOffer = CreateOfferLedgerObject( + ACCOUNT2, + 10, + 20, + ripple::to_string(ripple::xrpCurrency()), + ripple::to_string(ripple::to_currency("USD")), + toBase58(ripple::xrpAccount()), + ACCOUNT, + PAYS20USDGETS10XRPBOOKDIR); + + // for reverse + // offer owner is USD issuer + auto const gets10USDPays20XRPOffer = CreateOfferLedgerObject( + ACCOUNT, + 10, + 20, + ripple::to_string(ripple::to_currency("USD")), + ripple::to_string(ripple::xrpCurrency()), + ACCOUNT, + toBase58(ripple::xrpAccount()), + PAYS20XRPGETS10USDBOOKDIR); + + std::vector bbs(10, gets10XRPPays20USDOffer.getSerializer().peekData()); + ON_CALL(*rawBackendPtr, doFetchLedgerObjects(indexes, MAXSEQ, _)).WillByDefault(Return(bbs)); + + // for reverse + std::vector bbs2(10, gets10USDPays20XRPOffer.getSerializer().peekData()); + ON_CALL(*rawBackendPtr, doFetchLedgerObjects(indexes2, MAXSEQ, _)).WillByDefault(Return(bbs2)); + + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObjects).Times(2); + + static auto const expectedOffer = fmt::format( + R"({{ + "Account":"{}", + "BookDirectory":"{}", + "BookNode":"0", + "Flags":0, + "LedgerEntryType":"Offer", + "OwnerNode":"0", + "PreviousTxnID":"0000000000000000000000000000000000000000000000000000000000000000", + "PreviousTxnLgrSeq":0, + "Sequence":0, + "TakerGets":"10", + "TakerPays": + {{ + "currency":"USD", + "issuer":"{}", + "value":"20" + }}, + "index":"E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC321", + "owner_funds":"193", + "quality":"2" + }})", + ACCOUNT2, + PAYS20USDGETS10XRPBOOKDIR, + ACCOUNT); + static auto const expectedReversedOffer = fmt::format( + R"({{ + "Account":"{}", + "BookDirectory":"{}", + "BookNode":"0", + "Flags":0, + "LedgerEntryType":"Offer", + "OwnerNode":"0", + "PreviousTxnID":"0000000000000000000000000000000000000000000000000000000000000000", + "PreviousTxnLgrSeq":0, + "Sequence":0, + "TakerGets": + {{ + "currency":"USD", + "issuer":"{}", + "value":"10" + }}, + "TakerPays":"20", + "index":"1B8590C01B0006EDFA9ED60296DD052DC5E90F99659B25014D08E1BC983515BC", + "owner_funds":"10", + "quality":"2" + }})", + ACCOUNT, + PAYS20XRPGETS10USDBOOKDIR, + ACCOUNT); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{SubscribeHandler{mockBackendPtr, subManager_}}; + auto const output = handler.process(input, Context{std::ref(yield), session_}); + ASSERT_TRUE(output); + EXPECT_EQ(output->as_object().at("offers").as_array().size(), 20); + EXPECT_EQ(output->as_object().at("offers").as_array()[0].as_object(), json::parse(expectedOffer)); + EXPECT_EQ(output->as_object().at("offers").as_array()[10].as_object(), json::parse(expectedReversedOffer)); + std::this_thread::sleep_for(20ms); + auto const report = subManager_->report(); + // original book + reverse book + EXPECT_EQ(report.at("books").as_uint64(), 2); + }); +} diff --git a/unittests/util/Fixtures.h b/unittests/util/Fixtures.h index bfa40247..f02d1c59 100644 --- a/unittests/util/Fixtures.h +++ b/unittests/util/Fixtures.h @@ -285,6 +285,7 @@ protected: */ struct HandlerBaseTest : public MockBackendTest, public SyncAsioContextTest { +protected: void SetUp() override {