From 0d87b4053983ec8edaff5b73491b717866876586 Mon Sep 17 00:00:00 2001 From: Michael J <37635304+buttercat1791@users.noreply.github.com> Date: Sun, 3 Mar 2024 11:56:37 -0600 Subject: Create Nostr Service and Add Write Capabilities (#1) --- .gitignore | 6 + CMakeLists.txt | 78 +++++++ include/client/web_socket_client.hpp | 49 +++++ include/nostr.hpp | 123 +++++++++++ src/client/websocketpp_client.cpp | 107 ++++++++++ src/event.cpp | 38 ++++ src/nostr_service.cpp | 248 +++++++++++++++++++++ test/nostr_service_test.cpp | 404 +++++++++++++++++++++++++++++++++++ 8 files changed, 1053 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 include/client/web_socket_client.hpp create mode 100644 include/nostr.hpp create mode 100644 src/client/websocketpp_client.cpp create mode 100644 src/event.cpp create mode 100644 src/nostr_service.cpp create mode 100644 test/nostr_service_test.cpp diff --git a/.gitignore b/.gitignore index 259148f..8138b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,9 @@ *.exe *.out *.app + +# Outputs +build/ + +# VS Code Settings +.vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3d472c1 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,78 @@ +cmake_minimum_required(VERSION 3.14) +project(NostrSDK VERSION 0.0.1) + +# Specify the C++ standard +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) + +# Build the project. +set(INCLUDE_DIR ./include) +set(CLIENT_INCLUDE_DIR ./include/client) +include_directories(${INCLUDE_DIR}) +include_directories(${CLIENT_INCLUDE_DIR}) +set(HEADERS + ${INCLUDE_DIR}/nostr.hpp + ${CLIENT_INCLUDE_DIR}/web_socket_client.hpp +) + +set(SOURCE_DIR ./src) +set(CLIENT_SOURCE_DIR ./src/client) +set(SOURCES + ${SOURCE_DIR}/event.cpp + ${SOURCE_DIR}/nostr_service.cpp + ${CLIENT_SOURCE_DIR}/websocketpp_client.cpp +) + +find_package(Boost REQUIRED COMPONENTS random system) +find_package(nlohmann_json CONFIG REQUIRED) +find_package(OpenSSL REQUIRED) +find_package(plog CONFIG REQUIRED) +find_package(websocketpp CONFIG REQUIRED) + +add_library(NostrSDK ${SOURCES} ${HEADERS}) +target_link_libraries(NostrSDK PRIVATE + Boost::random + Boost::system + nlohmann_json::nlohmann_json + OpenSSL::SSL + OpenSSL::Crypto + plog::plog + websocketpp::websocketpp +) +set_target_properties(NostrSDK PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES) + +# Build the tests. +enable_testing() +include(GoogleTest) + +include(FetchContent) +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(NostrSDKTest ${TEST_SOURCES} ${HEADERS}) +target_link_libraries(NostrSDKTest PRIVATE + GTest::gmock + GTest::gtest + GTest::gtest_main + NostrSDK + plog::plog + websocketpp::websocketpp +) +set_target_properties(NostrSDKTest PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES) + +gtest_add_tests(TARGET NostrSDKTest) diff --git a/include/client/web_socket_client.hpp b/include/client/web_socket_client.hpp new file mode 100644 index 0000000..0f58749 --- /dev/null +++ b/include/client/web_socket_client.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include + +namespace client +{ +/** + * @brief An interface for a WebSocket client singleton. + */ +class IWebSocketClient +{ +public: + /** + * @brief Starts the client. + * @remark This method must be called before any other client methods. + */ + virtual void start() = 0; + + /** + * @brief Stops the client. + * @remark This method should be called when the client is no longer needed, before it is + * destroyed. + */ + virtual void stop() = 0; + + /** + * @brief Opens a connection to the given server. + */ + virtual void openConnection(std::string uri) = 0; + + /** + * @brief Indicates whether the client is connected to the given server. + * @returns True if the client is connected, false otherwise. + */ + virtual bool isConnected(std::string uri) = 0; + + /** + * @brief Sends the given message to the given server. + * @returns A tuple indicating the server URI and whether the message was successfully + * sent. + */ + virtual std::tuple send(std::string message, std::string uri) = 0; + + /** + * @brief Closes the connection to the given server. + */ + virtual void closeConnection(std::string uri) = 0; +}; +} // namespace client diff --git a/include/nostr.hpp b/include/nostr.hpp new file mode 100644 index 0000000..47b56f9 --- /dev/null +++ b/include/nostr.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "client/web_socket_client.hpp" + +namespace nostr +{ +typedef std::vector RelayList; + +// TODO: Add null checking to seralization and deserialization methods. +/** + * @brief A Nostr event. + * @remark All data transmitted over the Nostr protocol is encoded in JSON blobs. This struct + * is common to every Nostr event kind. The significance of each event is determined by the + * `tags` and `content` fields. +*/ +struct Event +{ + std::string id; ///< SHA-256 hash of the event data. + std::string pubkey; ///< Public key of the event creator. + std::string created_at; ///< Unix timestamp of the event creation. + int kind; ///< Event kind. + std::vector> tags; ///< Arbitrary event metadata. + std::string content; ///< Event content. + std::string sig; ///< Event signature created with the private key of the event creator. + + nlohmann::json serialize() const; + void deserialize(std::string jsonString); +}; + +class NostrService +{ +public: + NostrService(plog::IAppender* appender, client::IWebSocketClient* client); + NostrService(plog::IAppender* appender, client::IWebSocketClient* client, RelayList relays); + ~NostrService(); + + RelayList defaultRelays() const; + + RelayList activeRelays() const; + + /** + * @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. + */ + RelayList openRelayConnections(); + + /** + * @brief Opens connections to the specified Nostr relays. + * @returns A list of the relay URLs to which connections were successfully opened. + */ + RelayList openRelayConnections(RelayList relays); + + /** + * @brief Closes all open relay connections. + */ + void closeRelayConnections(); + + /** + * @brief Closes any open connections to the specified Nostr relays. + */ + void closeRelayConnections(RelayList relays); + + /** + * @brief Publishes a Nostr event to all open relay connections. + * @returns A tuple of `RelayList` objects, of the form ``, indicating + * to which relays the event was published successfully, and to which relays the event failed + * to publish. + */ + std::tuple publishEvent(Event event); + + // TODO: Add methods for reading events from relays. + +private: + std::mutex _propertyMutex; + RelayList _defaultRelays; + RelayList _activeRelays; + client::IWebSocketClient* _client; + + /** + * @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. + */ + RelayList getConnectedRelays(RelayList 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. + */ + RelayList getUnconnectedRelays(RelayList 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); +}; +} // namespace nostr diff --git a/src/client/websocketpp_client.cpp b/src/client/websocketpp_client.cpp new file mode 100644 index 0000000..1386e1a --- /dev/null +++ b/src/client/websocketpp_client.cpp @@ -0,0 +1,107 @@ +#include +#include + +#include "web_socket_client.hpp" + +using std::error_code; +using std::lock_guard; +using std::make_tuple; +using std::mutex; +using std::string; +using std::tuple; +using std::unordered_map; + +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(); + }; + + 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(); + } + + // 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); + } + }); + + lock_guard lock(this->_propertyMutex); + this->_connectionHandles[uri] = connection->get_handle(); + this->_client.connect(connection); + }; + + bool isConnected(string uri) override + { + lock_guard lock(this->_propertyMutex); + return this->_connectionHandles.find(uri) != this->_connectionHandles.end(); + }; + + tuple send(string message, string uri) override + { + error_code error; + + // 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); + + if (error.value() == -1) + { + // PLOG_ERROR << "Error publishing event to relay " << relay << ": " << error.message(); + return make_tuple(uri, false); + } + + return make_tuple(uri, true); + }; + + void closeConnection(string uri) override + { + 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); + }; + +private: + typedef websocketpp::client websocketpp_client; + typedef unordered_map::iterator connection_hdl_iterator; + + websocketpp_client _client; + unordered_map _connectionHandles; + mutex _propertyMutex; +}; +} // namespace client diff --git a/src/event.cpp b/src/event.cpp new file mode 100644 index 0000000..75f2ee8 --- /dev/null +++ b/src/event.cpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +#include "nostr.hpp" + +using std::string; + +namespace nostr +{ + nlohmann::json Event::serialize() const + { + nlohmann::json j = { + {"id", this->id}, + {"pubkey", this->pubkey}, + {"created_at", this->created_at}, + {"kind", this->kind}, + {"tags", this->tags}, + {"content", this->content}, + {"sig", this->sig} + }; + return j.dump(); + }; + + void Event::deserialize(string jsonString) + { + nlohmann::json j = nlohmann::json::parse(jsonString); + this->id = j["id"]; + this->pubkey = j["pubkey"]; + this->created_at = j["created_at"]; + this->kind = j["kind"]; + this->tags = j["tags"]; + this->content = j["content"]; + this->sig = j["sig"]; + }; +} diff --git a/src/nostr_service.cpp b/src/nostr_service.cpp new file mode 100644 index 0000000..09be6e3 --- /dev/null +++ b/src/nostr_service.cpp @@ -0,0 +1,248 @@ +#include +#include +#include +#include + +#include "nostr.hpp" +#include "client/web_socket_client.hpp" + +using std::async; +using std::future; +using std::lock_guard; +using std::make_tuple; +using std::move; +using std::mutex; +using std::string; +using std::thread; +using std::tuple; +using std::vector; + +namespace nostr +{ +NostrService::NostrService(plog::IAppender* appender, client::IWebSocketClient* client) + : NostrService(appender, client, {}) { }; + +NostrService::NostrService(plog::IAppender* appender, client::IWebSocketClient* client, RelayList relays) + : _defaultRelays(relays), _client(client) +{ + plog::init(plog::debug, appender); + client->start(); +}; + +NostrService::~NostrService() +{ + this->_client->stop(); + delete this->_client; +}; + +RelayList NostrService::defaultRelays() const { return this->_defaultRelays; }; + +RelayList NostrService::activeRelays() const { return this->_activeRelays; }; + +RelayList NostrService::openRelayConnections() +{ + return this->openRelayConnections(this->_defaultRelays); +}; + +RelayList NostrService::openRelayConnections(RelayList relays) +{ + PLOG_INFO << "Attempting to connect to Nostr relays."; + RelayList 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(); + } + + size_t targetCount = relays.size(); + 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(RelayList relays) +{ + PLOG_INFO << "Disconnecting from Nostr relays."; + RelayList connectedRelays = getConnectedRelays(relays); + + vector disconnectionThreads; + for (string relay : connectedRelays) + { + thread disconnectionThread([this, relay]() { + this->disconnect(relay); + }); + disconnectionThreads.push_back(move(disconnectionThread)); + } + + for (thread& disconnectionThread : disconnectionThreads) + { + disconnectionThread.join(); + } +}; + +tuple NostrService::publishEvent(Event event) +{ + // TODO: Add validation function. + + RelayList successfulRelays; + RelayList failedRelays; + + PLOG_INFO << "Attempting to publish event to Nostr relays."; + + vector>> publishFutures; + for (string relay : this->_activeRelays) + { + future> publishFuture = async([this, relay, event]() { + return this->_client->send(event.serialize(), relay); + }); + + publishFutures.push_back(move(publishFuture)); + } + + for (auto& publishFuture : publishFutures) + { + auto [relay, isSuccess] = publishFuture.get(); + if (isSuccess) + { + successfulRelays.push_back(relay); + } + else + { + failedRelays.push_back(relay); + } + } + + size_t targetCount = this->_activeRelays.size(); + size_t successfulCount = successfulRelays.size(); + PLOG_INFO << "Published event to " << successfulCount << "/" << targetCount << " target relays."; + + return make_tuple(successfulRelays, failedRelays); +}; + +RelayList NostrService::getConnectedRelays(RelayList relays) +{ + PLOG_VERBOSE << "Identifying connected relays."; + RelayList 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; +}; + +RelayList NostrService::getUnconnectedRelays(RelayList relays) +{ + PLOG_VERBOSE << "Identifying unconnected relays."; + RelayList 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); +}; +} // namespace nostr diff --git a/test/nostr_service_test.cpp b/test/nostr_service_test.cpp new file mode 100644 index 0000000..83de3be --- /dev/null +++ b/test/nostr_service_test.cpp @@ -0,0 +1,404 @@ +#include +#include +#include +#include +#include + +#include +#include + +using std::lock_guard; +using std::make_shared; +using std::mutex; +using std::shared_ptr; +using std::string; +using std::unordered_map; +using ::testing::_; +using ::testing::Invoke; +using ::testing::Return; + +namespace nostr_test +{ +class MockWebSocketClient : public client::IWebSocketClient { +public: + MOCK_METHOD(void, start, (), (override)); + MOCK_METHOD(void, stop, (), (override)); + MOCK_METHOD(void, openConnection, (std::string uri), (override)); + MOCK_METHOD(bool, isConnected, (std::string uri), (override)); + MOCK_METHOD((std::tuple), send, (std::string message, std::string uri), (override)); + MOCK_METHOD(void, closeConnection, (std::string uri), (override)); +}; + +class NostrServiceTest : public testing::Test +{ +public: + inline static const nostr::RelayList defaultTestRelays = + { + "wss://relay.damus.io", + "wss://nostr.thesamecat.io" + }; + +protected: + shared_ptr> testAppender; + shared_ptr testClient; + + void SetUp() override + { + testAppender = make_shared>(); + testClient = make_shared(); + }; +}; + +TEST_F(NostrServiceTest, Constructor_StartsClient) +{ + EXPECT_CALL(*testClient, start()).Times(1); + + auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get()); +}; + +TEST_F(NostrServiceTest, Constructor_InitializesService_WithNoDefaultRelays) +{ + auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get()); + 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 = new nostr::NostrService(testAppender.get(), testClient.get(), 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(*testClient, start()).Times(1); + + auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get()); +}; + +TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToDefaultRelays) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*testClient, openConnection(defaultTestRelays[0])).Times(1); + EXPECT_CALL(*testClient, openConnection(defaultTestRelays[1])).Times(1); + + EXPECT_CALL(*testClient, 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 = new nostr::NostrService(testAppender.get(), testClient.get(), 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) +{ + nostr::RelayList testRelays = { "wss://nos.lol" }; + + mutex connectionStatusMutex; + auto connectionStatus = make_shared>(); + connectionStatus -> insert({ testRelays[0], false }); + + EXPECT_CALL(*testClient, openConnection(testRelays[0])).Times(1); + EXPECT_CALL(*testClient, openConnection(defaultTestRelays[0])).Times(0); + EXPECT_CALL(*testClient, openConnection(defaultTestRelays[1])).Times(0); + + EXPECT_CALL(*testClient, 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 = new nostr::NostrService(testAppender.get(), testClient.get(), 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) +{ + nostr::RelayList 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(*testClient, openConnection(defaultTestRelays[0])).Times(1); + EXPECT_CALL(*testClient, openConnection(defaultTestRelays[1])).Times(1); + EXPECT_CALL(*testClient, openConnection(testRelays[0])).Times(1); + + EXPECT_CALL(*testClient, 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 = new nostr::NostrService(testAppender.get(), testClient.get(), 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(*testClient, 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 = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + nostrService->openRelayConnections(); + + EXPECT_CALL(*testClient, closeConnection(defaultTestRelays[0])).Times(1); + EXPECT_CALL(*testClient, closeConnection(defaultTestRelays[1])).Times(1); + + nostrService->closeRelayConnections(); + + auto activeRelays = nostrService->activeRelays(); + ASSERT_EQ(activeRelays.size(), 0); +}; + +TEST_F(NostrServiceTest, CloseRelayConnections_RemovesClosedConnections_FromActiveRelays) +{ + nostr::RelayList testRelays = { "wss://nos.lol" }; + nostr::RelayList 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(*testClient, 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 = new nostr::NostrService(testAppender.get(), testClient.get(), allTestRelays); + nostrService->openRelayConnections(); + + EXPECT_CALL(*testClient, 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(*testClient, 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 = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + nostrService->openRelayConnections(); + + EXPECT_CALL(*testClient, send(_, _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, true); + })); + + auto [successes, failures] = nostrService->publishEvent(nostr::Event()); + + 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(*testClient, 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 = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + nostrService->openRelayConnections(); + + EXPECT_CALL(*testClient, send(_, _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, false); + })); + + auto [successes, failures] = nostrService->publishEvent(nostr::Event()); + + 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(*testClient, 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 = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + nostrService->openRelayConnections(); + + EXPECT_CALL(*testClient, send(_, defaultTestRelays[0])) + .Times(1) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, true); + })); + EXPECT_CALL(*testClient, send(_, defaultTestRelays[1])) + .Times(1) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, false); + })); + + auto [successes, failures] = nostrService->publishEvent(nostr::Event()); + + ASSERT_EQ(successes.size(), 1); + ASSERT_EQ(successes[0], defaultTestRelays[0]); + + ASSERT_EQ(failures.size(), 1); + ASSERT_EQ(failures[0], defaultTestRelays[1]); +}; +} // namespace nostr_test -- cgit