aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--CMakeLists.txt78
-rw-r--r--include/client/web_socket_client.hpp49
-rw-r--r--include/nostr.hpp123
-rw-r--r--src/client/websocketpp_client.cpp107
-rw-r--r--src/event.cpp38
-rw-r--r--src/nostr_service.cpp248
-rw-r--r--test/nostr_service_test.cpp404
8 files changed, 1053 insertions, 0 deletions
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 <string>
+
+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<std::string, bool> 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 <mutex>
+#include <string>
+#include <tuple>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+#include <plog/Log.h>
+#include <websocketpp/client.hpp>
+#include <websocketpp/config/asio_client.hpp>
+
+#include "client/web_socket_client.hpp"
+
+namespace nostr
+{
+typedef std::vector<std::string> 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<std::vector<std::string>> 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 `<successes, failures>`, indicating
+ * to which relays the event was published successfully, and to which relays the event failed
+ * to publish.
+ */
+ std::tuple<RelayList, RelayList> 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 <websocketpp/client.hpp>
+#include <websocketpp/config/asio_client.hpp>
+
+#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<mutex> lock(this->_propertyMutex);
+ if (this->isConnected(uri))
+ {
+ this->_connectionHandles.erase(uri);
+ }
+ });
+
+ lock_guard<mutex> lock(this->_propertyMutex);
+ this->_connectionHandles[uri] = connection->get_handle();
+ this->_client.connect(connection);
+ };
+
+ bool isConnected(string uri) override
+ {
+ lock_guard<mutex> lock(this->_propertyMutex);
+ return this->_connectionHandles.find(uri) != this->_connectionHandles.end();
+ };
+
+ tuple<string, bool> send(string message, string uri) override
+ {
+ error_code error;
+
+ // Make sure the connection isn't closed from under us.
+ lock_guard<mutex> 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<mutex> 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::config::asio_client> websocketpp_client;
+ typedef unordered_map<string, websocketpp::connection_hdl>::iterator connection_hdl_iterator;
+
+ websocketpp_client _client;
+ unordered_map<string, websocketpp::connection_hdl> _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 <string>
+#include <vector>
+#include <nlohmann/json.hpp>
+
+#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 <plog/Init.h>
+#include <plog/Log.h>
+#include <websocketpp/client.hpp>
+#include <websocketpp/config/asio_client.hpp>
+
+#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<thread> 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<thread> disconnectionThreads;
+ for (string relay : connectedRelays)
+ {
+ thread disconnectionThread([this, relay]() {
+ this->disconnect(relay);
+ });
+ disconnectionThreads.push_back(move(disconnectionThread));
+ }
+
+ for (thread& disconnectionThread : disconnectionThreads)
+ {
+ disconnectionThread.join();
+ }
+};
+
+tuple<RelayList, RelayList> NostrService::publishEvent(Event event)
+{
+ // TODO: Add validation function.
+
+ RelayList successfulRelays;
+ RelayList failedRelays;
+
+ PLOG_INFO << "Attempting to publish event to Nostr relays.";
+
+ vector<future<tuple<string, bool>>> publishFutures;
+ for (string relay : this->_activeRelays)
+ {
+ future<tuple<string, bool>> 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<mutex> 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<mutex> 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 <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <plog/Appenders/ConsoleAppender.h>
+#include <plog/Formatters/TxtFormatter.h>
+#include <websocketpp/client.hpp>
+
+#include <client/web_socket_client.hpp>
+#include <nostr.hpp>
+
+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<std::string, bool>), 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<plog::ConsoleAppender<plog::TxtFormatter>> testAppender;
+ shared_ptr<MockWebSocketClient> testClient;
+
+ void SetUp() override
+ {
+ testAppender = make_shared<plog::ConsoleAppender<plog::TxtFormatter>>();
+ testClient = make_shared<MockWebSocketClient>();
+ };
+};
+
+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<unordered_map<string, bool>>();
+ 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<mutex> 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<unordered_map<string, bool>>();
+ 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<mutex> 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<unordered_map<string, bool>>();
+ 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<mutex> 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<unordered_map<string, bool>>();
+ connectionStatus->insert({ defaultTestRelays[0], false });
+ connectionStatus->insert({ defaultTestRelays[1], false });
+
+ EXPECT_CALL(*testClient, isConnected(_))
+ .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri)
+ {
+ lock_guard<mutex> 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<unordered_map<string, bool>>();
+ 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<mutex> 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<unordered_map<string, bool>>();
+ connectionStatus->insert({ defaultTestRelays[0], false });
+ connectionStatus->insert({ defaultTestRelays[1], false });
+
+ EXPECT_CALL(*testClient, isConnected(_))
+ .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri)
+ {
+ lock_guard<mutex> 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<unordered_map<string, bool>>();
+ connectionStatus->insert({ defaultTestRelays[0], false });
+ connectionStatus->insert({ defaultTestRelays[1], false });
+
+ EXPECT_CALL(*testClient, isConnected(_))
+ .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri)
+ {
+ lock_guard<mutex> 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<unordered_map<string, bool>>();
+ connectionStatus->insert({ defaultTestRelays[0], false });
+ connectionStatus->insert({ defaultTestRelays[1], false });
+
+ EXPECT_CALL(*testClient, isConnected(_))
+ .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri)
+ {
+ lock_guard<mutex> 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