From 1d6b704c15ee289037447fb566e7583962496650 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Sun, 26 May 2024 12:31:12 -0500 Subject: Refactor to separate interface from implementation --- CMakeLists.txt | 69 ++- CMakePresets.json | 17 +- include/client/web_socket_client.hpp | 4 +- include/client/websocketpp_client.hpp | 44 ++ include/data/data.hpp | 3 - include/nostr.hpp | 153 +---- include/nostr_service_base.hpp | 104 ++++ include/signer/noscrypt_signer.hpp | 55 ++ include/signer/signer.hpp | 7 +- src/client/websocketpp_client.cpp | 193 +++--- src/data/event.cpp | 6 - src/nostr_service.cpp | 659 -------------------- src/nostr_service_base.cpp | 658 ++++++++++++++++++++ src/signer/noscrypt_signer.cpp | 274 ++++----- test/nostr_service_base_test.cpp | 1049 +++++++++++++++++++++++++++++++ test/nostr_service_test.cpp | 1088 --------------------------------- 16 files changed, 2197 insertions(+), 2186 deletions(-) create mode 100644 include/client/websocketpp_client.hpp create mode 100644 include/nostr_service_base.hpp create mode 100644 include/signer/noscrypt_signer.hpp delete mode 100644 src/nostr_service.cpp create mode 100644 src/nostr_service_base.cpp create mode 100644 test/nostr_service_base_test.cpp delete mode 100644 test/nostr_service_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e8be787..461fbcc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -75,9 +75,12 @@ include_directories(${SIGNER_INCLUDE_DIR}) set(HEADERS ${INCLUDE_DIR}/nostr.hpp + ${INCLUDE_DIR}/nostr_service_base.hpp ${CLIENT_INCLUDE_DIR}/web_socket_client.hpp + ${CLIENT_INCLUDE_DIR}/websocketpp_client.hpp ${DATA_INCLUDE_DIR}/data.hpp ${SIGNER_INCLUDE_DIR}/signer.hpp + ${SIGNER_INCLUDE_DIR}/noscrypt_signer.hpp ) set(SOURCE_DIR ./src) @@ -85,7 +88,7 @@ set(CLIENT_SOURCE_DIR ./src/client) set(DATA_SOURCE_DIR ./src/data) set(SIGNER_SOURCE_DIR ./src/signer) set(SOURCES - ${SOURCE_DIR}/nostr_service.cpp + ${SOURCE_DIR}/nostr_service_base.cpp ${CLIENT_SOURCE_DIR}/websocketpp_client.cpp ${DATA_SOURCE_DIR}/event.cpp ${DATA_SOURCE_DIR}/filters.cpp @@ -104,33 +107,37 @@ target_link_libraries(aedile PRIVATE set_target_properties(aedile PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES) #======== Build the tests ========# -enable_testing() -include(GoogleTest) - -FetchContent_Declare( - googletest - URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip -) - -set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) -FetchContent_MakeAvailable(googletest) - -enable_testing() - -set(TEST_DIR ./test) -set(TEST_SOURCES - ${TEST_DIR}/nostr_service_test.cpp -) - -add_executable(aedile_test ${TEST_SOURCES} ${HEADERS}) -target_link_libraries(aedile_test PRIVATE - GTest::gmock - GTest::gtest - GTest::gtest_main - plog::plog - websocketpp::websocketpp - aedile -) -set_target_properties(aedile_test PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES) - -gtest_add_tests(TARGET aedile_test) +if(AEDILE_INCLUDE_TESTS) + message(STATUS "Building unit tests.") + + enable_testing() + include(GoogleTest) + + FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip + ) + + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) + + enable_testing() + + set(TEST_DIR ./test) + set(TEST_SOURCES + ${TEST_DIR}/nostr_service_base_test.cpp + ) + + add_executable(aedile_test ${TEST_SOURCES} ${HEADERS}) + target_link_libraries(aedile_test PRIVATE + GTest::gmock + GTest::gtest + GTest::gtest_main + plog::plog + websocketpp::websocketpp + aedile + ) + set_target_properties(aedile_test PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES) + + gtest_add_tests(TARGET aedile_test) +endif() diff --git a/CMakePresets.json b/CMakePresets.json index 208a085..84c0aff 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -9,6 +9,16 @@ "CMAKE_BUILD_TYPE": "Release", "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" } + }, + { + "name": "linux tests", + "generator": "Unix Makefiles", + "binaryDir": "${sourceDir}/build/linux", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "AEDILE_INCLUDE_TESTS": "ON" + } } ], "buildPresets": [ @@ -16,12 +26,17 @@ "name": "linux", "configurePreset": "linux", "jobs": 4 + }, + { + "name": "linux tests", + "configurePreset": "linux tests", + "jobs": 4 } ], "testPresets": [ { "name": "linux", - "configurePreset": "linux" + "configurePreset": "linux tests" } ] } diff --git a/include/client/web_socket_client.hpp b/include/client/web_socket_client.hpp index eca8e24..19fc949 100644 --- a/include/client/web_socket_client.hpp +++ b/include/client/web_socket_client.hpp @@ -10,14 +10,14 @@ namespace nostr { namespace client { -class IWebSocketClient; - /** * @brief An interface for a WebSocket client singleton. */ class IWebSocketClient { public: + virtual ~IWebSocketClient() = default; + /** * @brief Starts the client. * @remark This method must be called before any other client methods. diff --git a/include/client/websocketpp_client.hpp b/include/client/websocketpp_client.hpp new file mode 100644 index 0000000..becf4fa --- /dev/null +++ b/include/client/websocketpp_client.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "web_socket_client.hpp" + +namespace nostr +{ +namespace client +{ +/** + * @brief An implementation of the `IWebSocketClient` interface that uses the WebSocket++ library. + */ +class WebsocketppClient : public IWebSocketClient +{ +public: + void start() override; + + void stop() override; + + void openConnection(std::string uri) override; + + bool isConnected(std::string uri) override; + + std::tuple send(std::string message, std::string uri) override; + + std::tuple send( + std::string message, + std::string uri, + std::function messageHandler) override; + + void receive(std::string uri, std::function messageHandler) override; + + void closeConnection(std::string uri) override; + +private: + typedef websocketpp::client websocketpp_client; + typedef std::unordered_map::iterator connection_hdl_iterator; + + websocketpp_client _client; + std::unordered_map _connectionHandles; + std::mutex _propertyMutex; +}; +} // namespace client +} // namespace nostr + diff --git a/include/data/data.hpp b/include/data/data.hpp index 78c17c4..46156cd 100644 --- a/include/data/data.hpp +++ b/include/data/data.hpp @@ -13,9 +13,6 @@ namespace nostr { namespace data { -class Event; -class Filters; - /** * @brief A Nostr event. * @remark All data transmitted over the Nostr protocol is encoded in JSON blobs. This struct diff --git a/include/nostr.hpp b/include/nostr.hpp index d21a86d..c555bbe 100644 --- a/include/nostr.hpp +++ b/include/nostr.hpp @@ -21,52 +21,35 @@ namespace nostr { -class NostrService; - // TODO: Create custom exception types for the nostr namespace. -class NostrService +class INostrServiceBase { public: - NostrService( - std::shared_ptr appender, - std::shared_ptr client, - std::shared_ptr signer); - NostrService( - std::shared_ptr appender, - std::shared_ptr client, - std::shared_ptr signer, - std::vector relays); - ~NostrService(); - - std::vector defaultRelays() const; - - std::vector activeRelays() const; - - std::unordered_map> subscriptions() const; + virtual ~INostrServiceBase() = default; /** * @brief Opens connections to the default Nostr relays of the instance, as specified in * the constructor. * @return A list of the relay URLs to which connections were successfully opened. */ - std::vector openRelayConnections(); + virtual std::vector openRelayConnections() = 0; /** * @brief Opens connections to the specified Nostr relays. * @returns A list of the relay URLs to which connections were successfully opened. */ - std::vector openRelayConnections(std::vector relays); + virtual std::vector openRelayConnections(std::vector relays) = 0; /** * @brief Closes all open relay connections. */ - void closeRelayConnections(); + virtual void closeRelayConnections() = 0; /** * @brief Closes any open connections to the specified Nostr relays. */ - void closeRelayConnections(std::vector relays); + virtual void closeRelayConnections(std::vector relays) = 0; /** * @brief Publishes a Nostr event to all open relay connections. @@ -74,7 +57,8 @@ public: * to which relays the event was published successfully, and to which relays the event failed * to publish. */ - std::tuple, std::vector> publishEvent(std::shared_ptr event); + virtual std::tuple, std::vector> publishEvent( + std::shared_ptr event) = 0; /** * @brief Queries all open relay connections for events matching the given set of filters, and @@ -88,7 +72,8 @@ public: * set on the filters in the range 1-64, inclusive. If no valid limit is given, it will be * defaulted to 16. */ - std::vector> queryRelays(std::shared_ptr filters); + virtual std::vector> queryRelays( + std::shared_ptr filters) = 0; /** * @brief Queries all open relay connections for events matching the given set of filters. @@ -104,11 +89,11 @@ public: * events returned from the relay for the given filters. The service will not store the * events, and they will not be accessible via `getNewEvents`. */ - std::string queryRelays( + virtual std::string queryRelays( std::shared_ptr filters, std::function)> eventHandler, std::function eoseHandler, - std::function closeHandler); + std::function closeHandler) = 0; /** * @brief Closes the subscription with the given ID on all open relay connections. @@ -116,7 +101,8 @@ public: * to which relays the message was sent successfully, and which relays failed to receive the * message. */ - std::tuple, std::vector> closeSubscription(std::string subscriptionId); + virtual std::tuple, std::vector> closeSubscription( + std::string subscriptionId) = 0; /** * @brief Closes the subscription with the given ID on the given relay. @@ -124,119 +110,12 @@ public: * @remark If the subscription does not exist on the given relay, or if the relay is not * connected, the method will do nothing and return false. */ - bool closeSubscription(std::string subscriptionId, std::string relay); + virtual bool closeSubscription(std::string subscriptionId, std::string relay) = 0; /** * @brief Closes all open subscriptions on all open relay connections. * @returns A list of any subscription IDs that failed to close. */ - std::vector closeSubscriptions(); - - /** - * @brief Closes all open subscriptions on the given relays. - * @returns A list of any subscription IDs that failed to close. - */ - std::vector closeSubscriptions(std::vector relays); - -private: - ///< The maximum number of events the service will store for each subscription. - const int MAX_EVENTS_PER_SUBSCRIPTION = 128; - - ///< The WebSocket client used to communicate with relays. - std::shared_ptr _client; - ///< The signer used to sign Nostr events. - std::shared_ptr _signer; - - ///< A mutex to protect the instance properties. - std::mutex _propertyMutex; - ///< The default set of Nostr relays to which the service will attempt to connect. - std::vector _defaultRelays; - ///< The set of Nostr relays to which the service is currently connected. - std::vector _activeRelays; - ///< A map from subscription IDs to the relays on which each subscription is open. - std::unordered_map> _subscriptions; - - /** - * @brief Determines which of the given relays are currently connected. - * @returns A list of the URIs of currently-open relay connections from the given list. - */ - std::vector getConnectedRelays(std::vector relays); - - /** - * @brief Determines which of the given relays are not currently connected. - * @returns A list of the URIs of currently-unconnected relays from the given list. - */ - std::vector getUnconnectedRelays(std::vector relays); - - /** - * @brief Determines whether the given relay is currently connected. - * @returns True if the relay is connected, false otherwise. - */ - bool isConnected(std::string relay); - - /** - * @brief Removes the given relay from the instance's list of active relays. - */ - void eraseActiveRelay(std::string relay); - - /** - * @brief Opens a connection from the client to the given relay. - */ - void connect(std::string relay); - - /** - * @brief Closes the connection from the client to the given relay. - */ - void disconnect(std::string relay); - - /** - * @brief Generates a unique subscription ID that may be used to identify event requests. - * @returns A stringified UUID. - */ - std::string generateSubscriptionId(); - - /** - * @brief Generates a message requesting a relay to close the subscription with the given ID. - * @returns A stringified JSON object representing the close request. - */ - std::string generateCloseRequest(std::string subscriptionId); - - /** - * @brief Indicates whether the the service has an open subscription with the given ID. - * @returns True if the service has the subscription, false otherwise. - */ - bool hasSubscription(std::string subscriptionId); - - /** - * @brief Indicates whether the service has an open subscription with the given ID on the given - * relay. - * @returns True if the subscription exists on the relay, false otherwise. - */ - bool hasSubscription(std::string subscriptionId, std::string relay); - - /** - * @brief Parses EVENT messages received from the relay and invokes the given event handler. - * @param message The raw message received from the relay. - * @param eventHandler A callable object that will be invoked with the subscription ID and the - * payload of the event. - * @param eoseHandler A callable object that will be invoked with the subscription ID when the - * relay sends an EOSE message, indicating it has reached the end of stored events for the - * given query. - * @param closeHandler A callable object that will be invoked with the subscription ID and the - * message sent by the relay if the subscription is ended by the relay. - */ - void onSubscriptionMessage( - std::string message, - std::function)> eventHandler, - std::function eoseHandler, - std::function closeHandler); - - /** - * @brief Parses OK messages received from the relay and invokes the given acceptance handler. - * @remark The OK message type is sent to indicate whether the relay has accepted an event sent - * by the client. Note that this is distinct from whether the message was successfully sent to - * the relay over the WebSocket connection. - */ - void onAcceptance(std::string message, std::function acceptanceHandler); + virtual std::vector closeSubscriptions() = 0; }; } // namespace nostr diff --git a/include/nostr_service_base.hpp b/include/nostr_service_base.hpp new file mode 100644 index 0000000..6abfedc --- /dev/null +++ b/include/nostr_service_base.hpp @@ -0,0 +1,104 @@ +#pragma once + +#include "nostr.hpp" + +namespace nostr +{ +class NostrServiceBase : public INostrServiceBase +{ +public: + NostrServiceBase( + std::shared_ptr appender, + std::shared_ptr client); + + NostrServiceBase( + std::shared_ptr appender, + std::shared_ptr client, + std::vector relays); + + ~NostrServiceBase() override; + + std::vector defaultRelays() const; + + std::vector activeRelays() const; + + std::unordered_map> subscriptions() const; + + std::vector openRelayConnections() override; + + std::vector openRelayConnections(std::vector relays) override; + + void closeRelayConnections() override; + + void closeRelayConnections(std::vector relays) override; + + // TODO: Make this method return a promise. + std::tuple, std::vector> publishEvent( + std::shared_ptr event) override; + + // TODO: Make this method return a promise. + // TODO: Add a timeout to this method to prevent hanging while waiting for the relay. + std::vector> queryRelays( + std::shared_ptr filters) override; + + std::string queryRelays( + std::shared_ptr filters, + std::function)> eventHandler, + std::function eoseHandler, + std::function closeHandler) override; + + std::tuple, std::vector> closeSubscription( + std::string subscriptionId) override; + + bool closeSubscription(std::string subscriptionId, std::string relay) override; + + std::vector closeSubscriptions() override; + +private: + ///< The maximum number of events the service will store for each subscription. + const int MAX_EVENTS_PER_SUBSCRIPTION = 128; + + ///< The WebSocket client used to communicate with relays. + std::shared_ptr _client; + + ///< A mutex to protect the instance properties. + std::mutex _propertyMutex; + + ///< The default set of Nostr relays to which the service will attempt to connect. + std::vector _defaultRelays; + + ///< The set of Nostr relays to which the service is currently connected. + std::vector _activeRelays; + + ///< A map from subscription IDs to the relays on which each subscription is open. + std::unordered_map> _subscriptions; + + std::vector _getConnectedRelays(std::vector relays); + + std::vector _getUnconnectedRelays(std::vector relays); + + bool _isConnected(std::string relay); + + void _eraseActiveRelay(std::string relay); + + void _connect(std::string relay); + + void _disconnect(std::string relay); + + std::string _generateSubscriptionId(); + + std::string _generateCloseRequest(std::string subscriptionId); + + bool _hasSubscription(std::string subscriptionId); + + bool _hasSubscription(std::string subscriptionId, std::string relay); + + void _onSubscriptionMessage( + std::string message, + std::function)> eventHandler, + std::function eoseHandler, + std::function closeHandler); + + void _onAcceptance(std::string message, std::function acceptanceHandler); +}; +} // namespace nostr diff --git a/include/signer/noscrypt_signer.hpp b/include/signer/noscrypt_signer.hpp new file mode 100644 index 0000000..1476303 --- /dev/null +++ b/include/signer/noscrypt_signer.hpp @@ -0,0 +1,55 @@ +#pragma once + +extern "C" +{ +#include +} + +#include "signer.hpp" + +namespace nostr +{ +namespace signer +{ +class NoscryptSigner : public INostrConnectSigner +{ +public: + NoscryptSigner(std::shared_ptr appender); + + ~NoscryptSigner(); + + void receiveConnection(std::string connectionToken) override; + + void initiateConnection( + std::string relay, + std::string name, + std::string url, + std::string description) override; + + void sign(std::shared_ptr event) override; + +private: + std::shared_ptr noscryptContext; + + std::string localPrivateKey; + std::string localPublicKey; + + /** + * @brief Initializes the noscrypt library context into the class's `context` property. + * @returns `true` if successful, `false` otherwise. + */ + std::shared_ptr _initNoscryptContext(); + + void _logNoscryptResult(NCResult result); + + /** + * @brief Generates a private/public key pair for local use. + * @returns The generated keypair of the form `[privateKey, publicKey]`, or a pair of empty + * strings if the function failed. + * @remarks This keypair is intended for temporary use, and should not be saved or used outside + * of this class. + */ + std::tuple _createLocalKeypair(); +}; +} // namespace signer +} // namespace nostr diff --git a/include/signer/signer.hpp b/include/signer/signer.hpp index cc4fd03..e16aa38 100644 --- a/include/signer/signer.hpp +++ b/include/signer/signer.hpp @@ -14,15 +14,14 @@ namespace nostr { namespace signer { -class ISigner; -class INostrConnectSigner; - /** * @brief An interface for Nostr event signing that implements NIP-46. */ class ISigner { public: + virtual ~ISigner() = default; + /** * @brief Signs the given Nostr event. * @param event The event to sign. @@ -34,6 +33,8 @@ public: class INostrConnectSigner : public ISigner { public: + virtual ~INostrConnectSigner() = default; + virtual void receiveConnection(std::string connectionToken) = 0; virtual void initiateConnection( diff --git a/src/client/websocketpp_client.cpp b/src/client/websocketpp_client.cpp index 9967d74..3cc6c99 100644 --- a/src/client/websocketpp_client.cpp +++ b/src/client/websocketpp_client.cpp @@ -1,132 +1,105 @@ -#include "web_socket_client.hpp" - -using std::error_code; -using std::function; -using std::lock_guard; -using std::make_tuple; -using std::mutex; -using std::string; -using std::tuple; -using std::unordered_map; - -namespace nostr -{ -namespace client -{ -/** - * @brief An implementation of the `IWebSocketClient` interface that uses the WebSocket++ library. - */ -class WebsocketppClient : public IWebSocketClient -{ -public: - void start() override - { - this->_client.init_asio(); - this->_client.start_perpetual(); - }; - - void stop() override - { - this->_client.stop_perpetual(); - this->_client.stop(); - }; +#include "websocketpp_client.hpp" - void openConnection(string uri) override - { - error_code error; - websocketpp_client::connection_ptr connection = this->_client.get_connection(uri, error); - - if (error.value() == -1) - { - // PLOG_ERROR << "Error connecting to relay " << relay << ": " << error.message(); - } +using namespace std; - // Configure the connection here via the connection pointer. - connection->set_fail_handler([this, uri](auto handle) { - // PLOG_ERROR << "Error connecting to relay " << relay << ": Handshake failed."; - lock_guard lock(this->_propertyMutex); - if (this->isConnected(uri)) - { - this->_connectionHandles.erase(uri); - } - }); +void nostr::client::WebsocketppClient::start() +{ + this->_client.init_asio(); + this->_client.start_perpetual(); +}; - lock_guard lock(this->_propertyMutex); - this->_connectionHandles[uri] = connection->get_handle(); - this->_client.connect(connection); - }; +void nostr::client::WebsocketppClient::stop() +{ + this->_client.stop_perpetual(); + this->_client.stop(); +}; - bool isConnected(string uri) override - { - lock_guard lock(this->_propertyMutex); - return this->_connectionHandles.find(uri) != this->_connectionHandles.end(); - }; +void nostr::client::WebsocketppClient::openConnection(string uri) +{ + error_code error; + websocketpp_client::connection_ptr connection = this->_client.get_connection(uri, error); - tuple send(string message, string uri) override + if (error.value() == -1) { - error_code error; + // PLOG_ERROR << "Error connecting to relay " << relay << ": " << error.message(); + } - // Make sure the connection isn't closed from under us. + // Configure the connection here via the connection pointer. + connection->set_fail_handler([this, uri](auto handle) { + // PLOG_ERROR << "Error connecting to relay " << relay << ": Handshake failed."; lock_guard lock(this->_propertyMutex); - this->_client.send( - this->_connectionHandles[uri], - message, - websocketpp::frame::opcode::text, - error); - - if (error.value() == -1) + if (this->isConnected(uri)) { - return make_tuple(uri, false); + this->_connectionHandles.erase(uri); } + }); - return make_tuple(uri, true); - }; + lock_guard lock(this->_propertyMutex); + this->_connectionHandles[uri] = connection->get_handle(); + this->_client.connect(connection); +}; - tuple send(string message, string uri, function messageHandler) override - { - auto successes = this->send(message, uri); - this->receive(uri, messageHandler); - return successes; - }; +bool nostr::client::WebsocketppClient::isConnected(string uri) +{ + lock_guard lock(this->_propertyMutex); + return this->_connectionHandles.find(uri) != this->_connectionHandles.end(); +}; - void receive(string uri, function messageHandler) override - { - lock_guard lock(this->_propertyMutex); - auto connectionHandle = this->_connectionHandles[uri]; - auto connection = this->_client.get_con_from_hdl(connectionHandle); +tuple nostr::client::WebsocketppClient::send(string message, string uri) +{ + error_code error; - connection->set_message_handler([messageHandler]( - websocketpp::connection_hdl connectionHandle, - websocketpp_client::message_ptr message) - { - messageHandler(message->get_payload()); - }); - }; + // Make sure the connection isn't closed from under us. + lock_guard lock(this->_propertyMutex); + this->_client.send( + this->_connectionHandles[uri], + message, + websocketpp::frame::opcode::text, + error); - void closeConnection(string uri) override + if (error.value() == -1) { - lock_guard lock(this->_propertyMutex); + return make_tuple(uri, false); + } - websocketpp::connection_hdl handle = this->_connectionHandles[uri]; - this->_client.close( - handle, - websocketpp::close::status::going_away, - "_client requested close."); - - this->_connectionHandles.erase(uri); - }; + return make_tuple(uri, true); +}; -private: - typedef websocketpp::client websocketpp_client; - typedef unordered_map::iterator connection_hdl_iterator; +tuple nostr::client::WebsocketppClient::send( + string message, + string uri, + function messageHandler) +{ + auto successes = this->send(message, uri); + this->receive(uri, messageHandler); + return successes; +}; - websocketpp_client _client; - unordered_map _connectionHandles; - mutex _propertyMutex; +void nostr::client::WebsocketppClient::receive( + string uri, + function messageHandler) +{ + lock_guard lock(this->_propertyMutex); + auto connectionHandle = this->_connectionHandles[uri]; + auto connection = this->_client.get_con_from_hdl(connectionHandle); - void onMessage(websocketpp::connection_hdl handle, websocketpp_client::message_ptr message) + connection->set_message_handler([messageHandler]( + websocketpp::connection_hdl connectionHandle, + websocketpp_client::message_ptr message) { - }; + messageHandler(message->get_payload()); + }); +}; + +void nostr::client::WebsocketppClient::closeConnection(string uri) +{ + lock_guard lock(this->_propertyMutex); + + websocketpp::connection_hdl handle = this->_connectionHandles[uri]; + this->_client.close( + handle, + websocketpp::close::status::going_away, + "_client requested close."); + + this->_connectionHandles.erase(uri); }; -} // namespace client -} // namespace nostr diff --git a/src/data/event.cpp b/src/data/event.cpp index 620ee3f..5f611ff 100644 --- a/src/data/event.cpp +++ b/src/data/event.cpp @@ -88,12 +88,6 @@ void Event::validate() { throw std::invalid_argument("Event::validate: A valid event kind is required."); } - - bool hasSignature = this->sig.length() > 0; - if (!hasSignature) - { - throw std::invalid_argument("Event::validate: The event must be signed."); - } }; string Event::generateId(string serializedData) const diff --git a/src/nostr_service.cpp b/src/nostr_service.cpp deleted file mode 100644 index 9f6b8ce..0000000 --- a/src/nostr_service.cpp +++ /dev/null @@ -1,659 +0,0 @@ -#include "nostr.hpp" - -using namespace nlohmann; -using namespace std; - -namespace nostr -{ -NostrService::NostrService( - shared_ptr appender, - shared_ptr client, - shared_ptr signer) -: NostrService(appender, client, signer, {}) { }; - -NostrService::NostrService( - shared_ptr appender, - shared_ptr client, - shared_ptr signer, - vector relays) -: _defaultRelays(relays), _client(client), _signer(signer) -{ - plog::init(plog::debug, appender.get()); - client->start(); -}; - -NostrService::~NostrService() -{ - this->_client->stop(); -}; - -vector NostrService::defaultRelays() const { return this->_defaultRelays; }; - -vector NostrService::activeRelays() const { return this->_activeRelays; }; - -unordered_map> NostrService::subscriptions() const { return this->_subscriptions; }; - -vector NostrService::openRelayConnections() -{ - return this->openRelayConnections(this->_defaultRelays); -}; - -vector NostrService::openRelayConnections(vector relays) -{ - PLOG_INFO << "Attempting to connect to Nostr relays."; - vector unconnectedRelays = this->getUnconnectedRelays(relays); - - vector connectionThreads; - for (string relay : unconnectedRelays) - { - thread connectionThread([this, relay]() { - this->connect(relay); - }); - connectionThreads.push_back(move(connectionThread)); - } - - for (thread& connectionThread : connectionThreads) - { - connectionThread.join(); - } - - std::size_t targetCount = relays.size(); - std::size_t activeCount = this->_activeRelays.size(); - PLOG_INFO << "Connected to " << activeCount << "/" << targetCount << " target relays."; - - // This property should only contain successful relays at this point. - return this->_activeRelays; -}; - -void NostrService::closeRelayConnections() -{ - if (this->_activeRelays.size() == 0) - { - PLOG_INFO << "No active relay connections to close."; - return; - } - - this->closeRelayConnections(this->_activeRelays); -}; - -void NostrService::closeRelayConnections(vector relays) -{ - PLOG_INFO << "Disconnecting from Nostr relays."; - vector connectedRelays = getConnectedRelays(relays); - - vector disconnectionThreads; - for (string relay : connectedRelays) - { - thread disconnectionThread([this, relay]() { - this->disconnect(relay); - }); - disconnectionThreads.push_back(move(disconnectionThread)); - - // TODO: Close subscriptions before disconnecting. - lock_guard lock(this->_propertyMutex); - this->_subscriptions.erase(relay); - } - - for (thread& disconnectionThread : disconnectionThreads) - { - disconnectionThread.join(); - } -}; - -// TODO: Make this method return a promise. -tuple, vector> NostrService::publishEvent(shared_ptr event) -{ - vector successfulRelays; - vector failedRelays; - - PLOG_INFO << "Attempting to publish event to Nostr relays."; - - json message; - try - { - this->_signer->sign(event); - message = json::array({ "EVENT", event->serialize() }); - } - catch (const std::invalid_argument& e) - { - PLOG_ERROR << "Failed to sign event: " << e.what(); - throw e; - } - catch (const json::exception& je) - { - PLOG_ERROR << "Failed to serialize event: " << je.what(); - throw je; - } - - lock_guard lock(this->_propertyMutex); - vector targetRelays = this->_activeRelays; - vector>> publishFutures; - for (const string& relay : targetRelays) - { - promise> publishPromise; - publishFutures.push_back(move(publishPromise.get_future())); - - auto [uri, success] = this->_client->send( - message.dump(), - relay, - [this, &relay, &event, &publishPromise](string response) - { - this->onAcceptance(response, [this, &relay, &event, &publishPromise](bool isAccepted) - { - if (isAccepted) - { - PLOG_INFO << "Relay " << relay << " accepted event: " << event->id; - publishPromise.set_value(make_tuple(relay, true)); - } - else - { - PLOG_WARNING << "Relay " << relay << " rejected event: " << event->id; - publishPromise.set_value(make_tuple(relay, false)); - } - }); - }); - - if (!success) - { - PLOG_WARNING << "Failed to send event to relay " << relay; - publishPromise.set_value(make_tuple(relay, false)); - } - } - - for (auto& publishFuture : publishFutures) - { - auto [relay, isSuccess] = publishFuture.get(); - if (isSuccess) - { - successfulRelays.push_back(relay); - } - else - { - failedRelays.push_back(relay); - } - } - - std::size_t targetCount = targetRelays.size(); - std::size_t successfulCount = successfulRelays.size(); - PLOG_INFO << "Published event to " << successfulCount << "/" << targetCount << " target relays."; - - return make_tuple(successfulRelays, failedRelays); -}; - -// TODO: Make this method return a promise. -// TODO: Add a timeout to this method to prevent hanging while waiting for the relay. -vector> NostrService::queryRelays(shared_ptr filters) -{ - if (filters->limit > 64 || filters->limit < 1) - { - PLOG_WARNING << "Filters limit must be between 1 and 64, inclusive. Setting limit to 16."; - filters->limit = 16; - } - - vector> events; - - string subscriptionId = this->generateSubscriptionId(); - string request; - - try - { - request = filters->serialize(subscriptionId); - } - catch (const invalid_argument& e) - { - PLOG_ERROR << "Failed to serialize filters - invalid object: " << e.what(); - throw e; - } - catch (const json::exception& je) - { - PLOG_ERROR << "Failed to serialize filters - JSON exception: " << je.what(); - throw je; - } - - vector>> requestFutures; - - // Send the same query to each relay. As events trickle in from each relay, they will be added - // to the events vector. Multiple copies of an event may be received if the same event is - // stored on multiple relays. The function will block until all of the relays send an EOSE or - // CLOSE message. - for (const string relay : this->_activeRelays) - { - promise> eosePromise; - requestFutures.push_back(move(eosePromise.get_future())); - - auto [uri, success] = this->_client->send( - request, - relay, - [this, &relay, &events, &eosePromise](string payload) - { - this->onSubscriptionMessage( - payload, - [&events](const string&, shared_ptr event) - { - events.push_back(event); - }, - [relay, &eosePromise](const string&) - { - eosePromise.set_value(make_tuple(relay, true)); - }, - [relay, &eosePromise](const string&, const string&) - { - eosePromise.set_value(make_tuple(relay, false)); - }); - }); - - if (success) - { - PLOG_INFO << "Sent query to relay " << relay; - lock_guard lock(this->_propertyMutex); - this->_subscriptions[subscriptionId].push_back(relay); - } - else - { - PLOG_WARNING << "Failed to send query to relay " << relay; - eosePromise.set_value(make_tuple(uri, false)); - } - } - - // Close open subscriptions and disconnect from relays after events are received. - for (auto& publishFuture : requestFutures) - { - auto [relay, isEose] = publishFuture.get(); - if (isEose) - { - PLOG_INFO << "Received EOSE message from relay " << relay; - } - else - { - PLOG_WARNING << "Received CLOSE message from relay " << relay; - this->closeRelayConnections({ relay }); - } - } - this->closeSubscription(subscriptionId); - - // TODO: De-duplicate events in the vector before returning. - - return events; -}; - -string NostrService::queryRelays( - shared_ptr filters, - function)> eventHandler, - function eoseHandler, - function closeHandler) -{ - vector successfulRelays; - vector failedRelays; - - string subscriptionId = this->generateSubscriptionId(); - string request = filters->serialize(subscriptionId); - vector>> requestFutures; - for (const string relay : this->_activeRelays) - { - unique_lock lock(this->_propertyMutex); - this->_subscriptions[subscriptionId].push_back(relay); - lock.unlock(); - - future> requestFuture = async( - [this, &relay, &request, &eventHandler, &eoseHandler, &closeHandler]() - { - return this->_client->send( - request, - relay, - [this, &eventHandler, &eoseHandler, &closeHandler](string payload) - { - this->onSubscriptionMessage(payload, eventHandler, eoseHandler, closeHandler); - }); - }); - requestFutures.push_back(move(requestFuture)); - } - - for (auto& publishFuture : requestFutures) - { - auto [relay, isSuccess] = publishFuture.get(); - if (isSuccess) - { - successfulRelays.push_back(relay); - } - else - { - failedRelays.push_back(relay); - } - } - - std::size_t targetCount = this->_activeRelays.size(); - std::size_t successfulCount = successfulRelays.size(); - PLOG_INFO << "Sent query to " << successfulCount << "/" << targetCount << " open relay connections."; - - return subscriptionId; -}; - -tuple, vector> NostrService::closeSubscription(string subscriptionId) -{ - vector successfulRelays; - vector failedRelays; - - vector subscriptionRelays; - std::size_t subscriptionRelayCount; - vector>> closeFutures; - - try - { - unique_lock lock(this->_propertyMutex); - subscriptionRelays = this->_subscriptions.at(subscriptionId); - subscriptionRelayCount = subscriptionRelays.size(); - lock.unlock(); - } - catch (const out_of_range& oor) - { - PLOG_WARNING << "Subscription " << subscriptionId << " not found."; - return make_tuple(successfulRelays, failedRelays); - } - - for (const string relay : subscriptionRelays) - { - future> closeFuture = async([this, subscriptionId, relay]() - { - bool success = this->closeSubscription(subscriptionId, relay); - - return make_tuple(relay, success); - }); - closeFutures.push_back(move(closeFuture)); - } - - for (auto& closeFuture : closeFutures) - { - auto [uri, success] = closeFuture.get(); - if (success) - { - successfulRelays.push_back(uri); - } - else - { - failedRelays.push_back(uri); - } - } - - std::size_t successfulCount = successfulRelays.size(); - PLOG_INFO << "Sent CLOSE request for subscription " << subscriptionId << " to " << successfulCount << "/" << subscriptionRelayCount << " open relay connections."; - - // If there were no failures, and the subscription has been closed on all of its relays, forget - // about the subscription. - if (failedRelays.empty()) - { - lock_guard lock(this->_propertyMutex); - this->_subscriptions.erase(subscriptionId); - } - - return make_tuple(successfulRelays, failedRelays); -}; - -bool NostrService::closeSubscription(string subscriptionId, string relay) -{ - if (!this->hasSubscription(subscriptionId, relay)) - { - PLOG_WARNING << "Subscription " << subscriptionId << " not found on relay " << relay; - return false; - } - - if (!this->isConnected(relay)) - { - PLOG_WARNING << "Relay " << relay << " is not connected."; - return false; - } - - string request = this->generateCloseRequest(subscriptionId); - auto [uri, success] = this->_client->send(request, relay); - - if (success) - { - lock_guard lock(this->_propertyMutex); - auto it = find( - this->_subscriptions[subscriptionId].begin(), - this->_subscriptions[subscriptionId].end(), - relay); - - if (it != this->_subscriptions[subscriptionId].end()) - { - this->_subscriptions[subscriptionId].erase(it); - } - - PLOG_INFO << "Sent close request for subscription " << subscriptionId << " to relay " << relay; - } - else - { - PLOG_WARNING << "Failed to send close request to relay " << relay; - } - - return success; -}; - -vector NostrService::closeSubscriptions() -{ - unique_lock lock(this->_propertyMutex); - vector subscriptionIds; - for (auto& [subscriptionId, relays] : this->_subscriptions) - { - subscriptionIds.push_back(subscriptionId); - } - lock.unlock(); - - vector remainingSubscriptions; - for (const string& subscriptionId : subscriptionIds) - { - auto [successes, failures] = this->closeSubscription(subscriptionId); - if (!failures.empty()) - { - remainingSubscriptions.push_back(subscriptionId); - } - } - - return remainingSubscriptions; -}; - -vector NostrService::getConnectedRelays(vector relays) -{ - PLOG_VERBOSE << "Identifying connected relays."; - vector connectedRelays; - for (string relay : relays) - { - bool isActive = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay) - != this->_activeRelays.end(); - bool isConnected = this->_client->isConnected(relay); - PLOG_VERBOSE << "Relay " << relay << " is active: " << isActive << ", is connected: " << isConnected; - - if (isActive && isConnected) - { - connectedRelays.push_back(relay); - } - else if (isActive && !isConnected) - { - this->eraseActiveRelay(relay); - } - else if (!isActive && isConnected) - { - this->_activeRelays.push_back(relay); - connectedRelays.push_back(relay); - } - } - return connectedRelays; -}; - -vector NostrService::getUnconnectedRelays(vector relays) -{ - PLOG_VERBOSE << "Identifying unconnected relays."; - vector unconnectedRelays; - for (string relay : relays) - { - bool isActive = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay) - != this->_activeRelays.end(); - bool isConnected = this->_client->isConnected(relay); - PLOG_VERBOSE << "Relay " << relay << " is active: " << isActive << ", is connected: " << isConnected; - - if (!isActive && !isConnected) - { - PLOG_VERBOSE << "Relay " << relay << " is not active and not connected."; - unconnectedRelays.push_back(relay); - } - else if (isActive && !isConnected) - { - PLOG_VERBOSE << "Relay " << relay << " is active but not connected. Removing from active relays list."; - this->eraseActiveRelay(relay); - unconnectedRelays.push_back(relay); - } - else if (!isActive && isConnected) - { - PLOG_VERBOSE << "Relay " << relay << " is connected but not active. Adding to active relays list."; - this->_activeRelays.push_back(relay); - } - } - return unconnectedRelays; -}; - -bool NostrService::isConnected(string relay) -{ - auto it = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay); - if (it != this->_activeRelays.end()) // If the relay is in this->_activeRelays - { - return true; - } - return false; -}; - -void NostrService::eraseActiveRelay(string relay) -{ - auto it = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay); - if (it != this->_activeRelays.end()) // If the relay is in this->_activeRelays - { - this->_activeRelays.erase(it); - } -}; - -void NostrService::connect(string relay) -{ - PLOG_VERBOSE << "Connecting to relay " << relay; - this->_client->openConnection(relay); - - lock_guard lock(this->_propertyMutex); - bool isConnected = this->_client->isConnected(relay); - - if (isConnected) - { - PLOG_VERBOSE << "Connected to relay " << relay << ": " << isConnected; - this->_activeRelays.push_back(relay); - } - else - { - PLOG_ERROR << "Failed to connect to relay " << relay; - } -}; - -void NostrService::disconnect(string relay) -{ - this->_client->closeConnection(relay); - - lock_guard lock(this->_propertyMutex); - this->eraseActiveRelay(relay); -}; - -string NostrService::generateSubscriptionId() -{ - UUIDv4::UUIDGenerator uuidGenerator; - UUIDv4::UUID uuid = uuidGenerator.getUUID(); - return uuid.str(); -}; - -string NostrService::generateCloseRequest(string subscriptionId) -{ - json jarr = json::array({ "CLOSE", subscriptionId }); - return jarr.dump(); -}; - -bool NostrService::hasSubscription(string subscriptionId) -{ - lock_guard lock(this->_propertyMutex); - auto it = this->_subscriptions.find(subscriptionId); - - return it != this->_subscriptions.end(); -}; - -bool NostrService::hasSubscription(string subscriptionId, string relay) -{ - lock_guard lock(this->_propertyMutex); - auto subscriptionIt = this->_subscriptions.find(subscriptionId); - - if (subscriptionIt == this->_subscriptions.end()) - { - return false; - } - - auto relays = this->_subscriptions[subscriptionId]; - auto relayIt = find(relays.begin(), relays.end(), relay); - - return relayIt != relays.end(); -}; - -void NostrService::onSubscriptionMessage( - string message, - function)> eventHandler, - function eoseHandler, - function closeHandler) -{ - try - { - json jMessage = json::parse(message); - string messageType = jMessage.at(0); - if (messageType == "EVENT") - { - string subscriptionId = jMessage.at(1); - data::Event event = data::Event::fromString(jMessage.at(2)); - eventHandler(subscriptionId, make_shared(event)); - } - else if (messageType == "EOSE") - { - string subscriptionId = jMessage.at(1); - eoseHandler(subscriptionId); - } - else if (messageType == "CLOSE") - { - string subscriptionId = jMessage.at(1); - string reason = jMessage.at(2); - closeHandler(subscriptionId, reason); - } - } - catch (const json::out_of_range& joor) - { - PLOG_ERROR << "JSON out-of-range exception: " << joor.what(); - throw joor; - } - catch (const json::exception& je) - { - PLOG_ERROR << "JSON handling exception: " << je.what(); - throw je; - } - catch (const invalid_argument& ia) - { - PLOG_ERROR << "Invalid argument exception: " << ia.what(); - throw ia; - } -}; - -void NostrService::onAcceptance(string message, function acceptanceHandler) -{ - try - { - json jMessage = json::parse(message); - string messageType = jMessage[0]; - if (messageType == "OK") - { - bool isAccepted = jMessage[2]; - acceptanceHandler(isAccepted); - } - } - catch (const json::exception& je) - { - PLOG_ERROR << "JSON handling exception: " << je.what(); - throw je; - } -}; -} // namespace nostr diff --git a/src/nostr_service_base.cpp b/src/nostr_service_base.cpp new file mode 100644 index 0000000..bce6728 --- /dev/null +++ b/src/nostr_service_base.cpp @@ -0,0 +1,658 @@ +#include "nostr_service_base.hpp" + +using namespace nlohmann; +using namespace std; + +nostr::NostrServiceBase::NostrServiceBase( + shared_ptr appender, + shared_ptr client) +: NostrServiceBase(appender, client, {}) { }; + +nostr::NostrServiceBase::NostrServiceBase( + shared_ptr appender, + shared_ptr client, + vector relays) +: _defaultRelays(relays), _client(client) +{ + plog::init(plog::debug, appender.get()); + client->start(); +}; + +nostr::NostrServiceBase::~NostrServiceBase() +{ + this->_client->stop(); +}; + +vector nostr::NostrServiceBase::defaultRelays() const +{ return this->_defaultRelays; }; + +vector nostr::NostrServiceBase::activeRelays() const +{ return this->_activeRelays; }; + +unordered_map> nostr::NostrServiceBase::subscriptions() const +{ return this->_subscriptions; }; + +vector nostr::NostrServiceBase::openRelayConnections() +{ + return this->openRelayConnections(this->_defaultRelays); +}; + +vector nostr::NostrServiceBase::openRelayConnections(vector relays) +{ + PLOG_INFO << "Attempting to connect to Nostr relays."; + vector unconnectedRelays = this->_getUnconnectedRelays(relays); + + vector connectionThreads; + for (string relay : unconnectedRelays) + { + thread connectionThread([this, relay]() { + this->_connect(relay); + }); + connectionThreads.push_back(move(connectionThread)); + } + + for (thread& connectionThread : connectionThreads) + { + connectionThread.join(); + } + + std::size_t targetCount = relays.size(); + std::size_t activeCount = this->_activeRelays.size(); + PLOG_INFO << "Connected to " << activeCount << "/" << targetCount << " target relays."; + + // This property should only contain successful relays at this point. + return this->_activeRelays; +}; + +void nostr::NostrServiceBase::closeRelayConnections() +{ + if (this->_activeRelays.size() == 0) + { + PLOG_INFO << "No active relay connections to close."; + return; + } + + this->closeRelayConnections(this->_activeRelays); +}; + +void nostr::NostrServiceBase::closeRelayConnections(vector relays) +{ + PLOG_INFO << "Disconnecting from Nostr relays."; + vector connectedRelays = this->_getConnectedRelays(relays); + + vector disconnectionThreads; + for (string relay : connectedRelays) + { + thread disconnectionThread([this, relay]() { + this->_disconnect(relay); + }); + disconnectionThreads.push_back(move(disconnectionThread)); + + // TODO: Close subscriptions before disconnecting. + lock_guard lock(this->_propertyMutex); + this->_subscriptions.erase(relay); + } + + for (thread& disconnectionThread : disconnectionThreads) + { + disconnectionThread.join(); + } +}; + +// TODO: Make this method return a promise. +tuple, vector> nostr::NostrServiceBase::publishEvent( + shared_ptr event) +{ + vector successfulRelays; + vector failedRelays; + + PLOG_INFO << "Attempting to publish event to Nostr relays."; + + json message; + try + { + message = json::array({ "EVENT", event->serialize() }); + } + catch (const std::invalid_argument& e) + { + PLOG_ERROR << "Failed to sign event: " << e.what(); + throw e; + } + catch (const json::exception& je) + { + PLOG_ERROR << "Failed to serialize event: " << je.what(); + throw je; + } + + lock_guard lock(this->_propertyMutex); + vector targetRelays = this->_activeRelays; + vector>> publishFutures; + for (const string& relay : targetRelays) + { + promise> publishPromise; + publishFutures.push_back(move(publishPromise.get_future())); + + auto [uri, success] = this->_client->send( + message.dump(), + relay, + [this, &relay, &event, &publishPromise](string response) + { + this->_onAcceptance(response, [this, &relay, &event, &publishPromise](bool isAccepted) + { + if (isAccepted) + { + PLOG_INFO << "Relay " << relay << " accepted event: " << event->id; + publishPromise.set_value(make_tuple(relay, true)); + } + else + { + PLOG_WARNING << "Relay " << relay << " rejected event: " << event->id; + publishPromise.set_value(make_tuple(relay, false)); + } + }); + }); + + if (!success) + { + PLOG_WARNING << "Failed to send event to relay " << relay; + publishPromise.set_value(make_tuple(relay, false)); + } + } + + for (auto& publishFuture : publishFutures) + { + auto [relay, isSuccess] = publishFuture.get(); + if (isSuccess) + { + successfulRelays.push_back(relay); + } + else + { + failedRelays.push_back(relay); + } + } + + std::size_t targetCount = targetRelays.size(); + std::size_t successfulCount = successfulRelays.size(); + PLOG_INFO << "Published event to " << successfulCount << "/" << targetCount << " target relays."; + + return make_tuple(successfulRelays, failedRelays); +}; + +// TODO: Make this method return a promise. +// TODO: Add a timeout to this method to prevent hanging while waiting for the relay. +vector> nostr::NostrServiceBase::queryRelays( + shared_ptr filters) +{ + if (filters->limit > 64 || filters->limit < 1) + { + PLOG_WARNING << "Filters limit must be between 1 and 64, inclusive. Setting limit to 16."; + filters->limit = 16; + } + + vector> events; + + string subscriptionId = this->_generateSubscriptionId(); + string request; + + try + { + request = filters->serialize(subscriptionId); + } + catch (const invalid_argument& e) + { + PLOG_ERROR << "Failed to serialize filters - invalid object: " << e.what(); + throw e; + } + catch (const json::exception& je) + { + PLOG_ERROR << "Failed to serialize filters - JSON exception: " << je.what(); + throw je; + } + + vector>> requestFutures; + + // Send the same query to each relay. As events trickle in from each relay, they will be added + // to the events vector. Multiple copies of an event may be received if the same event is + // stored on multiple relays. The function will block until all of the relays send an EOSE or + // CLOSE message. + for (const string relay : this->_activeRelays) + { + promise> eosePromise; + requestFutures.push_back(move(eosePromise.get_future())); + + auto [uri, success] = this->_client->send( + request, + relay, + [this, &relay, &events, &eosePromise](string payload) + { + this->_onSubscriptionMessage( + payload, + [&events](const string&, shared_ptr event) + { + events.push_back(event); + }, + [relay, &eosePromise](const string&) + { + eosePromise.set_value(make_tuple(relay, true)); + }, + [relay, &eosePromise](const string&, const string&) + { + eosePromise.set_value(make_tuple(relay, false)); + }); + }); + + if (success) + { + PLOG_INFO << "Sent query to relay " << relay; + lock_guard lock(this->_propertyMutex); + this->_subscriptions[subscriptionId].push_back(relay); + } + else + { + PLOG_WARNING << "Failed to send query to relay " << relay; + eosePromise.set_value(make_tuple(uri, false)); + } + } + + // Close open subscriptions and disconnect from relays after events are received. + for (auto& publishFuture : requestFutures) + { + auto [relay, isEose] = publishFuture.get(); + if (isEose) + { + PLOG_INFO << "Received EOSE message from relay " << relay; + } + else + { + PLOG_WARNING << "Received CLOSE message from relay " << relay; + this->closeRelayConnections({ relay }); + } + } + this->closeSubscription(subscriptionId); + + // TODO: De-duplicate events in the vector before returning. + + return events; +}; + +string nostr::NostrServiceBase::queryRelays( + shared_ptr filters, + function)> eventHandler, + function eoseHandler, + function closeHandler) +{ + vector successfulRelays; + vector failedRelays; + + string subscriptionId = this->_generateSubscriptionId(); + string request = filters->serialize(subscriptionId); + vector>> requestFutures; + for (const string relay : this->_activeRelays) + { + unique_lock lock(this->_propertyMutex); + this->_subscriptions[subscriptionId].push_back(relay); + lock.unlock(); + + future> requestFuture = async( + [this, &relay, &request, &eventHandler, &eoseHandler, &closeHandler]() + { + return this->_client->send( + request, + relay, + [this, &eventHandler, &eoseHandler, &closeHandler](string payload) + { + this->_onSubscriptionMessage(payload, eventHandler, eoseHandler, closeHandler); + }); + }); + requestFutures.push_back(move(requestFuture)); + } + + for (auto& publishFuture : requestFutures) + { + auto [relay, isSuccess] = publishFuture.get(); + if (isSuccess) + { + successfulRelays.push_back(relay); + } + else + { + failedRelays.push_back(relay); + } + } + + std::size_t targetCount = this->_activeRelays.size(); + std::size_t successfulCount = successfulRelays.size(); + PLOG_INFO << "Sent query to " << successfulCount << "/" << targetCount << " open relay connections."; + + return subscriptionId; +}; + +tuple, vector> nostr::NostrServiceBase::closeSubscription(string subscriptionId) +{ + vector successfulRelays; + vector failedRelays; + + vector subscriptionRelays; + std::size_t subscriptionRelayCount; + vector>> closeFutures; + + try + { + unique_lock lock(this->_propertyMutex); + subscriptionRelays = this->_subscriptions.at(subscriptionId); + subscriptionRelayCount = subscriptionRelays.size(); + lock.unlock(); + } + catch (const out_of_range& oor) + { + PLOG_WARNING << "Subscription " << subscriptionId << " not found."; + return make_tuple(successfulRelays, failedRelays); + } + + for (const string relay : subscriptionRelays) + { + future> closeFuture = async([this, subscriptionId, relay]() + { + bool success = this->closeSubscription(subscriptionId, relay); + + return make_tuple(relay, success); + }); + closeFutures.push_back(move(closeFuture)); + } + + for (auto& closeFuture : closeFutures) + { + auto [uri, success] = closeFuture.get(); + if (success) + { + successfulRelays.push_back(uri); + } + else + { + failedRelays.push_back(uri); + } + } + + std::size_t successfulCount = successfulRelays.size(); + PLOG_INFO << "Sent CLOSE request for subscription " << subscriptionId << " to " << successfulCount << "/" << subscriptionRelayCount << " open relay connections."; + + // If there were no failures, and the subscription has been closed on all of its relays, forget + // about the subscription. + if (failedRelays.empty()) + { + lock_guard lock(this->_propertyMutex); + this->_subscriptions.erase(subscriptionId); + } + + return make_tuple(successfulRelays, failedRelays); +}; + +bool nostr::NostrServiceBase::closeSubscription(string subscriptionId, string relay) +{ + if (!this->_hasSubscription(subscriptionId, relay)) + { + PLOG_WARNING << "Subscription " << subscriptionId << " not found on relay " << relay; + return false; + } + + if (!this->_isConnected(relay)) + { + PLOG_WARNING << "Relay " << relay << " is not connected."; + return false; + } + + string request = this->_generateCloseRequest(subscriptionId); + auto [uri, success] = this->_client->send(request, relay); + + if (success) + { + lock_guard lock(this->_propertyMutex); + auto it = find( + this->_subscriptions[subscriptionId].begin(), + this->_subscriptions[subscriptionId].end(), + relay); + + if (it != this->_subscriptions[subscriptionId].end()) + { + this->_subscriptions[subscriptionId].erase(it); + } + + PLOG_INFO << "Sent close request for subscription " << subscriptionId << " to relay " << relay; + } + else + { + PLOG_WARNING << "Failed to send close request to relay " << relay; + } + + return success; +}; + +vector nostr::NostrServiceBase::closeSubscriptions() +{ + unique_lock lock(this->_propertyMutex); + vector subscriptionIds; + for (auto& [subscriptionId, relays] : this->_subscriptions) + { + subscriptionIds.push_back(subscriptionId); + } + lock.unlock(); + + vector remainingSubscriptions; + for (const string& subscriptionId : subscriptionIds) + { + auto [successes, failures] = this->closeSubscription(subscriptionId); + if (!failures.empty()) + { + remainingSubscriptions.push_back(subscriptionId); + } + } + + return remainingSubscriptions; +}; + +vector nostr::NostrServiceBase::_getConnectedRelays(vector relays) +{ + PLOG_VERBOSE << "Identifying connected relays."; + vector connectedRelays; + for (string relay : relays) + { + bool isActive = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay) + != this->_activeRelays.end(); + bool isConnected = this->_client->isConnected(relay); + PLOG_VERBOSE << "Relay " << relay << " is active: " << isActive << ", is connected: " << isConnected; + + if (isActive && isConnected) + { + connectedRelays.push_back(relay); + } + else if (isActive && !isConnected) + { + this->_eraseActiveRelay(relay); + } + else if (!isActive && isConnected) + { + this->_activeRelays.push_back(relay); + connectedRelays.push_back(relay); + } + } + return connectedRelays; +}; + +vector nostr::NostrServiceBase::_getUnconnectedRelays(vector relays) +{ + PLOG_VERBOSE << "Identifying unconnected relays."; + vector unconnectedRelays; + for (string relay : relays) + { + bool isActive = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay) + != this->_activeRelays.end(); + bool isConnected = this->_client->isConnected(relay); + PLOG_VERBOSE << "Relay " << relay << " is active: " << isActive << ", is connected: " << isConnected; + + if (!isActive && !isConnected) + { + PLOG_VERBOSE << "Relay " << relay << " is not active and not connected."; + unconnectedRelays.push_back(relay); + } + else if (isActive && !isConnected) + { + PLOG_VERBOSE << "Relay " << relay << " is active but not connected. Removing from active relays list."; + this->_eraseActiveRelay(relay); + unconnectedRelays.push_back(relay); + } + else if (!isActive && isConnected) + { + PLOG_VERBOSE << "Relay " << relay << " is connected but not active. Adding to active relays list."; + this->_activeRelays.push_back(relay); + } + } + return unconnectedRelays; +}; + +bool nostr::NostrServiceBase::_isConnected(string relay) +{ + auto it = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay); + if (it != this->_activeRelays.end()) // If the relay is in this->_activeRelays + { + return true; + } + return false; +}; + +void nostr::NostrServiceBase::_eraseActiveRelay(string relay) +{ + auto it = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay); + if (it != this->_activeRelays.end()) // If the relay is in this->_activeRelays + { + this->_activeRelays.erase(it); + } +}; + +void nostr::NostrServiceBase::_connect(string relay) +{ + PLOG_VERBOSE << "Connecting to relay " << relay; + this->_client->openConnection(relay); + + lock_guard lock(this->_propertyMutex); + bool isConnected = this->_client->isConnected(relay); + + if (isConnected) + { + PLOG_VERBOSE << "Connected to relay " << relay << ": " << isConnected; + this->_activeRelays.push_back(relay); + } + else + { + PLOG_ERROR << "Failed to connect to relay " << relay; + } +}; + +void nostr::NostrServiceBase::_disconnect(string relay) +{ + this->_client->closeConnection(relay); + + lock_guard lock(this->_propertyMutex); + this->_eraseActiveRelay(relay); +}; + +string nostr::NostrServiceBase::_generateSubscriptionId() +{ + UUIDv4::UUIDGenerator uuidGenerator; + UUIDv4::UUID uuid = uuidGenerator.getUUID(); + return uuid.str(); +}; + +string nostr::NostrServiceBase::_generateCloseRequest(string subscriptionId) +{ + json jarr = json::array({ "CLOSE", subscriptionId }); + return jarr.dump(); +}; + +bool nostr::NostrServiceBase::_hasSubscription(string subscriptionId) +{ + lock_guard lock(this->_propertyMutex); + auto it = this->_subscriptions.find(subscriptionId); + + return it != this->_subscriptions.end(); +}; + +bool nostr::NostrServiceBase::_hasSubscription(string subscriptionId, string relay) +{ + lock_guard lock(this->_propertyMutex); + auto subscriptionIt = this->_subscriptions.find(subscriptionId); + + if (subscriptionIt == this->_subscriptions.end()) + { + return false; + } + + auto relays = this->_subscriptions[subscriptionId]; + auto relayIt = find(relays.begin(), relays.end(), relay); + + return relayIt != relays.end(); +}; + +void nostr::NostrServiceBase::_onSubscriptionMessage( + string message, + function)> eventHandler, + function eoseHandler, + function closeHandler) +{ + try + { + json jMessage = json::parse(message); + string messageType = jMessage.at(0); + if (messageType == "EVENT") + { + string subscriptionId = jMessage.at(1); + nostr::data::Event event = nostr::data::Event::fromString(jMessage.at(2)); + eventHandler(subscriptionId, make_shared(event)); + } + else if (messageType == "EOSE") + { + string subscriptionId = jMessage.at(1); + eoseHandler(subscriptionId); + } + else if (messageType == "CLOSE") + { + string subscriptionId = jMessage.at(1); + string reason = jMessage.at(2); + closeHandler(subscriptionId, reason); + } + } + catch (const json::out_of_range& joor) + { + PLOG_ERROR << "JSON out-of-range exception: " << joor.what(); + throw joor; + } + catch (const json::exception& je) + { + PLOG_ERROR << "JSON handling exception: " << je.what(); + throw je; + } + catch (const invalid_argument& ia) + { + PLOG_ERROR << "Invalid argument exception: " << ia.what(); + throw ia; + } +}; + +void nostr::NostrServiceBase::_onAcceptance(string message, function acceptanceHandler) +{ + try + { + json jMessage = json::parse(message); + string messageType = jMessage[0]; + if (messageType == "OK") + { + bool isAccepted = jMessage[2]; + acceptanceHandler(isAccepted); + } + } + catch (const json::exception& je) + { + PLOG_ERROR << "JSON handling exception: " << je.what(); + throw je; + } +}; diff --git a/src/signer/noscrypt_signer.cpp b/src/signer/noscrypt_signer.cpp index c56070c..39bb667 100644 --- a/src/signer/noscrypt_signer.cpp +++ b/src/signer/noscrypt_signer.cpp @@ -1,169 +1,151 @@ -#include - -#include "signer.hpp" +#include "noscrypt_signer.hpp" using namespace std; -namespace nostr +nostr::signer::NoscryptSigner::NoscryptSigner(shared_ptr appender) { -namespace signer + plog::init(plog::debug, appender.get()); + + this->noscryptContext = this->_initNoscryptContext(); + if (this->noscryptContext == nullptr) + { + return; + } + + const auto [privateKey, publicKey] = this->_createLocalKeypair(); + this->localPrivateKey = privateKey; + this->localPublicKey = publicKey; +}; + +nostr::signer::NoscryptSigner::~NoscryptSigner() { -class NoscryptSigner : public INostrConnectSigner + NCDestroyContext(this->noscryptContext.get()); +}; + +void nostr::signer::NoscryptSigner::receiveConnection(string connectionToken) { -public: - NoscryptSigner(shared_ptr appender) - { - plog::init(plog::debug, appender.get()); + // Receive the connection token here. +}; - this->noscryptContext = this->initNoscryptContext(); - if (this->noscryptContext == nullptr) - { - return; - } +void nostr::signer::NoscryptSigner::initiateConnection( + string relay, + string name, + string url, + string description) +{ + // Initiate the connection here. +}; - const auto [privateKey, publicKey] = this->createLocalKeypair(); - this->localPrivateKey = privateKey; - this->localPublicKey = publicKey; - }; +void nostr::signer::NoscryptSigner::sign(shared_ptr event) +{ + // Sign the event here. +}; - ~NoscryptSigner() - { - NCDestroyContext(this->noscryptContext.get()); - }; +/** + * @brief Initializes the noscrypt library context into the class's `context` property. + * @returns `true` if successful, `false` otherwise. + */ +shared_ptr nostr::signer::NoscryptSigner::_initNoscryptContext() +{ + shared_ptr context(new NCContext); + auto contextStructSize = NCGetContextStructSize(); + unique_ptr randomEntropy(new uint8_t[contextStructSize]); - void receiveConnection(string connectionToken) override - { - // Receive the connection token here. - }; - - void initiateConnection( - string relay, - string name, - string url, - string description) override - { - // Initiate the connection here. - }; + random_device rd; + mt19937 gen(rd()); + uniform_int_distribution<> dist(0, contextStructSize); + generate_n(randomEntropy.get(), contextStructSize, [&]() { return dist(gen); }); - void sign(shared_ptr event) override + NCResult result = NCInitContext(context.get(), randomEntropy.get()); + this->_logNoscryptResult(result); + + if (result != NC_SUCCESS) { - // Sign the event here. - }; + return nullptr; + } -private: - shared_ptr noscryptContext; + return context; +}; - string localPrivateKey; - string localPublicKey; +void nostr::signer::NoscryptSigner::_logNoscryptResult(NCResult result) +{ + switch (result) { + case NC_SUCCESS: + PLOG_INFO << "noscrypt - success"; + break; - /** - * @brief Initializes the noscrypt library context into the class's `context` property. - * @returns `true` if successful, `false` otherwise. - */ - shared_ptr initNoscryptContext() - { - shared_ptr context(new NCContext); - auto contextStructSize = NCGetContextStructSize(); - unique_ptr randomEntropy(new uint8_t[contextStructSize]); + case E_NULL_PTR: + PLOG_ERROR << "noscrypt - error: A null pointer was passed to the initializer."; + break; - random_device rd; - mt19937 gen(rd()); - uniform_int_distribution<> dist(0, contextStructSize); - generate_n(randomEntropy.get(), contextStructSize, [&]() { return dist(gen); }); + case E_INVALID_ARG: + PLOG_ERROR << "noscrypt - error: An invalid argument was passed to the initializer."; + break; + + case E_INVALID_CONTEXT: + PLOG_ERROR << "noscrypt - error: The NCContext struct is in an invalid state."; + break; + + case E_ARGUMENT_OUT_OF_RANGE: + PLOG_ERROR << "noscrypt - error: An initializer argument was outside the range of acceptable values."; + break; + + case E_OPERATION_FAILED: + PLOG_ERROR << "noscrypt - error"; + break; + } +}; - NCResult result = NCInitContext(context.get(), randomEntropy.get()); - this->logNoscryptResult(result); +/** + * @brief Generates a private/public key pair for local use. + * @returns The generated keypair of the form `[privateKey, publicKey]`, or a pair of empty + * strings if the function failed. + * @remarks This keypair is intended for temporary use, and should not be saved or used outside + * of this class. + */ +tuple nostr::signer::NoscryptSigner::_createLocalKeypair() +{ + string privateKey; + string publicKey; - if (result != NC_SUCCESS) - { - return nullptr; - } + // To generate a private key, all we need is a random 32-bit buffer. + unique_ptr secretKey(new NCSecretKey); - return context; - }; + random_device rd; + mt19937 gen(rd()); + uniform_int_distribution<> dist(0, NC_SEC_KEY_SIZE); + generate_n(secretKey.get()->key, NC_SEC_KEY_SIZE, [&]() { return dist(gen); }); - void logNoscryptResult(NCResult result) + // Convert the buffer into a hex string for a more human-friendly representation. + stringstream secretKeyStream; + for (int i = 0; i < NC_SEC_KEY_SIZE; i++) + { + secretKeyStream << hex << setw(2) << setfill('0') << static_cast(secretKey->key[i]); + } + privateKey = secretKeyStream.str(); + + // Use noscrypt to derive the public key from its private counterpart. + unique_ptr pubkey(new NCPublicKey); + NCResult result = NCGetPublicKey( + this->noscryptContext.get(), + secretKey.get(), + pubkey.get()); + this->_logNoscryptResult(result); + + if (result != NC_SUCCESS) { - switch (result) { - case NC_SUCCESS: - PLOG_INFO << "noscrypt - success"; - break; - - case E_NULL_PTR: - PLOG_ERROR << "noscrypt - error: A null pointer was passed to the initializer."; - break; - - case E_INVALID_ARG: - PLOG_ERROR << "noscrypt - error: An invalid argument was passed to the initializer."; - break; - - case E_INVALID_CONTEXT: - PLOG_ERROR << "noscrypt - error: The NCContext struct is in an invalid state."; - break; - - case E_ARGUMENT_OUT_OF_RANGE: - PLOG_ERROR << "noscrypt - error: An initializer argument was outside the range of acceptable values."; - break; - - case E_OPERATION_FAILED: - PLOG_ERROR << "noscrypt - error"; - break; - } - }; - - /** - * @brief Generates a private/public key pair for local use. - * @returns The generated keypair of the form `[privateKey, publicKey]`, or a pair of empty - * strings if the function failed. - * @remarks This keypair is intended for temporary use, and should not be saved or used outside - * of this class. - */ - tuple createLocalKeypair() + // Return empty strings if the key generation fails. + return make_tuple(string(), string()); + } + + // Convert the now-populated pubkey buffer into a hex string for the pubkey representation + // used by Nostr events. + stringstream pubkeyStream; + for (int i = 0; i < NC_SEC_KEY_SIZE; i++) { - string privateKey; - string publicKey; - - // To generate a private key, all we need is a random 32-bit buffer. - unique_ptr secretKey(new NCSecretKey); - - random_device rd; - mt19937 gen(rd()); - uniform_int_distribution<> dist(0, NC_SEC_KEY_SIZE); - generate_n(secretKey.get()->key, NC_SEC_KEY_SIZE, [&]() { return dist(gen); }); - - // Convert the buffer into a hex string for a more human-friendly representation. - stringstream secretKeyStream; - for (int i = 0; i < NC_SEC_KEY_SIZE; i++) - { - secretKeyStream << hex << setw(2) << setfill('0') << static_cast(secretKey->key[i]); - } - privateKey = secretKeyStream.str(); - - // Use noscrypt to derive the public key from its private counterpart. - unique_ptr pubkey(new NCPublicKey); - NCResult result = NCGetPublicKey( - this->noscryptContext.get(), - secretKey.get(), - pubkey.get()); - this->logNoscryptResult(result); - - if (result != NC_SUCCESS) - { - // Return empty strings if the key generation fails. - return make_tuple(string(), string()); - } - - // Convert the now-populated pubkey buffer into a hex string for the pubkey representation - // used by Nostr events. - stringstream pubkeyStream; - for (int i = 0; i < NC_SEC_KEY_SIZE; i++) - { - pubkeyStream << hex << setw(2) << setfill('0') << static_cast(pubkey->key[i]); - } - publicKey = pubkeyStream.str(); - - return make_tuple(privateKey, publicKey); - }; + pubkeyStream << hex << setw(2) << setfill('0') << static_cast(pubkey->key[i]); + } + publicKey = pubkeyStream.str(); + + return make_tuple(privateKey, publicKey); }; -} // namespace signer -} // namespace nostr diff --git a/test/nostr_service_base_test.cpp b/test/nostr_service_base_test.cpp new file mode 100644 index 0000000..9116816 --- /dev/null +++ b/test/nostr_service_base_test.cpp @@ -0,0 +1,1049 @@ +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "nostr.hpp" +#include "nostr_service_base.hpp" +#include "client/web_socket_client.hpp" + +using namespace nostr; +using namespace std; +using namespace ::testing; + +using nlohmann::json; + +namespace nostr_test +{ +class MockWebSocketClient : public client::IWebSocketClient { +public: + MOCK_METHOD(void, start, (), (override)); + MOCK_METHOD(void, stop, (), (override)); + MOCK_METHOD(void, openConnection, (string uri), (override)); + MOCK_METHOD(bool, isConnected, (string uri), (override)); + MOCK_METHOD((tuple), send, (string message, string uri), (override)); + MOCK_METHOD((tuple), send, (string message, string uri, function messageHandler), (override)); + MOCK_METHOD(void, receive, (string uri, function messageHandler), (override)); + MOCK_METHOD(void, closeConnection, (string uri), (override)); +}; + +class NostrServiceBaseTest : public testing::Test +{ +public: + inline static const vector defaultTestRelays = + { + "wss://relay.damus.io", + "wss://nostr.thesamecat.io" + }; + + static const nostr::data::Event getTextNoteTestEvent() + { + nostr::data::Event event; + event.pubkey = "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask"; + event.kind = 1; + event.tags = + { + { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event.content = "Hello, World!"; + + return event; + }; + + static const vector getMultipleTextNoteTestEvents() + { + auto now = std::chrono::system_clock::now(); + std::time_t currentTime = std::chrono::system_clock::to_time_t(now); + + nostr::data::Event event1; + event1.pubkey = "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask"; + event1.kind = 1; + event1.tags = + { + { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event1.content = "Hello, World!"; + event1.createdAt = currentTime; + + nostr::data::Event event2; + event2.pubkey = "1l9d9jh67rkwayalrxcy686aujyz5pper5kzjv8jvg8pu9v9ns4ls0xvq42"; + event2.kind = 1; + event2.tags = + { + { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event2.content = "Welcome to Nostr!"; + event2.createdAt = currentTime; + + nostr::data::Event event3; + event3.pubkey = "187ujhtmnv82ftg03h4heetwk3dd9mlfkf8th3fvmrk20nxk9mansuzuyla"; + event3.kind = 1; + event3.tags = + { + { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event3.content = "Time for some introductions!"; + event3.createdAt = currentTime; + + return { event1, event2, event3 }; + }; + + static const nostr::data::Event getLongFormTestEvent() + { + nostr::data::Event event; + event.pubkey = "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask"; + event.kind = 30023; + event.tags = + { + { "event", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "pubkey", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "author", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event.content = "Hello, World!"; + + return event; + } + + static const string getTestEventMessage(shared_ptr event, string subscriptionId) + { + json jarr = json::array(); + jarr.push_back("EVENT"); + jarr.push_back(subscriptionId); + jarr.push_back(event->serialize()); + + return jarr.dump(); + } + + static const nostr::data::Filters getKind0And1TestFilters() + { + nostr::data::Filters filters; + filters.authors = { + "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask", + "1l9d9jh67rkwayalrxcy686aujyz5pper5kzjv8jvg8pu9v9ns4ls0xvq42", + "187ujhtmnv82ftg03h4heetwk3dd9mlfkf8th3fvmrk20nxk9mansuzuyla" + }; + filters.kinds = { 0, 1 }; + filters.limit = 10; + + return filters; + } + + static const nostr::data::Filters getKind30023TestFilters() + { + nostr::data::Filters filters; + filters.authors = { + "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask", + "1l9d9jh67rkwayalrxcy686aujyz5pper5kzjv8jvg8pu9v9ns4ls0xvq42", + "187ujhtmnv82ftg03h4heetwk3dd9mlfkf8th3fvmrk20nxk9mansuzuyla" + }; + filters.kinds = { 30023 }; + filters.limit = 5; + + return filters; + } + +protected: + shared_ptr> testAppender; + shared_ptr mockClient; + + void SetUp() override + { + testAppender = make_shared>(); + mockClient = make_shared(); + }; +}; + +TEST_F(NostrServiceBaseTest, Constructor_StartsClient) +{ + EXPECT_CALL(*mockClient, start()).Times(1); + + auto nostrService = make_unique(testAppender, mockClient); +}; + +TEST_F(NostrServiceBaseTest, Constructor_InitializesService_WithNoDefaultRelays) +{ + auto nostrService = make_unique(testAppender, mockClient); + auto defaultRelays = nostrService->defaultRelays(); + auto activeRelays = nostrService->activeRelays(); + + ASSERT_EQ(defaultRelays.size(), 0); + ASSERT_EQ(activeRelays.size(), 0); +}; + +TEST_F(NostrServiceBaseTest, Constructor_InitializesService_WithProvidedDefaultRelays) +{ + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + auto defaultRelays = nostrService->defaultRelays(); + auto activeRelays = nostrService->activeRelays(); + + ASSERT_EQ(defaultRelays.size(), defaultTestRelays.size()); + for (auto relay : defaultRelays) + { + ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); + } + ASSERT_EQ(activeRelays.size(), 0); +}; + +TEST_F(NostrServiceBaseTest, Destructor_StopsClient) +{ + EXPECT_CALL(*mockClient, start()).Times(1); + + auto nostrService = make_unique( + testAppender, + mockClient); +}; + +TEST_F(NostrServiceBaseTest, OpenRelayConnections_OpensConnections_ToDefaultRelays) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[0])).Times(1); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[1])).Times(1); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + auto activeRelays = nostrService->activeRelays(); + ASSERT_EQ(activeRelays.size(), defaultTestRelays.size()); + for (auto relay : activeRelays) + { + ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); + } +}; + +TEST_F(NostrServiceBaseTest, OpenRelayConnections_OpensConnections_ToProvidedRelays) +{ + vector testRelays = { "wss://nos.lol" }; + + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus -> insert({ testRelays[0], false }); + + EXPECT_CALL(*mockClient, openConnection(testRelays[0])).Times(1); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[0])).Times(0); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[1])).Times(0); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(testRelays); + + auto activeRelays = nostrService->activeRelays(); + ASSERT_EQ(activeRelays.size(), testRelays.size()); + for (auto relay : activeRelays) + { + ASSERT_NE(find(testRelays.begin(), testRelays.end(), relay), testRelays.end()); + } +}; + +TEST_F(NostrServiceBaseTest, OpenRelayConnections_AddsOpenConnections_ToActiveRelays) +{ + vector testRelays = { "wss://nos.lol" }; + + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + connectionStatus->insert({ testRelays[0], false }); + + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[0])).Times(1); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[1])).Times(1); + EXPECT_CALL(*mockClient, openConnection(testRelays[0])).Times(1); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + auto activeRelays = nostrService->activeRelays(); + ASSERT_EQ(activeRelays.size(), defaultTestRelays.size()); + for (auto relay : activeRelays) + { + ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); + } + + nostrService->openRelayConnections(testRelays); + + activeRelays = nostrService->activeRelays(); + ASSERT_EQ(activeRelays.size(), defaultTestRelays.size() + testRelays.size()); + for (auto relay : activeRelays) + { + bool isDefaultRelay = find(defaultTestRelays.begin(), defaultTestRelays.end(), relay) + != defaultTestRelays.end(); + bool isTestRelay = find(testRelays.begin(), testRelays.end(), relay) + != testRelays.end(); + ASSERT_TRUE(isDefaultRelay || isTestRelay); + } +}; + +TEST_F(NostrServiceBaseTest, CloseRelayConnections_ClosesConnections_ToActiveRelays) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + EXPECT_CALL(*mockClient, closeConnection(defaultTestRelays[0])).Times(1); + EXPECT_CALL(*mockClient, closeConnection(defaultTestRelays[1])).Times(1); + + nostrService->closeRelayConnections(); + + auto activeRelays = nostrService->activeRelays(); + ASSERT_EQ(activeRelays.size(), 0); +}; + +TEST_F(NostrServiceBaseTest, CloseRelayConnections_RemovesClosedConnections_FromActiveRelays) +{ + vector testRelays = { "wss://nos.lol" }; + vector allTestRelays = { defaultTestRelays[0], defaultTestRelays[1], testRelays[0] }; + + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + connectionStatus->insert({ testRelays[0], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + allTestRelays); + nostrService->openRelayConnections(); + + EXPECT_CALL(*mockClient, closeConnection(testRelays[0])).Times(1); + + nostrService->closeRelayConnections(testRelays); + + auto activeRelays = nostrService->activeRelays(); + ASSERT_EQ(activeRelays.size(), defaultTestRelays.size()); + for (auto relay : activeRelays) + { + bool isDefaultRelay = find(defaultTestRelays.begin(), defaultTestRelays.end(), relay) + != defaultTestRelays.end(); + bool isTestRelay = find(testRelays.begin(), testRelays.end(), relay) + != testRelays.end(); + ASSERT_TRUE((isDefaultRelay || isTestRelay) && !(isDefaultRelay && isTestRelay)); // XOR + } +}; + +TEST_F(NostrServiceBaseTest, PublishEvent_CorrectlyIndicates_AllSuccesses) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + EXPECT_CALL(*mockClient, send(_, _, _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) + { + json messageArr = json::parse(message); + auto event = nostr::data::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, true, "Event accepted" }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + + auto testEvent = make_shared(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); + + ASSERT_EQ(successes.size(), defaultTestRelays.size()); + for (auto relay : successes) + { + ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); + } + + ASSERT_EQ(failures.size(), 0); +}; + +TEST_F(NostrServiceBaseTest, PublishEvent_CorrectlyIndicates_AllFailures) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + // Simulate a case where the message failed to send to all relays. + EXPECT_CALL(*mockClient, send(_, _, _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) + { + return make_tuple(uri, false); + })); + + auto testEvent = make_shared(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); + + ASSERT_EQ(successes.size(), 0); + + ASSERT_EQ(failures.size(), defaultTestRelays.size()); + for (auto relay : failures) + { + ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); + } +}; + +TEST_F(NostrServiceBaseTest, PublishEvent_CorrectlyIndicates_MixedSuccessesAndFailures) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + // Simulate a scenario where the message fails to send to one relay, but sends successfully to + // the other, and the relay accepts it. + EXPECT_CALL(*mockClient, send(_, defaultTestRelays[0], _)) + .Times(1) + .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) + { + return make_tuple(uri, false); + })); + EXPECT_CALL(*mockClient, send(_, defaultTestRelays[1], _)) + .Times(1) + .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) + { + json messageArr = json::parse(message); + auto event = nostr::data::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, true, "Event accepted" }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + + auto testEvent = make_shared(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); + + ASSERT_EQ(successes.size(), 1); + ASSERT_EQ(successes[0], defaultTestRelays[1]); + + ASSERT_EQ(failures.size(), 1); + ASSERT_EQ(failures[0], defaultTestRelays[0]); +}; + +TEST_F(NostrServiceBaseTest, PublishEvent_CorrectlyIndicates_RejectedEvent) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + // Simulate a scenario where the message is rejected by all target relays. + EXPECT_CALL(*mockClient, send(_, _, _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) + { + json messageArr = json::parse(message); + auto event = nostr::data::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, false, "Event rejected" }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + + auto testEvent = make_shared(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); + + ASSERT_EQ(failures.size(), defaultTestRelays.size()); + for (auto relay : failures) + { + ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); + } +}; + +TEST_F(NostrServiceBaseTest, PublishEvent_CorrectlyIndicates_EventRejectedBySomeRelays) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + // Simulate a scenario where the message fails to send to one relay, but sends successfully to + // the other, and the relay accepts it. + EXPECT_CALL(*mockClient, send(_, defaultTestRelays[0], _)) + .Times(1) + .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) + { + json messageArr = json::parse(message); + auto event = nostr::data::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, true, "Event accepted" }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + EXPECT_CALL(*mockClient, send(_, defaultTestRelays[1], _)) + .Times(1) + .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) + { + json messageArr = json::parse(message); + auto event = nostr::data::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, false, "Event rejected" }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + + auto testEvent = make_shared(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); + + ASSERT_EQ(successes.size(), 1); + ASSERT_EQ(successes[0], defaultTestRelays[0]); + + ASSERT_EQ(failures.size(), 1); + ASSERT_EQ(failures[0], defaultTestRelays[1]); +}; + +TEST_F(NostrServiceBaseTest, QueryRelays_ReturnsEvents_UpToEOSE) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + auto testEvents = getMultipleTextNoteTestEvents(); + vector> sendableTestEvents; + for (nostr::data::Event testEvent : testEvents) + { + auto event = make_shared(testEvent); + auto serializedEvent = event->serialize(); + auto deserializedEvent = nostr::data::Event::fromString(serializedEvent); + + event = make_shared(deserializedEvent); + sendableTestEvents.push_back(event); + } + + // Expect the query messages. + EXPECT_CALL(*mockClient, send(HasSubstr("REQ"), _, _)) + .Times(2) + .WillRepeatedly(Invoke([&testEvents]( + string message, + string uri, + function messageHandler) + { + json messageArr = json::parse(message); + string subscriptionId = messageArr.at(1); + + for (auto event : testEvents) + { + auto sendableEvent = make_shared(event); + json jarr = json::array({ "EVENT", subscriptionId, sendableEvent->serialize() }); + messageHandler(jarr.dump()); + } + + json jarr = json::array({ "EOSE", subscriptionId }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + // Expect the close subscription messages after the client receives events. + EXPECT_CALL(*mockClient, send(HasSubstr("CLOSE"), _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, true); + })); + + auto filters = make_shared(getKind0And1TestFilters()); + auto results = nostrService->queryRelays(filters); + + // TODO: Check results size when the queryRelays method deduplicates results before returning. + // ASSERT_EQ(results.size(), testEvents.size()); + + // Check that the results contain the expected events. + for (auto resultEvent : results) + { + ASSERT_NE( + find_if( + sendableTestEvents.begin(), + sendableTestEvents.end(), + [&resultEvent](shared_ptr testEvent) + { + return *testEvent == *resultEvent; + }), + sendableTestEvents.end()); + } + + auto subscriptions = nostrService->subscriptions(); + ASSERT_TRUE(subscriptions.empty()); +}; + +TEST_F(NostrServiceBaseTest, QueryRelays_CallsHandler_WithReturnedEvents) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + defaultTestRelays); + nostrService->openRelayConnections(); + + auto testEvents = getMultipleTextNoteTestEvents(); + vector> sendableTestEvents; + for (nostr::data::Event testEvent : testEvents) + { + auto event = make_shared(testEvent); + auto serializedEvent = event->serialize(); + auto deserializedEvent = nostr::data::Event::fromString(serializedEvent); + + event = make_shared(deserializedEvent); + sendableTestEvents.push_back(event); + } + + EXPECT_CALL(*mockClient, send(HasSubstr("REQ"), _, _)) + .Times(2) + .WillRepeatedly(Invoke([&testEvents]( + string message, + string uri, + function messageHandler) + { + json messageArr = json::parse(message); + string subscriptionId = messageArr.at(1); + + for (auto event : testEvents) + { + auto sendableEvent = make_shared(event); + json jarr = json::array({ "EVENT", subscriptionId, sendableEvent->serialize() }); + messageHandler(jarr.dump()); + } + + json jarr = json::array({ "EOSE", subscriptionId }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + + auto filters = make_shared(getKind0And1TestFilters()); + promise eoseReceivedPromise; + auto eoseReceivedFuture = eoseReceivedPromise.get_future(); + int eoseCount = 0; + + string generatedSubscriptionId = nostrService->queryRelays( + filters, + [&generatedSubscriptionId, &sendableTestEvents](const string& subscriptionId, shared_ptr event) + { + ASSERT_STREQ(subscriptionId.c_str(), generatedSubscriptionId.c_str()); + ASSERT_NE( + find_if( + sendableTestEvents.begin(), + sendableTestEvents.end(), + [&event](shared_ptr testEvent) + { + return *testEvent == *event; + }), + sendableTestEvents.end()); + }, + [&generatedSubscriptionId, &eoseReceivedPromise, &eoseCount] + (const string& subscriptionId) + { + ASSERT_STREQ(subscriptionId.c_str(), generatedSubscriptionId.c_str()); + + if (++eoseCount == 2) + { + eoseReceivedPromise.set_value(); + } + }, + [](const string&, const string&) {}); + + eoseReceivedFuture.wait(); + + // Check that the service is keeping track of its active subscriptions. + auto subscriptions = nostrService->subscriptions(); + ASSERT_NO_THROW(subscriptions.at(generatedSubscriptionId)); + ASSERT_EQ(subscriptions.at(generatedSubscriptionId).size(), 2); + + EXPECT_CALL(*mockClient, send(HasSubstr("CLOSE"), _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, true); + })); + + auto [successes, failures] = nostrService->closeSubscription(generatedSubscriptionId); + + ASSERT_TRUE(failures.empty()); + + // Check that the service has forgotten about the subscriptions after closing them. + subscriptions = nostrService->subscriptions(); + ASSERT_TRUE(subscriptions.empty()); +}; + +TEST_F(NostrServiceBaseTest, Service_MaintainsMultipleSubscriptions_ThenClosesAll) +{ + // Mock connections. + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + vector testRelays = { "wss://theforest.nostr1.com" }; + connectionStatus->insert({ testRelays[0], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique( + testAppender, + mockClient, + testRelays); + nostrService->openRelayConnections(); + + // Mock relay responses. + auto testEvents = getMultipleTextNoteTestEvents(); + vector> sendableTestEvents; + for (nostr::data::Event testEvent : testEvents) + { + auto event = make_shared(testEvent); + auto serializedEvent = event->serialize(); + auto deserializedEvent = nostr::data::Event::fromString(serializedEvent); + + event = make_shared(deserializedEvent); + sendableTestEvents.push_back(event); + } + + vector subscriptionIds; + EXPECT_CALL(*mockClient, send(HasSubstr("REQ"), _, _)) + .Times(2) + .WillOnce(Invoke([&testEvents, &subscriptionIds]( + string message, + string uri, + function messageHandler) + { + json messageArr = json::parse(message); + subscriptionIds.push_back(messageArr.at(1)); + + for (auto event : testEvents) + { + auto sendableEvent = make_shared(event); + json jarr = json::array({ "EVENT", subscriptionIds.at(0), sendableEvent->serialize() }); + messageHandler(jarr.dump()); + } + + json jarr = json::array({ "EOSE", subscriptionIds.at(0), }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })) + .WillOnce(Invoke([&testEvents, &subscriptionIds]( + string message, + string uri, + function messageHandler) + { + json messageArr = json::parse(message); + subscriptionIds.push_back(messageArr.at(1)); + + for (auto event : testEvents) + { + auto sendableEvent = make_shared(event); + json jarr = json::array({ "EVENT", subscriptionIds.at(1), sendableEvent->serialize() }); + messageHandler(jarr.dump()); + } + + json jarr = json::array({ "EOSE", subscriptionIds.at(1), }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + + // Send queries. + auto shortFormFilters = make_shared(getKind0And1TestFilters()); + auto longFormFilters = make_shared(getKind30023TestFilters()); + promise shortFormPromise; + promise longFormPromise; + auto shortFormFuture = shortFormPromise.get_future(); + auto longFormFuture = longFormPromise.get_future(); + + string shortFormSubscriptionId = nostrService->queryRelays( + shortFormFilters, + [&shortFormSubscriptionId, &sendableTestEvents](const string& subscriptionId, shared_ptr event) + { + ASSERT_STREQ(subscriptionId.c_str(), shortFormSubscriptionId.c_str()); + ASSERT_NE( + find_if( + sendableTestEvents.begin(), + sendableTestEvents.end(), + [&event](shared_ptr testEvent) + { + return *testEvent == *event; + }), + sendableTestEvents.end()); + }, + [&shortFormSubscriptionId, &shortFormPromise] + (const string& subscriptionId) + { + ASSERT_STREQ(subscriptionId.c_str(), shortFormSubscriptionId.c_str()); + shortFormPromise.set_value(); + }, + [](const string&, const string&) {}); + string longFormSubscriptionId = nostrService->queryRelays( + shortFormFilters, + [&longFormSubscriptionId, &sendableTestEvents](const string& subscriptionId, shared_ptr event) + { + ASSERT_STREQ(subscriptionId.c_str(), longFormSubscriptionId.c_str()); + ASSERT_NE( + find_if( + sendableTestEvents.begin(), + sendableTestEvents.end(), + [&event](shared_ptr testEvent) + { + return *testEvent == *event; + }), + sendableTestEvents.end()); + }, + [&longFormSubscriptionId, &longFormPromise] + (const string& subscriptionId) + { + ASSERT_STREQ(subscriptionId.c_str(), longFormSubscriptionId.c_str()); + longFormPromise.set_value(); + }, + [](const string&, const string&) {}); + + shortFormFuture.wait(); + longFormFuture.wait(); + + // Check that the service has opened a subscription for each query. + auto subscriptions = nostrService->subscriptions(); + ASSERT_NO_THROW(subscriptions.at(shortFormSubscriptionId)); + ASSERT_EQ(subscriptions.at(shortFormSubscriptionId).size(), 1); + ASSERT_NO_THROW(subscriptions.at(longFormSubscriptionId)); + ASSERT_EQ(subscriptions.at(longFormSubscriptionId).size(), 1); + + // Mock the relay response for closing subscriptions. + EXPECT_CALL(*mockClient, send(HasSubstr("CLOSE"), _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, true); + })); + + // Close all subscriptions maintained by the service. + auto remainingSubscriptions = nostrService->closeSubscriptions(); + ASSERT_TRUE(remainingSubscriptions.empty()); + + // Check that all subscriptions have been closed. + subscriptions = nostrService->subscriptions(); + ASSERT_TRUE(subscriptions.empty()); +}; +} // namespace nostr_test diff --git a/test/nostr_service_test.cpp b/test/nostr_service_test.cpp deleted file mode 100644 index e5375db..0000000 --- a/test/nostr_service_test.cpp +++ /dev/null @@ -1,1088 +0,0 @@ -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "nostr.hpp" -#include "client/web_socket_client.hpp" -#include "signer/signer.hpp" - -using namespace nostr; -using namespace std; -using namespace ::testing; - -using nlohmann::json; - -namespace nostr_test -{ -class MockWebSocketClient : public client::IWebSocketClient { -public: - MOCK_METHOD(void, start, (), (override)); - MOCK_METHOD(void, stop, (), (override)); - MOCK_METHOD(void, openConnection, (string uri), (override)); - MOCK_METHOD(bool, isConnected, (string uri), (override)); - MOCK_METHOD((tuple), send, (string message, string uri), (override)); - MOCK_METHOD((tuple), send, (string message, string uri, function messageHandler), (override)); - MOCK_METHOD(void, receive, (string uri, function messageHandler), (override)); - MOCK_METHOD(void, closeConnection, (string uri), (override)); -}; - -class FakeSigner : public signer::ISigner -{ -public: - void sign(shared_ptr event) override - { - event->sig = "fake_signature"; - }; -}; - -class NostrServiceTest : public testing::Test -{ -public: - inline static const vector defaultTestRelays = - { - "wss://relay.damus.io", - "wss://nostr.thesamecat.io" - }; - - static const nostr::data::Event getTextNoteTestEvent() - { - nostr::data::Event event; - event.pubkey = "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask"; - event.kind = 1; - event.tags = - { - { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, - { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, - { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } - }; - event.content = "Hello, World!"; - - return event; - }; - - static const vector getMultipleTextNoteTestEvents() - { - auto now = std::chrono::system_clock::now(); - std::time_t currentTime = std::chrono::system_clock::to_time_t(now); - - nostr::data::Event event1; - event1.pubkey = "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask"; - event1.kind = 1; - event1.tags = - { - { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, - { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, - { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } - }; - event1.content = "Hello, World!"; - event1.createdAt = currentTime; - - nostr::data::Event event2; - event2.pubkey = "1l9d9jh67rkwayalrxcy686aujyz5pper5kzjv8jvg8pu9v9ns4ls0xvq42"; - event2.kind = 1; - event2.tags = - { - { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, - { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, - { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } - }; - event2.content = "Welcome to Nostr!"; - event2.createdAt = currentTime; - - nostr::data::Event event3; - event3.pubkey = "187ujhtmnv82ftg03h4heetwk3dd9mlfkf8th3fvmrk20nxk9mansuzuyla"; - event3.kind = 1; - event3.tags = - { - { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, - { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, - { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } - }; - event3.content = "Time for some introductions!"; - event3.createdAt = currentTime; - - return { event1, event2, event3 }; - }; - - static const nostr::data::Event getLongFormTestEvent() - { - nostr::data::Event event; - event.pubkey = "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask"; - event.kind = 30023; - event.tags = - { - { "event", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, - { "pubkey", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, - { "author", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } - }; - event.content = "Hello, World!"; - - return event; - } - - static const string getTestEventMessage(shared_ptr event, string subscriptionId) - { - auto signer = make_unique(); - signer->sign(event); - - json jarr = json::array(); - jarr.push_back("EVENT"); - jarr.push_back(subscriptionId); - jarr.push_back(event->serialize()); - - return jarr.dump(); - } - - static const nostr::data::Filters getKind0And1TestFilters() - { - nostr::data::Filters filters; - filters.authors = { - "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask", - "1l9d9jh67rkwayalrxcy686aujyz5pper5kzjv8jvg8pu9v9ns4ls0xvq42", - "187ujhtmnv82ftg03h4heetwk3dd9mlfkf8th3fvmrk20nxk9mansuzuyla" - }; - filters.kinds = { 0, 1 }; - filters.limit = 10; - - return filters; - } - - static const nostr::data::Filters getKind30023TestFilters() - { - nostr::data::Filters filters; - filters.authors = { - "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask", - "1l9d9jh67rkwayalrxcy686aujyz5pper5kzjv8jvg8pu9v9ns4ls0xvq42", - "187ujhtmnv82ftg03h4heetwk3dd9mlfkf8th3fvmrk20nxk9mansuzuyla" - }; - filters.kinds = { 30023 }; - filters.limit = 5; - - return filters; - } - -protected: - shared_ptr> testAppender; - shared_ptr mockClient; - shared_ptr fakeSigner; - - void SetUp() override - { - testAppender = make_shared>(); - mockClient = make_shared(); - fakeSigner = make_shared(); - }; -}; - -TEST_F(NostrServiceTest, Constructor_StartsClient) -{ - EXPECT_CALL(*mockClient, start()).Times(1); - - auto nostrService = make_unique(testAppender, mockClient, fakeSigner); -}; - -TEST_F(NostrServiceTest, Constructor_InitializesService_WithNoDefaultRelays) -{ - auto nostrService = make_unique(testAppender, mockClient, fakeSigner); - auto defaultRelays = nostrService->defaultRelays(); - auto activeRelays = nostrService->activeRelays(); - - ASSERT_EQ(defaultRelays.size(), 0); - ASSERT_EQ(activeRelays.size(), 0); -}; - -TEST_F(NostrServiceTest, Constructor_InitializesService_WithProvidedDefaultRelays) -{ - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - auto defaultRelays = nostrService->defaultRelays(); - auto activeRelays = nostrService->activeRelays(); - - ASSERT_EQ(defaultRelays.size(), defaultTestRelays.size()); - for (auto relay : defaultRelays) - { - ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); - } - ASSERT_EQ(activeRelays.size(), 0); -}; - -TEST_F(NostrServiceTest, Destructor_StopsClient) -{ - EXPECT_CALL(*mockClient, start()).Times(1); - - auto nostrService = make_unique(testAppender, mockClient, fakeSigner); -}; - -TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToDefaultRelays) -{ - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - - EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[0])).Times(1); - EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[1])).Times(1); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - auto activeRelays = nostrService->activeRelays(); - ASSERT_EQ(activeRelays.size(), defaultTestRelays.size()); - for (auto relay : activeRelays) - { - ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); - } -}; - -TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToProvidedRelays) -{ - vector testRelays = { "wss://nos.lol" }; - - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus -> insert({ testRelays[0], false }); - - EXPECT_CALL(*mockClient, openConnection(testRelays[0])).Times(1); - EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[0])).Times(0); - EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[1])).Times(0); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(testRelays); - - auto activeRelays = nostrService->activeRelays(); - ASSERT_EQ(activeRelays.size(), testRelays.size()); - for (auto relay : activeRelays) - { - ASSERT_NE(find(testRelays.begin(), testRelays.end(), relay), testRelays.end()); - } -}; - -TEST_F(NostrServiceTest, OpenRelayConnections_AddsOpenConnections_ToActiveRelays) -{ - vector testRelays = { "wss://nos.lol" }; - - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - connectionStatus->insert({ testRelays[0], false }); - - EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[0])).Times(1); - EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[1])).Times(1); - EXPECT_CALL(*mockClient, openConnection(testRelays[0])).Times(1); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - auto activeRelays = nostrService->activeRelays(); - ASSERT_EQ(activeRelays.size(), defaultTestRelays.size()); - for (auto relay : activeRelays) - { - ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); - } - - nostrService->openRelayConnections(testRelays); - - activeRelays = nostrService->activeRelays(); - ASSERT_EQ(activeRelays.size(), defaultTestRelays.size() + testRelays.size()); - for (auto relay : activeRelays) - { - bool isDefaultRelay = find(defaultTestRelays.begin(), defaultTestRelays.end(), relay) - != defaultTestRelays.end(); - bool isTestRelay = find(testRelays.begin(), testRelays.end(), relay) - != testRelays.end(); - ASSERT_TRUE(isDefaultRelay || isTestRelay); - } -}; - -TEST_F(NostrServiceTest, CloseRelayConnections_ClosesConnections_ToActiveRelays) -{ - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - EXPECT_CALL(*mockClient, closeConnection(defaultTestRelays[0])).Times(1); - EXPECT_CALL(*mockClient, closeConnection(defaultTestRelays[1])).Times(1); - - nostrService->closeRelayConnections(); - - auto activeRelays = nostrService->activeRelays(); - ASSERT_EQ(activeRelays.size(), 0); -}; - -TEST_F(NostrServiceTest, CloseRelayConnections_RemovesClosedConnections_FromActiveRelays) -{ - vector testRelays = { "wss://nos.lol" }; - vector allTestRelays = { defaultTestRelays[0], defaultTestRelays[1], testRelays[0] }; - - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - connectionStatus->insert({ testRelays[0], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - allTestRelays); - nostrService->openRelayConnections(); - - EXPECT_CALL(*mockClient, closeConnection(testRelays[0])).Times(1); - - nostrService->closeRelayConnections(testRelays); - - auto activeRelays = nostrService->activeRelays(); - ASSERT_EQ(activeRelays.size(), defaultTestRelays.size()); - for (auto relay : activeRelays) - { - bool isDefaultRelay = find(defaultTestRelays.begin(), defaultTestRelays.end(), relay) - != defaultTestRelays.end(); - bool isTestRelay = find(testRelays.begin(), testRelays.end(), relay) - != testRelays.end(); - ASSERT_TRUE((isDefaultRelay || isTestRelay) && !(isDefaultRelay && isTestRelay)); // XOR - } -}; - -TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_AllSuccesses) -{ - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - EXPECT_CALL(*mockClient, send(_, _, _)) - .Times(2) - .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) - { - json messageArr = json::parse(message); - auto event = nostr::data::Event::fromString(messageArr[1]); - - json jarr = json::array({ "OK", event.id, true, "Event accepted" }); - messageHandler(jarr.dump()); - - return make_tuple(uri, true); - })); - - auto testEvent = make_shared(getTextNoteTestEvent()); - auto [successes, failures] = nostrService->publishEvent(testEvent); - - ASSERT_EQ(successes.size(), defaultTestRelays.size()); - for (auto relay : successes) - { - ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); - } - - ASSERT_EQ(failures.size(), 0); -}; - -TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_AllFailures) -{ - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - // Simulate a case where the message failed to send to all relays. - EXPECT_CALL(*mockClient, send(_, _, _)) - .Times(2) - .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) - { - return make_tuple(uri, false); - })); - - auto testEvent = make_shared(getTextNoteTestEvent()); - auto [successes, failures] = nostrService->publishEvent(testEvent); - - ASSERT_EQ(successes.size(), 0); - - ASSERT_EQ(failures.size(), defaultTestRelays.size()); - for (auto relay : failures) - { - ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); - } -}; - -TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_MixedSuccessesAndFailures) -{ - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - // Simulate a scenario where the message fails to send to one relay, but sends successfully to - // the other, and the relay accepts it. - EXPECT_CALL(*mockClient, send(_, defaultTestRelays[0], _)) - .Times(1) - .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) - { - return make_tuple(uri, false); - })); - EXPECT_CALL(*mockClient, send(_, defaultTestRelays[1], _)) - .Times(1) - .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) - { - json messageArr = json::parse(message); - auto event = nostr::data::Event::fromString(messageArr[1]); - - json jarr = json::array({ "OK", event.id, true, "Event accepted" }); - messageHandler(jarr.dump()); - - return make_tuple(uri, true); - })); - - auto testEvent = make_shared(getTextNoteTestEvent()); - auto [successes, failures] = nostrService->publishEvent(testEvent); - - ASSERT_EQ(successes.size(), 1); - ASSERT_EQ(successes[0], defaultTestRelays[1]); - - ASSERT_EQ(failures.size(), 1); - ASSERT_EQ(failures[0], defaultTestRelays[0]); -}; - -TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_RejectedEvent) -{ - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - // Simulate a scenario where the message is rejected by all target relays. - EXPECT_CALL(*mockClient, send(_, _, _)) - .Times(2) - .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) - { - json messageArr = json::parse(message); - auto event = nostr::data::Event::fromString(messageArr[1]); - - json jarr = json::array({ "OK", event.id, false, "Event rejected" }); - messageHandler(jarr.dump()); - - return make_tuple(uri, true); - })); - - auto testEvent = make_shared(getTextNoteTestEvent()); - auto [successes, failures] = nostrService->publishEvent(testEvent); - - ASSERT_EQ(failures.size(), defaultTestRelays.size()); - for (auto relay : failures) - { - ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); - } -}; - -TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_EventRejectedBySomeRelays) -{ - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - // Simulate a scenario where the message fails to send to one relay, but sends successfully to - // the other, and the relay accepts it. - EXPECT_CALL(*mockClient, send(_, defaultTestRelays[0], _)) - .Times(1) - .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) - { - json messageArr = json::parse(message); - auto event = nostr::data::Event::fromString(messageArr[1]); - - json jarr = json::array({ "OK", event.id, true, "Event accepted" }); - messageHandler(jarr.dump()); - - return make_tuple(uri, true); - })); - EXPECT_CALL(*mockClient, send(_, defaultTestRelays[1], _)) - .Times(1) - .WillRepeatedly(Invoke([](string message, string uri, function messageHandler) - { - json messageArr = json::parse(message); - auto event = nostr::data::Event::fromString(messageArr[1]); - - json jarr = json::array({ "OK", event.id, false, "Event rejected" }); - messageHandler(jarr.dump()); - - return make_tuple(uri, true); - })); - - auto testEvent = make_shared(getTextNoteTestEvent()); - auto [successes, failures] = nostrService->publishEvent(testEvent); - - ASSERT_EQ(successes.size(), 1); - ASSERT_EQ(successes[0], defaultTestRelays[0]); - - ASSERT_EQ(failures.size(), 1); - ASSERT_EQ(failures[0], defaultTestRelays[1]); -}; - -TEST_F(NostrServiceTest, QueryRelays_ReturnsEvents_UpToEOSE) -{ - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto signer = make_unique(); - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - auto testEvents = getMultipleTextNoteTestEvents(); - vector> signedTestEvents; - for (nostr::data::Event testEvent : testEvents) - { - auto signedEvent = make_shared(testEvent); - signer->sign(signedEvent); - - auto serializedEvent = signedEvent->serialize(); - auto deserializedEvent = nostr::data::Event::fromString(serializedEvent); - - signedEvent = make_shared(deserializedEvent); - signedTestEvents.push_back(signedEvent); - } - - // Expect the query messages. - EXPECT_CALL(*mockClient, send(HasSubstr("REQ"), _, _)) - .Times(2) - .WillRepeatedly(Invoke([&testEvents, &signer]( - string message, - string uri, - function messageHandler) - { - json messageArr = json::parse(message); - string subscriptionId = messageArr.at(1); - - for (auto event : testEvents) - { - auto sendableEvent = make_shared(event); - signer->sign(sendableEvent); - json jarr = json::array({ "EVENT", subscriptionId, sendableEvent->serialize() }); - messageHandler(jarr.dump()); - } - - json jarr = json::array({ "EOSE", subscriptionId }); - messageHandler(jarr.dump()); - - return make_tuple(uri, true); - })); - // Expect the close subscription messages after the client receives events. - EXPECT_CALL(*mockClient, send(HasSubstr("CLOSE"), _)) - .Times(2) - .WillRepeatedly(Invoke([](string message, string uri) - { - return make_tuple(uri, true); - })); - - auto filters = make_shared(getKind0And1TestFilters()); - auto results = nostrService->queryRelays(filters); - - // TODO: Check results size when the queryRelays method deduplicates results before returning. - // ASSERT_EQ(results.size(), testEvents.size()); - - // Check that the results contain the expected events. - for (auto resultEvent : results) - { - ASSERT_NE( - find_if( - signedTestEvents.begin(), - signedTestEvents.end(), - [&resultEvent](shared_ptr testEvent) - { - return *testEvent == *resultEvent; - }), - signedTestEvents.end()); - } - - auto subscriptions = nostrService->subscriptions(); - ASSERT_TRUE(subscriptions.empty()); -}; - -TEST_F(NostrServiceTest, QueryRelays_CallsHandler_WithReturnedEvents) -{ - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - connectionStatus->insert({ defaultTestRelays[0], false }); - connectionStatus->insert({ defaultTestRelays[1], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto signer = make_unique(); - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - defaultTestRelays); - nostrService->openRelayConnections(); - - auto testEvents = getMultipleTextNoteTestEvents(); - vector> signedTestEvents; - for (nostr::data::Event testEvent : testEvents) - { - auto signedEvent = make_shared(testEvent); - signer->sign(signedEvent); - - auto serializedEvent = signedEvent->serialize(); - auto deserializedEvent = nostr::data::Event::fromString(serializedEvent); - - signedEvent = make_shared(deserializedEvent); - signedTestEvents.push_back(signedEvent); - } - - EXPECT_CALL(*mockClient, send(HasSubstr("REQ"), _, _)) - .Times(2) - .WillRepeatedly(Invoke([&testEvents, &signer]( - string message, - string uri, - function messageHandler) - { - json messageArr = json::parse(message); - string subscriptionId = messageArr.at(1); - - for (auto event : testEvents) - { - auto sendableEvent = make_shared(event); - signer->sign(sendableEvent); - json jarr = json::array({ "EVENT", subscriptionId, sendableEvent->serialize() }); - messageHandler(jarr.dump()); - } - - json jarr = json::array({ "EOSE", subscriptionId }); - messageHandler(jarr.dump()); - - return make_tuple(uri, true); - })); - - auto filters = make_shared(getKind0And1TestFilters()); - promise eoseReceivedPromise; - auto eoseReceivedFuture = eoseReceivedPromise.get_future(); - int eoseCount = 0; - - string generatedSubscriptionId = nostrService->queryRelays( - filters, - [&generatedSubscriptionId, &signedTestEvents](const string& subscriptionId, shared_ptr event) - { - ASSERT_STREQ(subscriptionId.c_str(), generatedSubscriptionId.c_str()); - ASSERT_NE( - find_if( - signedTestEvents.begin(), - signedTestEvents.end(), - [&event](shared_ptr testEvent) - { - return *testEvent == *event; - }), - signedTestEvents.end()); - }, - [&generatedSubscriptionId, &eoseReceivedPromise, &eoseCount] - (const string& subscriptionId) - { - ASSERT_STREQ(subscriptionId.c_str(), generatedSubscriptionId.c_str()); - - if (++eoseCount == 2) - { - eoseReceivedPromise.set_value(); - } - }, - [](const string&, const string&) {}); - - eoseReceivedFuture.wait(); - - // Check that the service is keeping track of its active subscriptions. - auto subscriptions = nostrService->subscriptions(); - ASSERT_NO_THROW(subscriptions.at(generatedSubscriptionId)); - ASSERT_EQ(subscriptions.at(generatedSubscriptionId).size(), 2); - - EXPECT_CALL(*mockClient, send(HasSubstr("CLOSE"), _)) - .Times(2) - .WillRepeatedly(Invoke([](string message, string uri) - { - return make_tuple(uri, true); - })); - - auto [successes, failures] = nostrService->closeSubscription(generatedSubscriptionId); - - ASSERT_TRUE(failures.empty()); - - // Check that the service has forgotten about the subscriptions after closing them. - subscriptions = nostrService->subscriptions(); - ASSERT_TRUE(subscriptions.empty()); -}; - -TEST_F(NostrServiceTest, Service_MaintainsMultipleSubscriptions_ThenClosesAll) -{ - // Mock connections. - mutex connectionStatusMutex; - auto connectionStatus = make_shared>(); - vector testRelays = { "wss://theforest.nostr1.com" }; - connectionStatus->insert({ testRelays[0], false }); - - EXPECT_CALL(*mockClient, isConnected(_)) - .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) - { - lock_guard lock(connectionStatusMutex); - bool status = connectionStatus->at(uri); - if (status == false) - { - connectionStatus->at(uri) = true; - } - return status; - })); - - auto signer = make_unique(); - auto nostrService = make_unique( - testAppender, - mockClient, - fakeSigner, - testRelays); - nostrService->openRelayConnections(); - - // Mock relay responses. - auto testEvents = getMultipleTextNoteTestEvents(); - vector> signedTestEvents; - for (nostr::data::Event testEvent : testEvents) - { - auto signedEvent = make_shared(testEvent); - signer->sign(signedEvent); - - auto serializedEvent = signedEvent->serialize(); - auto deserializedEvent = nostr::data::Event::fromString(serializedEvent); - - signedEvent = make_shared(deserializedEvent); - signedTestEvents.push_back(signedEvent); - } - - vector subscriptionIds; - EXPECT_CALL(*mockClient, send(HasSubstr("REQ"), _, _)) - .Times(2) - .WillOnce(Invoke([&testEvents, &signer, &subscriptionIds]( - string message, - string uri, - function messageHandler) - { - json messageArr = json::parse(message); - subscriptionIds.push_back(messageArr.at(1)); - - for (auto event : testEvents) - { - auto sendableEvent = make_shared(event); - signer->sign(sendableEvent); - json jarr = json::array({ "EVENT", subscriptionIds.at(0), sendableEvent->serialize() }); - messageHandler(jarr.dump()); - } - - json jarr = json::array({ "EOSE", subscriptionIds.at(0), }); - messageHandler(jarr.dump()); - - return make_tuple(uri, true); - })) - .WillOnce(Invoke([&testEvents, &signer, &subscriptionIds]( - string message, - string uri, - function messageHandler) - { - json messageArr = json::parse(message); - subscriptionIds.push_back(messageArr.at(1)); - - for (auto event : testEvents) - { - auto sendableEvent = make_shared(event); - signer->sign(sendableEvent); - json jarr = json::array({ "EVENT", subscriptionIds.at(1), sendableEvent->serialize() }); - messageHandler(jarr.dump()); - } - - json jarr = json::array({ "EOSE", subscriptionIds.at(1), }); - messageHandler(jarr.dump()); - - return make_tuple(uri, true); - })); - - // Send queries. - auto shortFormFilters = make_shared(getKind0And1TestFilters()); - auto longFormFilters = make_shared(getKind30023TestFilters()); - promise shortFormPromise; - promise longFormPromise; - auto shortFormFuture = shortFormPromise.get_future(); - auto longFormFuture = longFormPromise.get_future(); - - string shortFormSubscriptionId = nostrService->queryRelays( - shortFormFilters, - [&shortFormSubscriptionId, &signedTestEvents](const string& subscriptionId, shared_ptr event) - { - ASSERT_STREQ(subscriptionId.c_str(), shortFormSubscriptionId.c_str()); - ASSERT_NE( - find_if( - signedTestEvents.begin(), - signedTestEvents.end(), - [&event](shared_ptr testEvent) - { - return *testEvent == *event; - }), - signedTestEvents.end()); - }, - [&shortFormSubscriptionId, &shortFormPromise] - (const string& subscriptionId) - { - ASSERT_STREQ(subscriptionId.c_str(), shortFormSubscriptionId.c_str()); - shortFormPromise.set_value(); - }, - [](const string&, const string&) {}); - string longFormSubscriptionId = nostrService->queryRelays( - shortFormFilters, - [&longFormSubscriptionId, &signedTestEvents](const string& subscriptionId, shared_ptr event) - { - ASSERT_STREQ(subscriptionId.c_str(), longFormSubscriptionId.c_str()); - ASSERT_NE( - find_if( - signedTestEvents.begin(), - signedTestEvents.end(), - [&event](shared_ptr testEvent) - { - return *testEvent == *event; - }), - signedTestEvents.end()); - }, - [&longFormSubscriptionId, &longFormPromise] - (const string& subscriptionId) - { - ASSERT_STREQ(subscriptionId.c_str(), longFormSubscriptionId.c_str()); - longFormPromise.set_value(); - }, - [](const string&, const string&) {}); - - shortFormFuture.wait(); - longFormFuture.wait(); - - // Check that the service has opened a subscription for each query. - auto subscriptions = nostrService->subscriptions(); - ASSERT_NO_THROW(subscriptions.at(shortFormSubscriptionId)); - ASSERT_EQ(subscriptions.at(shortFormSubscriptionId).size(), 1); - ASSERT_NO_THROW(subscriptions.at(longFormSubscriptionId)); - ASSERT_EQ(subscriptions.at(longFormSubscriptionId).size(), 1); - - // Mock the relay response for closing subscriptions. - EXPECT_CALL(*mockClient, send(HasSubstr("CLOSE"), _)) - .Times(2) - .WillRepeatedly(Invoke([](string message, string uri) - { - return make_tuple(uri, true); - })); - - // Close all subscriptions maintained by the service. - auto remainingSubscriptions = nostrService->closeSubscriptions(); - ASSERT_TRUE(remainingSubscriptions.empty()); - - // Check that all subscriptions have been closed. - subscriptions = nostrService->subscriptions(); - ASSERT_TRUE(subscriptions.empty()); -}; -} // namespace nostr_test -- cgit