From bc052fbd512e781affafc106a917fb50c8b3a284 Mon Sep 17 00:00:00 2001 From: Dennis Oberst Date: Fri, 15 Aug 2025 13:47:29 +0200 Subject: [PATCH] Long Live mockserver! Our current test strategy relies on having a dedicated server running. While this tests the most 'accurate' flow of the system, it is really hard to verify the server behavior due to missing flexibility. Introduce the mockserver to solve this problem. It wraps the low-level async gRPC server API and allows for non-blocking processing of operations. This allows it to run nicely together with the Qt Even Loop, which is blocking. Provide the server-handling code by using CallbackTags with Lambdas, or by subclassing the AbstractRpcTag interface. Change-Id: I086cbd1d6d8542c717ae036bdf54ba96c55afb58 Fixes: QTBUG-139285 Reviewed-by: Alexey Edelev --- tests/auto/grpc/client/CMakeLists.txt | 3 + tests/auto/grpc/client/end2end/CMakeLists.txt | 68 +++ tests/auto/grpc/client/end2end/event.proto | 15 + tests/auto/grpc/client/end2end/eventhub.proto | 15 + .../end2end/tst_grpc_client_end2end.cpp | 440 ++++++++++++++++++ tests/auto/grpc/client/shared/CMakeLists.txt | 1 + .../client/shared/mockserver/CMakeLists.txt | 34 ++ .../client/shared/mockserver/certificates.h | 98 ++++ .../client/shared/mockserver/mockserver.cpp | 133 ++++++ .../client/shared/mockserver/mockserver.h | 63 +++ .../auto/grpc/client/shared/mockserver/tags.h | 84 ++++ 11 files changed, 954 insertions(+) create mode 100644 tests/auto/grpc/client/end2end/CMakeLists.txt create mode 100644 tests/auto/grpc/client/end2end/event.proto create mode 100644 tests/auto/grpc/client/end2end/eventhub.proto create mode 100644 tests/auto/grpc/client/end2end/tst_grpc_client_end2end.cpp create mode 100644 tests/auto/grpc/client/shared/mockserver/CMakeLists.txt create mode 100644 tests/auto/grpc/client/shared/mockserver/certificates.h create mode 100644 tests/auto/grpc/client/shared/mockserver/mockserver.cpp create mode 100644 tests/auto/grpc/client/shared/mockserver/mockserver.h create mode 100644 tests/auto/grpc/client/shared/mockserver/tags.h diff --git a/tests/auto/grpc/client/CMakeLists.txt b/tests/auto/grpc/client/CMakeLists.txt index 6a2d6abd..50c44a94 100644 --- a/tests/auto/grpc/client/CMakeLists.txt +++ b/tests/auto/grpc/client/CMakeLists.txt @@ -10,3 +10,6 @@ add_subdirectory(bidistream) if(QT_FEATURE_ssl AND NOT WIN32) add_subdirectory(ssl) endif() +if(TARGET grpc_mock_server) + add_subdirectory(end2end) +endif() diff --git a/tests/auto/grpc/client/end2end/CMakeLists.txt b/tests/auto/grpc/client/end2end/CMakeLists.txt new file mode 100644 index 00000000..3895cff4 --- /dev/null +++ b/tests/auto/grpc/client/end2end/CMakeLists.txt @@ -0,0 +1,68 @@ +# Copyright (C) 2025 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +if(NOT QT_BUILD_STANDALONE_TESTS AND NOT QT_BUILDING_QT) + cmake_minimum_required(VERSION 3.16) + project(tst_grpc_client_end2end LANGUAGES CXX) + find_package(Qt6BuildInternals REQUIRED COMPONENTS STANDALONE_TEST) + include("../shared/mockserver/CMakeLists.txt") +endif() + +if(NOT TARGET grpc_mock_server) + message(WARNING "Dependencies of 'tst_grpc_client_end2end' not found. Skipping.") + return() +endif() + +qt_internal_add_test(tst_grpc_client_end2end + SOURCES + tst_grpc_client_end2end.cpp + LIBRARIES + Qt::Test + Qt::Core + Qt::Grpc + grpc_mock_server +) + +set(proto_files + "${CMAKE_CURRENT_LIST_DIR}/eventhub.proto" + "${CMAKE_CURRENT_LIST_DIR}/event.proto" +) +set(proto_import "${CMAKE_CURRENT_LIST_DIR}") +set(proto_out_include "${CMAKE_CURRENT_BINARY_DIR}/include") +set(proto_out_server "${proto_out_include}/proto/server") +set(proto_out_client "${proto_out_include}/proto/client") + +qt_add_protobuf(tst_grpc_client_end2end + PROTO_FILES + ${proto_files} + OUTPUT_DIRECTORY + ${proto_out_client} + EXTRA_NAMESPACE "qt" +) +qt_add_grpc(tst_grpc_client_end2end CLIENT + PROTO_FILES + ${proto_files} + OUTPUT_DIRECTORY + ${proto_out_client} + EXTRA_NAMESPACE "qt" +) + +protobuf_generate( + PROTOS ${proto_files} + TARGET tst_grpc_client_end2end + IMPORT_DIRS ${proto_import} + PROTOC_OUT_DIR ${proto_out_server} +) +protobuf_generate( + PROTOS ${proto_files} + TARGET tst_grpc_client_end2end + LANGUAGE grpc + GENERATE_EXTENSIONS .grpc.pb.h .grpc.pb.cc + PLUGIN "protoc-gen-grpc=\$" + IMPORT_DIRS ${proto_import} + PROTOC_OUT_DIR "${proto_out_server}" +) + +target_include_directories(tst_grpc_client_end2end PRIVATE + "${proto_out_include}" +) diff --git a/tests/auto/grpc/client/end2end/event.proto b/tests/auto/grpc/client/end2end/event.proto new file mode 100644 index 00000000..499bfc3e --- /dev/null +++ b/tests/auto/grpc/client/end2end/event.proto @@ -0,0 +1,15 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +syntax = "proto3"; + +message Event { + enum Type { + UNKNOWN = 0; + SERVER = 1; + CLIENT = 2; + } + Type type = 1; + string name = 2; + uint64 number = 3; +} diff --git a/tests/auto/grpc/client/end2end/eventhub.proto b/tests/auto/grpc/client/end2end/eventhub.proto new file mode 100644 index 00000000..8ef30cab --- /dev/null +++ b/tests/auto/grpc/client/end2end/eventhub.proto @@ -0,0 +1,15 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +syntax = "proto3"; + +import "event.proto"; + +message None {} + +service EventHub { + rpc Push(Event) returns (None) {} + rpc Subscribe(None) returns (stream Event) {} + rpc Notify(stream Event) returns (None) {} + rpc Exchange(stream Event) returns (stream Event) {} +} diff --git a/tests/auto/grpc/client/end2end/tst_grpc_client_end2end.cpp b/tests/auto/grpc/client/end2end/tst_grpc_client_end2end.cpp new file mode 100644 index 00000000..45fd03a5 --- /dev/null +++ b/tests/auto/grpc/client/end2end/tst_grpc_client_end2end.cpp @@ -0,0 +1,440 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include + +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include + +// Re-define so that we can use QTest Macros inside non void functions too. +#undef QTEST_FAIL_ACTION +#define QTEST_FAIL_ACTION \ + do { \ + std::cerr << "Test failed!" << std::endl; \ + std::abort(); \ + } while (0) + +using namespace Qt::Literals::StringLiterals; +using MultiHash = QMultiHash; + +class QtGrpcClientEnd2EndTest : public QObject +{ + Q_OBJECT + +public: + static std::string serverHttpAddress() { return "localhost:50051"; } + static std::string serverHttpsAddress() { return "localhost:50052"; } + static std::string serverUnixAddress() { return "unix:///tmp/qtgrpc_test_end2end.sock"; } + static std::string serverUnixAbstractAddress() { return "unix-abstract:qtgrpc_test_end2end"; } + static std::vector serverListeningPorts() + { + return { + { serverHttpAddress(), grpc::InsecureServerCredentials() }, +#if QT_CONFIG(ssl) + { serverHttpsAddress(), serverSslCredentials() }, +#endif +#ifdef Q_OS_UNIX + { serverUnixAddress(), grpc::InsecureServerCredentials() }, +#endif + +#ifdef Q_OS_LINUX + { serverUnixAbstractAddress(), grpc::InsecureServerCredentials() }, +#endif + }; + } + +private Q_SLOTS: + void initTestCase_data() const; + void initTestCase(); + void cleanupTestCase(); + + void init(); + void cleanup(); + + // Testcases: + void clientMetadataReceived_data() const; + void clientMetadataReceived(); + void serverMetadataReceived_data() const; + void serverMetadataReceived(); + + void bidiStreamsInOrder(); + +private: + static std::shared_ptr serverSslCredentials() + { + grpc::SslServerCredentialsOptions opts(GRPC_SSL_DONT_REQUEST_CLIENT_CERTIFICATE); + opts.pem_key_cert_pairs.push_back({ SslKey, SslCert }); + return grpc::SslServerCredentials(opts); + } + +private: + std::unique_ptr m_server; + std::unique_ptr m_service; + std::unique_ptr m_client; +}; + +void QtGrpcClientEnd2EndTest::initTestCase_data() const +{ + QTest::addColumn>("channel"); + + QUrl httpAddress("http://"_ba + QByteArrayView(serverHttpAddress())); + QTest::newRow("http") << std::make_shared(httpAddress); + +#if QT_CONFIG(ssl) + QSslConfiguration tlsConfig; + tlsConfig.setProtocol(QSsl::TlsV1_2); + tlsConfig.setCaCertificates({ QSslCertificate{ QByteArray(SslCert) } }); + tlsConfig.setAllowedNextProtocols({ "h2"_ba }); + QGrpcChannelOptions chOpts; + chOpts.setSslConfiguration(tlsConfig); + QUrl httpsAddress("https://"_ba + QByteArrayView(serverHttpsAddress())); + QTest::newRow("https") << std::make_shared(httpsAddress, chOpts); +#endif + +#ifdef Q_OS_UNIX + QUrl unixAddress(serverUnixAddress().data()); + QTest::newRow("unix") << std::make_shared(unixAddress); +#endif + +#ifdef Q_OS_LINUX + QUrl unixAbstractAddress(serverUnixAbstractAddress().data()); + QTest::newRow("unix-abstract") << std::make_shared(unixAbstractAddress); +#endif +} + +void QtGrpcClientEnd2EndTest::initTestCase() +{ + QTest::failOnWarning(); + m_service = std::make_unique(); + m_server = std::make_unique(); + QVERIFY(m_server->start(serverListeningPorts(), { m_service.get() })); +} + +void QtGrpcClientEnd2EndTest::cleanupTestCase() +{ + m_client.reset(); + QVERIFY(m_server->stop()); + m_service.reset(); +} + +void QtGrpcClientEnd2EndTest::init() +{ + QVERIFY(m_service && m_server); + QFETCH_GLOBAL(std::shared_ptr, channel); + m_client = std::make_unique(); + QVERIFY(m_client->attachChannel(channel)); +} + +void QtGrpcClientEnd2EndTest::cleanup() +{ + m_client.reset(); + QVERIFY(m_server->stopAsyncProcessing()); +} + +void QtGrpcClientEnd2EndTest::clientMetadataReceived_data() const +{ + QTest::addColumn("callMetadata"); + QTest::addColumn("channelMetadata"); + MultiHash callMd{ + { "client-call-single", "call-value-1" }, + { "client-call-multi", "call-a" }, + { "client-call-multi", "call-b" } + }; + MultiHash channelMd{ + { "client-channel-single", "channel-value-1" }, + { "client-channel-multi", "channel-a" }, + { "client-channel-multi", "channel-b" } + }; + QTest::addRow("call") << callMd << MultiHash{}; + QTest::addRow("channel") << MultiHash{} << channelMd; + QTest::addRow("call+channel") << callMd << channelMd; +} + +void QtGrpcClientEnd2EndTest::clientMetadataReceived() +{ + QFETCH(const MultiHash, callMetadata); + QFETCH(const MultiHash, channelMetadata); + + // Setup Server-side handling + struct ServerData + { + grpc::ServerAsyncResponseWriter op{ &ctx }; + grpc::ServerContext ctx; + + Event request; + None response; + }; + auto *data = new ServerData; + + CallbackTag *callHandler = new CallbackTag([&](bool ok) { + QVERIFY(ok); + + const std::multimap + &receivedMd = data->ctx.client_metadata(); + auto mergedMd = channelMetadata; + mergedMd.unite(callMetadata); + + for (auto it = mergedMd.cbegin(); it != mergedMd.cend(); ++it) { + // Check that each key-value pair sent by the client exists on the server + auto serverRange = receivedMd.equal_range(it.key().toStdString()); + auto clientRange = mergedMd.equal_range(it.key()); + + QCOMPARE_EQ(std::distance(serverRange.first, serverRange.second), + std::distance(clientRange.first, clientRange.second)); + while (clientRange.first != clientRange.second) { + // Look for the exact entry in the server range. The order may + // be changed but it must be present. + const auto it = std::find_if(serverRange.first, serverRange.second, [&](auto it) { + return it.first == clientRange.first.key().toStdString() + && it.second == clientRange.first.value().toStdString(); + }); + QVERIFY(it != serverRange.second); + std::advance(clientRange.first, 1); + } + } + data->op.Finish(data->response, grpc::Status::OK, new DeleteTag(data)); + return CallbackTag::Delete; + }); + m_service->RequestPush(&data->ctx, &data->request, &data->op, m_server->cq(), m_server->cq(), + callHandler); + + // Setup Client-side call + m_client->channel()->setChannelOptions(QGrpcChannelOptions().setMetadata(channelMetadata)); + auto call = m_client->Push(qt::Event{}, QGrpcCallOptions().setMetadata(callMetadata)); + QVERIFY(call); + + connect(call.get(), &QGrpcOperation::finished, this, [&](const QGrpcStatus &status) { + QVERIFY(status.isOk()); + auto response = call->read(); + QVERIFY(response.has_value()); + }); + + QVERIFY(m_server->startAsyncProcessing()); + + QSignalSpy finishedSpy(call.get(), &QGrpcOperation::finished); + QVERIFY(finishedSpy.isValid()); + QVERIFY(finishedSpy.wait()); +} + +void QtGrpcClientEnd2EndTest::serverMetadataReceived_data() const +{ + QTest::addColumn("filterServerMetadata"); + QTest::addColumn("expectedInitialMd"); + QTest::addColumn("expectedTrailingMd"); + + MultiHash initialMd{ + { "initial-1", "ivalue-1" }, + { "initial-2", "ivalue-2" } + }; + MultiHash trailingMd{ + { "trailing-1", "tvalue-1" }, + { "trailing-multi", "tvalue-x" }, + { "trailing-multi", "tvalue-y" } + }; + + QTest::addRow("filter(true)") << true << initialMd << trailingMd; + QTest::addRow("filter(false)") << false << initialMd << trailingMd; +} + +void QtGrpcClientEnd2EndTest::serverMetadataReceived() +{ + using MultiHash = QMultiHash; + QFETCH(const bool, filterServerMetadata); + QFETCH(const MultiHash, expectedInitialMd); + QFETCH(const MultiHash, expectedTrailingMd); + + // Setup Server-side handling + struct ServerData + { + grpc::ServerAsyncResponseWriter op{ &ctx }; + grpc::ServerContext ctx; + + Event request; + None response; + }; + auto *data = new ServerData; + + for (auto it = expectedInitialMd.cbegin(); it != expectedInitialMd.cend(); ++it) + data->ctx.AddInitialMetadata(it.key().toStdString(), it.value().toStdString()); + for (auto it = expectedTrailingMd.cbegin(); it != expectedTrailingMd.cend(); ++it) + data->ctx.AddTrailingMetadata(it.key().toStdString(), it.value().toStdString()); + + CallbackTag *callHandler = new CallbackTag([&](bool ok) { + QVERIFY(ok); + data->op.Finish(data->response, grpc::Status::OK, new DeleteTag(data)); + return CallbackTag::Delete; + }); + m_service->RequestPush(&data->ctx, &data->request, &data->op, m_server->cq(), m_server->cq(), + callHandler); + + // Setup Client-side call + auto chOpts = QGrpcChannelOptions().setFilterServerMetadata(filterServerMetadata); + m_client->channel()->setChannelOptions(chOpts); + + auto call = m_client->Push(qt::Event{}); + QVERIFY(call); + + connect(call.get(), &QGrpcOperation::finished, this, [&](const QGrpcStatus &status) { + QVERIFY(status.isOk()); + auto response = call->read(); + QVERIFY(response.has_value()); + + const auto &initialMd = call->serverInitialMetadata(); + const auto &trailingMd = call->serverTrailingMetadata(); + + if (filterServerMetadata) { + QCOMPARE(initialMd, expectedInitialMd); + QCOMPARE(trailingMd, expectedTrailingMd); + } else { + QCOMPARE_GE(initialMd.size(), expectedInitialMd.size()); + QCOMPARE_GE(trailingMd.size(), expectedTrailingMd.size()); + for (auto it = expectedInitialMd.cbegin(); it != expectedInitialMd.cend(); ++it) + QVERIFY(initialMd.contains(it.key(), it.value())); + for (auto it = expectedTrailingMd.cbegin(); it != expectedTrailingMd.cend(); ++it) + QVERIFY(trailingMd.contains(it.key(), it.value())); + } + }); + QVERIFY(m_server->startAsyncProcessing()); + + QSignalSpy finishedSpy(call.get(), &QGrpcOperation::finished); + QVERIFY(finishedSpy.isValid()); + QVERIFY(finishedSpy.wait()); +} + +void QtGrpcClientEnd2EndTest::bidiStreamsInOrder() +{ + constexpr auto SleepTime = std::chrono::milliseconds(5); + + // Setup Server-side handling + struct ServerData + { + grpc::ServerAsyncReaderWriter op{ &ctx }; + grpc::ServerContext ctx; + + Event request; + Event response; + unsigned long count = 0; + std::atomic readerDone = false; + std::atomic writerDone = false; + + void updateResponse() + { + response.set_type(Event::SERVER); + response.set_number(response.number() + 1); + response.set_name("server-" + std::to_string(response.number())); + } + }; + auto *data = new ServerData; + + CallbackTag *reader = new CallbackTag([&, current = 1u](bool ok) mutable { + if (!ok) { + data->readerDone = true; + if (data->writerDone) + data->op.Finish(grpc::Status::OK, new DeleteTag(data)); + return CallbackTag::Delete; + } + QCOMPARE_EQ(data->request.type(), Event::CLIENT); + QCOMPARE_EQ(data->request.number(), current); + std::string name = "client-" + std::to_string(current); + QCOMPARE_EQ(data->request.name(), name); + ++current; + + data->op.Read(&data->request, reader); + return CallbackTag::Proceed; + }); + CallbackTag *writer = new CallbackTag([&](bool ok) { + QVERIFY(ok); + if (data->response.number() >= data->count) { + data->writerDone = true; + if (data->readerDone) + data->op.Finish(grpc::Status::OK, new DeleteTag(data)); + return CallbackTag::Delete; + } + std::this_thread::sleep_for(SleepTime); + data->updateResponse(); + data->op.Write(data->response, writer); + return CallbackTag::Proceed; + }); + CallbackTag *callHandler = new CallbackTag([&](bool ok) { + QVERIFY(ok); + const auto &md = data->ctx.client_metadata(); + const auto countIt = md.find("call-count"); + QVERIFY(countIt != md.cend()); + data->count = std::stoul(std::string(countIt->second.data(), countIt->second.length())); + QCOMPARE_GT(data->count, 0); + + data->op.Read(&data->request, reader); + data->updateResponse(); + data->op.Write(data->response, writer); + + return CallbackTag::Delete; + }); + m_service->RequestExchange(&data->ctx, &data->op, m_server->cq(), m_server->cq(), callHandler); + + // Client bidi stream + uint callCount = 25; + qt::Event request; + + auto updateRequest = [&] { + request.setType(qt::Event::Type::CLIENT); + request.setNumber(request.number() + 1); + request.setName("client-"_L1 + QString::number(request.number())); + }; + + updateRequest(); + auto copts = QGrpcCallOptions().addMetadata("call-count", QByteArray::number(callCount)); + auto stream = m_client->Exchange(request, copts); + QVERIFY(stream); + + connect(stream.get(), &QGrpcOperation::finished, this, + [](const QGrpcStatus &status) { QVERIFY(status.isOk()); }); + connect(stream.get(), &QGrpcBidiStream::messageReceived, this, [&, current = 1u]() mutable { + const auto response = stream->read(); + QVERIFY(response.has_value()); + QCOMPARE_EQ(response->type(), qt::Event::Type::SERVER); + QCOMPARE_EQ(response->number(), current); + QString name = "server-"_L1 + QString::number(current); + QCOMPARE_EQ(response->name(), name); + ++current; + }); + + QTimer delayedWriter; + connect(&delayedWriter, &QTimer::timeout, this, [&, current = 1u]() mutable { + if (current >= callCount) { + stream->writesDone(); + delayedWriter.stop(); + } + updateRequest(); + stream->writeMessage(request); + ++current; + }); + delayedWriter.start(SleepTime); + + QVERIFY(m_server->startAsyncProcessing()); + + QSignalSpy finishedSpy(stream.get(), &QGrpcOperation::finished); + QVERIFY(finishedSpy.isValid()); + QVERIFY(finishedSpy.wait()); +} + +QTEST_MAIN(QtGrpcClientEnd2EndTest) + +#include "tst_grpc_client_end2end.moc" diff --git a/tests/auto/grpc/client/shared/CMakeLists.txt b/tests/auto/grpc/client/shared/CMakeLists.txt index 38c70f71..5276d3ed 100644 --- a/tests/auto/grpc/client/shared/CMakeLists.txt +++ b/tests/auto/grpc/client/shared/CMakeLists.txt @@ -11,3 +11,4 @@ else() endif() add_subdirectory(client_service) add_subdirectory(client_test_common) +add_subdirectory(mockserver) diff --git a/tests/auto/grpc/client/shared/mockserver/CMakeLists.txt b/tests/auto/grpc/client/shared/mockserver/CMakeLists.txt new file mode 100644 index 00000000..74ce12fd --- /dev/null +++ b/tests/auto/grpc/client/shared/mockserver/CMakeLists.txt @@ -0,0 +1,34 @@ +# Copyright (C) 2025 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +set(CMAKE_FIND_PACKAGE_PREFER_CONFIG TRUE) + +find_package(Protobuf) +find_package(gRPC) + +if(CMAKE_CROSSCOMPILING) + find_program(grpc_cpp_plugin grpc_cpp_plugin NO_CACHE) +elseif(TARGET gRPC::grpc_cpp_plugin) + set(grpc_cpp_plugin $) +else() + set(grpc_cpp_plugin "") +endif() + +if(NOT grpc_cpp_plugin OR NOT TARGET WrapProtoc::WrapProtoc + OR NOT TARGET gRPC::grpc++) + message(WARNING "Dependencies of 'grpc_mock_server' not found. Skipping.") + return() +endif() + +add_library(grpc_mock_server STATIC mockserver.h mockserver.cpp) +target_include_directories(grpc_mock_server + PUBLIC + "${CMAKE_CURRENT_LIST_DIR}" +) + +target_link_libraries(grpc_mock_server + PUBLIC + protobuf::libprotobuf + gRPC::grpc++ +) + diff --git a/tests/auto/grpc/client/shared/mockserver/certificates.h b/tests/auto/grpc/client/shared/mockserver/certificates.h new file mode 100644 index 00000000..58183787 --- /dev/null +++ b/tests/auto/grpc/client/shared/mockserver/certificates.h @@ -0,0 +1,98 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#pragma once + +#include + +// Valid for the next 100 years. +inline const static std::string SslCert = R"( +-----BEGIN CERTIFICATE----- +MIIFpDCCA4wCCQCbPKhPsY/FVjANBgkqhkiG9w0BAQsFADCBkzELMAkGA1UEBhMC +REUxDzANBgNVBAgMBkJlcmxpbjEPMA0GA1UEBwwGQmVybGluMRwwGgYDVQQKDBNU +aGUgUXQgQ29tcGFueSBHbWJIMQwwCgYDVQQLDANSbkQxEjAQBgNVBAMMCWxvY2Fs +aG9zdDEiMCAGCSqGSIb3DQEJARYTZGVubmlzLm9iZXJzdEBxdC5pbzAeFw0yNTA4 +MjExMzQ5MjFaFw0zNTA4MTkxMzQ5MjFaMIGTMQswCQYDVQQGEwJERTEPMA0GA1UE +CAwGQmVybGluMQ8wDQYDVQQHDAZCZXJsaW4xHDAaBgNVBAoME1RoZSBRdCBDb21w +YW55IEdtYkgxDDAKBgNVBAsMA1JuRDESMBAGA1UEAwwJbG9jYWxob3N0MSIwIAYJ +KoZIhvcNAQkBFhNkZW5uaXMub2JlcnN0QHF0LmlvMIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEA5kkaxZi/RtgEOWm7CmFvRlocBMtAPumZSvHCYxQXNvp7 +biombepztFejuNzdO6Xaz1IbQhDmzRv9PAQFvOYGLWCfV8oMeZE+maQNIBVSxIaK +c7o2AZUA0z1AROLpfrn+RbhTfTrXiY5FVheMJZIZC5EvdLCBPmlSpE1ILk1otkcl +ID6EMsVAhTdFRP3CJZtAaGFivLsJmgkXHgmGT+pGY4eknIhuRMCWk+mnyuqbI5rf +H4eXTb2mtXTtiOy4CNd7N3d2m4su1OiYYl8yo8iZRS6XV3NCs73QaEoTbEoh0YR+ +m88xxUsl42AWUnNn+A4V3p4SpHZUwDLC+MBVVuB/AlTrGVTMmztYuUhkZH9WTwn4 ++cezAO925Wei0tkAGkqYvYLUFxEzgT8u+x0WpcbvQvvkf2/l4UCadw6k1UEWmyFT ++mHe1uYYucK+Qmj/lPkHgipyDDSzOKCU2r6GCxkl7ctCnUIUyYdiw0aMkJq2gOyM +TPXT9XXFumeK3jnv7/4Ly4UivhediwhJXRI2aMOctcK3zgQ0yKe4T8Sjao9un4Fb +euvtp9+CXxj2tEst1gQKn/kB0VHa4WqnC6TaZug7hTwwYvmwApvAnSEK5ach8oJR +hgfPctVHTdkpOkvrF8xxQW/3+ip3LawZm17egUXx+0JNNw7yA+FRcRFxLN858W0C +AwEAATANBgkqhkiG9w0BAQsFAAOCAgEAkSN4aIkGfVgi9XBZeja78lMnfyUD9lkO +NSZ2XS+dKUi2xOWGMzWnD6lqOLKxAUDeQVH+M/Y+8xwoVsiRfbtONRjHcUKa8YR/ +Z0BzU4CLohFJGLwxSJtjoIiHVF9io9GAjsG7heawqhuAyUHnjAuqQbad3AIiTzIr +lheH2zQUVd+ld2ozLQinHvEGcTOl6U5TgNQVa75ojgdYBeRH6ewuKQt0nSCRXd+K +CtImwWPKjpbdcS5vom7U+1SGsz0/MvaLXq7DoMjaLXMdljrEEjTUjuhEJ3Ld7DBH +UeColH1wuZVyaHEJNRdKb/VjqC2SKdSHT+uvkuTlWq+lY3vyV/tAjUsRmhf539Xh +fpA1Ok9GSwtAD45tV/aHblxtogI0R4xKA+CCF/c5FYsl4cX9dSVq7Nyxfb0ZvP+S +8aKrqy64XnvEVotUQIP/qZspJgE9pbgnJ7cm6TL+D73OghV+6tvhgiYM4UA7oRJZ +pAqyCLhjTgv1KEGQQ5b7dRFvbt6IbmmdetOjOICVhko6Rz2/M3fIS7EvY12Vuhs0 +IUjB00f4dn5CD+2zyv74BNIesyk2YEfaqck7CrAjbz69MNjoTvYdMRr4o0eN8yVD +MUnTeeMKniNQ2aTUMPpTnTIPwh4ih44U50HIZITG/6nvlHTTzRbSQa+Jw4OAW8vs +UCLjQ0fpqT0= +-----END CERTIFICATE----- +)"; + +inline const static std::string SslKey = R"( +-----BEGIN PRIVATE KEY----- +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 +lQREmzyAXi5cEA2KzhcGKK6cfV1ZgUA6 +-----END PRIVATE KEY----- +)"; diff --git a/tests/auto/grpc/client/shared/mockserver/mockserver.cpp b/tests/auto/grpc/client/shared/mockserver/mockserver.cpp new file mode 100644 index 00000000..4b038c46 --- /dev/null +++ b/tests/auto/grpc/client/shared/mockserver/mockserver.cpp @@ -0,0 +1,133 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include "mockserver.h" + +#include +#include + +MockServer::MockServer() = default; +MockServer::~MockServer() +{ + stop(); +}; + +bool MockServer::start(std::vector ports, std::vector services) +{ + if (!transitionState(State::Stopped, State::Starting)) + return false; + + grpc::ServerBuilder builder; + for (auto &p : ports) + builder.AddListeningPort(p.addressUri, p.creds, &p.selectedPort); + for (auto *s : services) + builder.RegisterService(s); + mCQ = builder.AddCompletionQueue(); + mServer = builder.BuildAndStart(); + if (!mServer || !mCQ) { + mState = State::Stopped; + return false; + } + mState = State::Started; + return true; +} + +bool MockServer::stop() +{ + State currentState = mState.load(); + if (currentState == State::Processing) + stopAsyncProcessing(); + + if (currentState != State::Started && currentState != State::Processing) + return currentState == State::Stopped; + + mState = State::ShuttingDown; + + mServer->Shutdown(std::chrono::system_clock::now() + std::chrono::milliseconds(500)); + mCQ->Shutdown(); + + void *drainTag; + bool drainOk; + while (mCQ->Next(&drainTag, &drainOk)) { + if (drainTag) + static_cast(drainTag)->process(drainOk); + } + + mServer.reset(); + mCQ.reset(); + mState = State::Stopped; + + return true; +} + +bool MockServer::processTag(int timeoutMs) +{ + State currentState = mState.load(); + if (currentState != State::Processing && currentState != State::Started) { + return false; + } + + void *rawTag = nullptr; + bool ok = false; + const auto status = timeoutMs < 0 + ? mCQ->AsyncNext(&rawTag, &ok, gpr_inf_future(GPR_CLOCK_REALTIME)) + : mCQ->AsyncNext(&rawTag, &ok, + std::chrono::system_clock::now() + std::chrono::milliseconds(timeoutMs)); + + if (rawTag && status == grpc::CompletionQueue::NextStatus::GOT_EVENT) { + static_cast(rawTag)->process(ok); + return true; + } + + return false; +} + +bool MockServer::startAsyncProcessing(int timeoutMs) +{ + if (!transitionState(State::Started, State::Processing)) + return false; + + mProcessingThread = std::thread([this, timeoutMs] { + while (mState.load(std::memory_order_acquire) == State::Processing) { + processTag(timeoutMs); + } + }); + return true; +} + +bool MockServer::stopAsyncProcessing() +{ + if (!transitionState(State::Processing, State::Started)) + return false; + + if (mProcessingThread.joinable()) { + grpc::Alarm alarm; + // Trigger an event so that the processing loop detects the change. + alarm.Set(mCQ.get(), gpr_now(gpr_clock_type::GPR_CLOCK_REALTIME), new VoidTag()); + mProcessingThread.join(); + } + return true; +} + +MockServer &MockServer::step(int timeoutMs) +{ + mFutures.emplace_back(std::async(std::launch::async, + [this, timeoutMs] { return processTag(timeoutMs); })); + return *this; +} + +bool MockServer::waitForAllSteps() +{ + for (auto &f : mFutures) { + if (!f.get()) + return false; + } + mFutures.clear(); + return true; +} + +bool MockServer::transitionState(State from, State to) +{ + State expected = from; + return mState.compare_exchange_strong(expected, to); +} diff --git a/tests/auto/grpc/client/shared/mockserver/mockserver.h b/tests/auto/grpc/client/shared/mockserver/mockserver.h new file mode 100644 index 00000000..b3ae60f4 --- /dev/null +++ b/tests/auto/grpc/client/shared/mockserver/mockserver.h @@ -0,0 +1,63 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#pragma once + +#include "tags.h" +#include "certificates.h" + +#include +#include + +#include +#include +#include +#include +#include + +struct ListeningPort +{ + std::string addressUri; + std::shared_ptr creds; + int selectedPort = -1; +}; + +class MockServer +{ +public: + enum class State { + Stopped, + Starting, + Started, + Processing, + ShuttingDown, + }; + + MockServer(); + ~MockServer(); + + grpc::ServerCompletionQueue *cq() { return mCQ.get(); } + State state() const { return mState.load(std::memory_order_acquire); } + + bool start(std::vector ports, std::vector services); + bool stop(); + + bool processTag(int timeoutMs = -1); + bool startAsyncProcessing(int timeoutMs = -1); + bool stopAsyncProcessing(); + + void startRpcTag(AbstractRpcTag &tag) { tag.start(mCQ.get()); } + + MockServer &step(int timeoutMs = -1); + bool waitForAllSteps(); + +private: + bool transitionState(State from, State to); + +private: + std::unique_ptr mServer; + std::unique_ptr mCQ; + std::vector> mFutures; + std::thread mProcessingThread; + std::atomic mState = State::Stopped; +}; diff --git a/tests/auto/grpc/client/shared/mockserver/tags.h b/tests/auto/grpc/client/shared/mockserver/tags.h new file mode 100644 index 00000000..47d68fd2 --- /dev/null +++ b/tests/auto/grpc/client/shared/mockserver/tags.h @@ -0,0 +1,84 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#pragma once + +#include +#include + +#include +#include + +class AbstractTag +{ +public: + explicit AbstractTag() = default; + virtual ~AbstractTag() = default; + + AbstractTag(const AbstractTag &) = delete; + AbstractTag &operator=(const AbstractTag &) = delete; + + AbstractTag(AbstractTag &&) = default; + AbstractTag &operator=(AbstractTag &&) = default; + + virtual void process(bool ok) = 0; +}; + +class CallbackTag : public AbstractTag +{ +public: + enum Operation { Proceed, Delete }; + using Function = std::function; + explicit CallbackTag(Function fn) : mFn(std::move(fn)) { } + void process(bool ok) override + { + if (mFn(ok) == Delete) + delete this; + } + +private: + Function mFn; +}; + +class VoidTag final : public CallbackTag +{ +public: + VoidTag() : CallbackTag([](bool) { return Delete; }) { } +}; + +template +class DeleteTag final : public CallbackTag +{ +public: + explicit DeleteTag(Data *data) : CallbackTag([](bool) { return Delete; }), data(data) + { + assert(this->data); + } + ~DeleteTag() { delete data; } + +private: + Data *data; +}; + +class AbstractRpcTag : public AbstractTag +{ +public: + AbstractRpcTag() + { + mContext.AsyncNotifyWhenDone(new CallbackTag([this](bool ok) { + if (ok && mContext.IsCancelled()) + mIsCancelled = true; + return CallbackTag::Delete; + })); + } + + virtual void start(grpc::ServerCompletionQueue *cq) = 0; + + const grpc::ServerContext &context() const { return mContext; } + grpc::ServerContext &context() { return mContext; } + [[nodiscard]] bool isCancelled() const noexcept { return mIsCancelled.load(); } + +private: + std::atomic mIsCancelled = false; + grpc::ServerContext mContext; +};