From 91717bd15c359cedb99f261be653e83becd0df48 Mon Sep 17 00:00:00 2001 From: Mark Nunberg Date: Mon, 15 Apr 2019 14:49:54 -0400 Subject: [PATCH] basic gtest implementation Note that this does require that gtest be installed in `contrib/gtest`. This should be done using the `get_gtest` script. --- CMakeLists.txt | 10 +++ cpptests/CMakeLists.txt | 10 +++ cpptests/common.cpp | 43 ++++++++++ cpptests/common.h | 48 ++++++++++++ cpptests/t_async.cpp | 139 +++++++++++++++++++++++++++++++++ cpptests/t_basic.cpp | 34 ++++++++ cpptests/t_client.cpp | 169 ++++++++++++++++++++++++++++++++++++++++ get_gtest.sh | 23 ++++++ 8 files changed, 476 insertions(+) create mode 100644 cpptests/CMakeLists.txt create mode 100644 cpptests/common.cpp create mode 100644 cpptests/common.h create mode 100644 cpptests/t_async.cpp create mode 100644 cpptests/t_basic.cpp create mode 100644 cpptests/t_client.cpp create mode 100755 get_gtest.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index c8c8071..b2e7894 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,11 @@ MACRO(getVersionBit name) STRING(REGEX REPLACE ${VERSION_REGEX} "\\1" ${name} "${VERSION_BIT}") ENDMACRO(getVersionBit) +IF (${CMAKE_C_COMPILER_ID} MATCHES "GNU" OR ${CMAKE_C_COMPILER_ID} MATCHES "Clang") + SET(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall") + SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall") +ENDIF() + getVersionBit(HIREDIS_MAJOR) getVersionBit(HIREDIS_MINOR) getVersionBit(HIREDIS_PATCH) @@ -75,3 +80,8 @@ ENDIF() IF(ENABLE_EXAMPLES) ADD_SUBDIRECTORY(examples) ENDIF(ENABLE_EXAMPLES) + +IF(ENABLE_TESTS) + ADD_SUBDIRECTORY(contrib/gtest) + ADD_SUBDIRECTORY(cpptests) +ENDIF() \ No newline at end of file diff --git a/cpptests/CMakeLists.txt b/cpptests/CMakeLists.txt new file mode 100644 index 0000000..14274e4 --- /dev/null +++ b/cpptests/CMakeLists.txt @@ -0,0 +1,10 @@ +INCLUDE(CTest) +INCLUDE_DIRECTORIES(${gtest_SOURCE_DIR}/include) +INCLUDE_DIRECTORIES(PROJECT_SOURCE_DIR) +INCLUDE_DIRECTORIES(${LIBEVENT_INCLUDES}) + +ADD_EXECUTABLE(hiredis-gtest t_basic.cpp t_client.cpp t_async.cpp + common.cpp) +TARGET_LINK_LIBRARIES(hiredis-gtest gtest_main hiredis event) +ADD_TEST(NAME hiredis-test COMMAND hiredis-test) +SET_PROPERTY(TARGET hiredis-gtest PROPERTY CXX_STANDARD 11) \ No newline at end of file diff --git a/cpptests/common.cpp b/cpptests/common.cpp new file mode 100644 index 0000000..e2e6f15 --- /dev/null +++ b/cpptests/common.cpp @@ -0,0 +1,43 @@ +#include "common.h" + +hiredis::ClientSettings hiredis::settings_g; + +using namespace hiredis; + + +ClientSettings::ClientSettings() { + std::string hostval; + if (getenv("REDIS_SOCKET")) { + m_hostname.assign(getenv("REDIS_SOCKET")); + m_mode = REDIS_CONN_UNIX; + return; + } + + if (getenv("REDIS_HOST")) { + hostval.assign(getenv("REDIS_HOST")); + } + size_t idx = hostval.find(':'); + if (idx == std::string::npos) { + // First part is hostname only + m_hostname = hostval; + } else { + m_port = atoi(hostval.c_str() + idx); + hostval.resize(idx); + } + if (!m_port) { + m_port = 6379; + } + + // Handle SSL settings as well + m_ssl_cert_path = getenv("REDIS_SSL_CLIENT_CERT"); + m_ssl_key_path = getenv("REDIS_SSL_CLIENT_KEY"); + m_ssl_ca_path = getenv("REDIS_SSL_CA"); +} + +void ClientSettings::initOptions(redisOptions& options) const { + if (m_mode == REDIS_CONN_TCP) { + REDIS_OPTIONS_SET_TCP(&options, hostname(), port()); + } else if (options.type == REDIS_CONN_UNIX) { + REDIS_OPTIONS_SET_UNIX(&options, hostname()); + } +} diff --git a/cpptests/common.h b/cpptests/common.h new file mode 100644 index 0000000..8d66f1d --- /dev/null +++ b/cpptests/common.h @@ -0,0 +1,48 @@ +#ifndef HIREDIS_CPP_COMMON_H +#define HIREDIS_CPP_COMMON_H + +#include +#include +#include "hiredis.h" + +namespace hiredis { +class ClientSettings { +public: + ClientSettings(); + + const char *ssl_cert() const { return m_ssl_cert_path; } + const char *ssl_key() const { return m_ssl_key_path; } + const char *ssl_ca() const { return m_ssl_ca_path; } + + bool is_ssl() const { + return m_ssl_ca_path != NULL; + } + bool is_unix() const { + return false; + } + const char *hostname() const { + return m_hostname.c_str(); + } + uint16_t port() const { + return m_port; + } + int mode() const { + return m_mode; + } + + void initOptions(redisOptions& options) const; + +private: + std::string m_hostname; + uint16_t m_port; + + int m_mode = REDIS_CONN_TCP; + const char *m_ssl_cert_path = NULL; + const char *m_ssl_ca_path = NULL; + const char *m_ssl_key_path = NULL; +}; + +extern ClientSettings settings_g; + +} +#endif diff --git a/cpptests/t_async.cpp b/cpptests/t_async.cpp new file mode 100644 index 0000000..13968a1 --- /dev/null +++ b/cpptests/t_async.cpp @@ -0,0 +1,139 @@ +#include +#include +#include "adapters/libevent.h" +#include +#include +#include "common.h" + +using namespace hiredis; + +struct AsyncClient; +typedef std::function ConnectionCallback; +typedef std::function DisconnectCallback; +typedef std::function CommandCallback; + +static void realConnectCb(const redisAsyncContext*, int); +static void realDisconnectCb(const redisAsyncContext*, int); +static void realCommandCb(redisAsyncContext*, void*, void*); + +struct CmdData { + AsyncClient *client; + CommandCallback cb; +}; + +struct AsyncClient { + AsyncClient(const redisOptions& options, event_base* b) { + ac = redisAsyncConnectWithOptions(&options); + redisLibeventAttach(ac, b); + redisAsyncSetConnectCallback(ac, realConnectCb); + ac->data = this; + } + + AsyncClient(const ClientSettings& settings, event_base *b) { + redisOptions options = { 0 }; + ac = redisAsyncConnectWithOptions(&options); + redisLibeventAttach(ac, b); + redisAsyncSetConnectCallback(ac, realConnectCb); + if (settings.is_ssl()) { + redisSecureConnection(&ac->c, + settings.ssl_ca(), settings.ssl_cert(), settings.ssl_key(), + NULL); + } + ac->data = this; + } + + AsyncClient(redisAsyncContext *ac) : ac(ac) { + redisAsyncSetDisconnectCallback(ac, realDisconnectCb); + } + + void onConnect(ConnectionCallback cb) { + conncb = cb; + } + + ~AsyncClient() { + if (ac != NULL) { + auto tmpac = ac; + ac = NULL; + redisAsyncDisconnect(tmpac); + } + } + + void cmd(CommandCallback cb, const char *fmt, ...) { + auto data = new CmdData {this, cb }; + va_list ap; + va_start(ap, fmt); + redisvAsyncCommand(ac, realCommandCb, data, fmt, ap); + va_end(ap); + } + + void disconnect(DisconnectCallback cb) { + disconncb = cb; + redisAsyncDisconnect(ac); + } + + void disconnect() { + redisAsyncDisconnect(ac); + } + + ConnectionCallback conncb; + DisconnectCallback disconncb; + redisAsyncContext *ac; +}; + + +static void realConnectCb(const redisAsyncContext *ac, int status) { + auto self = reinterpret_cast(ac->data); + if (self->conncb) { + self->conncb(self, status == 0); + } +} + +static void realDisconnectCb(const redisAsyncContext *ac, int status) { + auto self = reinterpret_cast(ac->data); + if (self->disconncb) { + self->disconncb(self, status == 0); + } +} + +static void realCommandCb(redisAsyncContext *ac, void *r, void *ctx) { + auto *d = reinterpret_cast(ctx); + auto *rep = reinterpret_cast(r); + auto *self = reinterpret_cast(ac->data); + d->cb(self, rep); + delete d; +} + +class AsyncTest : public ::testing::Test { +protected: + void SetUp() override { + libevent = event_base_new(); + } + void TearDown() override { + event_base_free(libevent); + libevent = NULL; + } + void wait() { + event_base_dispatch(libevent); + } + event_base *libevent; +}; + +TEST_F(AsyncTest, testAsync) { + redisOptions options = {0}; + struct timeval tv = {0}; + tv.tv_sec = 1; + options.timeout = &tv; + settings_g.initOptions(options); + AsyncClient client(options, libevent); + + client.onConnect([](AsyncClient*, bool status) { + printf("Status: %d\n", status); + }); + + client.cmd([](AsyncClient *c, redisReply*){ + printf("Got reply!\n"); + c->disconnect(); + }, "PING"); + + wait(); +} diff --git a/cpptests/t_basic.cpp b/cpptests/t_basic.cpp new file mode 100644 index 0000000..671a455 --- /dev/null +++ b/cpptests/t_basic.cpp @@ -0,0 +1,34 @@ +#include +#include +#include +#include +#include "hiredis.h" + +class FormatterTest : public ::testing::Test { +}; + + +static std::string formatCommand(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *s = NULL; + size_t n = redisvFormatCommand(&s, fmt, ap); + va_end(ap); + std::string xs(s, n); + free(s); + return xs; +} + +TEST_F(FormatterTest, testFormatCommands) { + auto expected = "*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"; + ASSERT_EQ(expected, formatCommand("SET foo bar")) + << "No interpolation"; + + expected = "*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"; + ASSERT_EQ(expected, formatCommand("SET %s %s", "foo", "bar")) + << "interpolation"; + + expected = "*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$0\r\n\r\n"; + ASSERT_EQ(expected, formatCommand("SET %s %s", "foo", "")) + << "empty string"; +} \ No newline at end of file diff --git a/cpptests/t_client.cpp b/cpptests/t_client.cpp new file mode 100644 index 0000000..df60472 --- /dev/null +++ b/cpptests/t_client.cpp @@ -0,0 +1,169 @@ +#include +#include +#include +#include "hiredis.h" +#include "common.h" + + +using namespace hiredis; + +class ClientError : public std::runtime_error { +public: + ClientError() : std::runtime_error("hiredis error") { + } + ClientError(const char *s) : std::runtime_error(s) { + } +}; + +class ConnectError : public ClientError { +public: + ConnectError() : ClientError(){} + ConnectError(const redisOptions& options) { + if (options.type == REDIS_CONN_TCP) { + endpoint = options.endpoint.tcp.ip; + endpoint += ":"; + endpoint += options.endpoint.tcp.port; + } else if (options.type == REDIS_CONN_UNIX) { + endpoint = "unix://"; + endpoint += options.endpoint.unix_socket; + } + } + virtual const char *what() const noexcept override{ + return endpoint.c_str(); + } +private: + std::string endpoint; +}; + +class IOError : public ClientError {}; +class TimeoutError : public ClientError {}; +class SSLError : public ClientError {}; + +class CommandError : public ClientError { +public: + CommandError(const redisReply *r) { + errstr = r->str; + } + virtual const char *what() const noexcept override { + return errstr.c_str(); + } +private: + std::string errstr; +}; + +void errorFromCode(int code) { + switch (code) { + case REDIS_ERR_IO: + case REDIS_ERR_EOF: + case REDIS_ERR_PROTOCOL: + throw IOError(); + case REDIS_ERR_TIMEOUT: + throw TimeoutError(); + default: + throw ClientError(); + } +} + +class Client { +public: + operator redisContext*() { + return ctx; + } + + Client(redisContext *ctx) :ctx(ctx) { + } + + Client(const redisOptions& options) { + connectOrThrow(options); + } + + Client(const ClientSettings& settings) { + redisOptions options = {0}; + settings.initOptions(options); + connectOrThrow(options); + if (settings.is_ssl()) { + secureConnection(settings); + } + } + + void secureConnection(const ClientSettings& settings) { + if (redisSecureConnection( + ctx, settings.ssl_ca(), settings.ssl_cert(), + settings.ssl_key(), NULL) != REDIS_OK) { + redisFree(ctx); + ctx = NULL; + throw SSLError(); + } + } + + redisReply *cmd(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + void *p = redisvCommand(ctx, fmt, ap); + va_end(ap); + return reinterpret_cast(p); + } + + void flushdb() { + redisReply *p = cmd("FLUSHDB"); + if (p == NULL) { + errorFromCode(ctx->err); + } + if (p->type == REDIS_REPLY_ERROR) { + auto pp = CommandError(p); + freeReplyObject(p); + throw pp; + } + freeReplyObject(p); + } + + ~Client() { + if (ctx != NULL) { + redisFree(ctx); + } + } + + Client(Client&& other) { + this->ctx = other.ctx; + other.ctx = NULL; + } + + void nothing() const {} + +private: + void destroyAndThrow() { + assert(ctx->err); + int err = ctx->err; + redisFree(ctx); + ctx = NULL; + errorFromCode(err); + } + void connectOrThrow(const redisOptions& options) { + ctx = redisConnectWithOptions(&options); + if (!ctx) { + throw ConnectError(); + } + if (ctx->err) { + destroyAndThrow(); + } + } + redisContext *ctx; +}; + +class ClientTest : public ::testing::Test { +}; + +TEST_F(ClientTest, testTimeout) { + redisOptions options = {0}; + timeval tv = {0}; + tv.tv_usec = 10000; // 10k micros, small enough + options.timeout = &tv; + // see https://tools.ietf.org/html/rfc5737 + // this block of addresses is reserved for "documentation", and it + // would likely not connect, ever. + ASSERT_THROW(Client(options).nothing(), ClientError); + + // Test the normal timeout + Client c(settings_g); + c.flushdb(); +} diff --git a/get_gtest.sh b/get_gtest.sh new file mode 100755 index 0000000..d400f0a --- /dev/null +++ b/get_gtest.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Run this from the toplevel directory of the source code tree +GTEST_URL_BASE=https://s3-eu-central-1.amazonaws.com/redislabs-dev-public-deps +GTEST_URL_BASE=https://github.com/google/googletest/archive/ +GTEST_FILENAME=release-1.8.0.tar.gz +GTEST_TOPDIR=googletest-release-1.8.0 +DESTDIR=contrib + +if [ -d $DESTDIR/gtest ]; then + exit 0 +fi + +curdir=$PWD +tarball=/tmp/${GTEST_FILENAME} +url=${GTEST_URL_BASE}/${GTEST_FILENAME} +if [ ! -e $tarball ]; then + wget -O $tarball $url +fi + +tar -C $DESTDIR -xf $tarball +rm $DESTDIR/gtest +cd $DESTDIR +ln -s $GTEST_TOPDIR gtest