diff options
Diffstat (limited to 'tests')
-rw-r--r-- | tests/.valgrind.supp | 68 | ||||
-rw-r--r-- | tests/CMakeLists.txt | 71 | ||||
-rw-r--r-- | tests/quotest.cpp | 865 |
3 files changed, 1004 insertions, 0 deletions
diff --git a/tests/.valgrind.supp b/tests/.valgrind.supp new file mode 100644 index 00000000..d65fb52e --- /dev/null +++ b/tests/.valgrind.supp @@ -0,0 +1,68 @@ +{ + libc_dirty_free_on_exit + Memcheck:Free + fun:free + fun:__libc_freeres + fun:_vgnU_freeres + fun:__run_exit_handlers + fun:exit +} + +{ + QAuthenticator + Memcheck:Leak + match-leak-kinds: possible + ... + fun:_ZN14QAuthenticator6detachEv +} + +{ + QTimer + Memcheck:Leak + match-leak-kinds: possible + fun:_Znwm + fun:_ZN7QObjectC1EPS_ + fun:_ZN6QTimerC1EP7QObject +} + +{ + QSslConfiguration + Memcheck:Leak + match-leak-kinds: possible + fun:_Znwm + ... + fun:_ZN17QSslConfigurationC1Ev +} + +{ + libcrypto_ASN1 + Memcheck:Leak + match-leak-kinds: definite + fun:malloc + ... + fun:ASN1_item_ex_d2i +} + +{ + malloc_from_libcrypto + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + fun:CRYPTO_malloc + ... + obj:/lib/x86_64-linux-gnu/libcrypto.so.* +} + +{ + Slot_activation_from_QtNetwork + Memcheck:Leak + match-leak-kinds: definite + fun:malloc + fun:inflateInit2_ + obj:/*/*/*/libQt5Network.so.* + ... + fun:_ZN11QMetaObject8activateEP7QObjectiiPPv + ... + fun:_ZN11QMetaObject8activateEP7QObjectiiPPv + obj:/*/*/*/libQt5Network.so.* +}
\ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..cb8c99f8 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,71 @@ +cmake_minimum_required(VERSION 3.1) + +# This CMakeLists file assumes that the library is installed to CMAKE_INSTALL_PREFIX +# and ignores the in-tree library code. You can use this to start work on your own client. + +project(quotest CXX) + +include(CheckCXXCompilerFlag) +if (NOT WIN32) + include(GNUInstallDirs) +endif(NOT WIN32) + +# Find includes in corresponding build directories +set(CMAKE_INCLUDE_CURRENT_DIR ON) +# Instruct CMake to run moc automatically when needed. +set(CMAKE_AUTOMOC ON) + +# Set a default build type if none was specified +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + message(STATUS "Setting build type to 'Debug' as none was specified") + set(CMAKE_BUILD_TYPE Debug CACHE STRING "Choose the type of build" FORCE) + # Set the possible values of build type for cmake-gui + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Release" + "MinSizeRel" "RelWithDebInfo") +endif() + +if (NOT CMAKE_INSTALL_LIBDIR) + set(CMAKE_INSTALL_LIBDIR ".") +endif() + +if (NOT CMAKE_INSTALL_BINDIR) + set(CMAKE_INSTALL_BINDIR ".") +endif() + +if (NOT CMAKE_INSTALL_INCLUDEDIR) + set(CMAKE_INSTALL_INCLUDEDIR "include") +endif() + +set(CMAKE_CXX_STANDARD 14) + +foreach (FLAG all "" pedantic extra error=return-type no-unused-parameter no-gnu-zero-variadic-macro-arguments) + CHECK_CXX_COMPILER_FLAG("-W${FLAG}" WARN_${FLAG}_SUPPORTED) + if ( WARN_${FLAG}_SUPPORTED AND NOT CMAKE_CXX_FLAGS MATCHES "(^| )-W?${FLAG}($| )") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -W${FLAG}") + endif () +endforeach () + +find_package(Qt5 5.9 REQUIRED Network Gui Multimedia Test) +get_filename_component(Qt5_Prefix "${Qt5_DIR}/../../../.." ABSOLUTE) + +set(LIBRARY_NAME "Quotient") + +find_package(${LIBRARY_NAME} REQUIRED) +get_filename_component(Quotient_Prefix "${Quotient_DIR}/../.." ABSOLUTE) + +message( STATUS "${PROJECT_NAME} configuration:" ) +if (CMAKE_BUILD_TYPE) + message( STATUS " Build type: ${CMAKE_BUILD_TYPE}") +endif(CMAKE_BUILD_TYPE) +message( STATUS " Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}" ) +message( STATUS " Qt: ${Qt5_VERSION} at ${Qt5_Prefix}" ) +message( STATUS " ${LIBRARY_NAME}: ${${LIBRARY_NAME}_VERSION} at ${${LIBRARY_NAME}_Prefix}" ) + +set(example_SRCS quotest.cpp) + +add_executable(${PROJECT_NAME} ${example_SRCS}) +target_link_libraries(${PROJECT_NAME} Qt5::Core Qt5::Test ${LIBRARY_NAME}) + +# Installation + +install (TARGETS ${PROJECT_NAME} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/tests/quotest.cpp b/tests/quotest.cpp new file mode 100644 index 00000000..bcc5a119 --- /dev/null +++ b/tests/quotest.cpp @@ -0,0 +1,865 @@ + +#include "connection.h" +#include "room.h" +#include "user.h" +#include "uriresolver.h" + +#include "csapi/joining.h" +#include "csapi/leaving.h" +#include "csapi/room_send.h" + +#include "events/reactionevent.h" +#include "events/redactionevent.h" +#include "events/simplestateevents.h" + +#include <QtTest/QSignalSpy> +#include <QtCore/QCoreApplication> +#include <QtCore/QFileInfo> +#include <QtCore/QStringBuilder> +#include <QtCore/QTemporaryFile> +#include <QtCore/QTimer> + +#include <functional> +#include <iostream> + +using namespace Quotient; +using std::clog, std::endl; + +class TestSuite; + +class TestManager : public QCoreApplication { +public: + TestManager(int& argc, char** argv); + +private: + void setupAndRun(); + void onNewRoom(Room* r); + void doTests(); + void conclude(); + void finalize(); + +private: + Connection* c = nullptr; + QString origin; + QString targetRoomName; + TestSuite* testSuite = nullptr; + QByteArrayList running {}, succeeded {}, failed {}; +}; + +using TestToken = QByteArray; // return value of QMetaMethod::name +Q_DECLARE_METATYPE(TestToken) + +// For now, the token itself is the test name but that may change. +const char* testName(const TestToken& token) { return token.constData(); } + +/// Test function declaration +/*! + * \return true, if the test finished (successfully or unsuccessfully); + * false, if the test went async and will complete later + */ +#define TEST_DECL(Name) bool Name(const TestToken& thisTest); + +/// The holder for the actual tests +/*! + * This class takes inspiration from Qt Test in terms of tests invocation; + * TestManager instantiates it and runs all public slots (cf. private slots in + * Qt Test) one after another. An important diversion from Qt Test is that + * the tests are assumed to by asynchronous rather than synchronous; so it's + * perfectly normal to have a few tests running at the same time. To avoid + * context clashes a special parameter with the name thisTest is passed to + * each test. Each test must conclude (synchronously or asynchronously) with + * an invocation of FINISH_TEST() macro (or FAIL_TEST() macro that expands to + * FINISH_TEST) that expects thisTest variable to be reachable. If FINISH_TEST() + * is invoked twice with the same thisTest, the second call will cause assertion + * failure; if FINISH_TEST() is not invoked at all, the test will be killed + * by a watchdog after a timeout and marked in the final report as not finished. + */ +class TestSuite : public QObject { + Q_OBJECT +public: + TestSuite(Room* testRoom, QString source, TestManager* parent) + : QObject(parent), targetRoom(testRoom), origin(std::move(source)) + { + qRegisterMetaType<TestToken>(); + Q_ASSERT(testRoom && parent); + } + +signals: + void finishedItem(QByteArray /*name*/, bool /*condition*/); + +public slots: + void doTest(const QByteArray& testName); + +private slots: + TEST_DECL(findRoomByAlias) + TEST_DECL(loadMembers) + TEST_DECL(sendMessage) + TEST_DECL(sendReaction) + TEST_DECL(sendFile) + TEST_DECL(setTopic) + TEST_DECL(changeName) + TEST_DECL(sendAndRedact) + TEST_DECL(addAndRemoveTag) + TEST_DECL(markDirectChat) + TEST_DECL(visitResources) + // Add more tests above here + +public: + [[nodiscard]] Room* room() const { return targetRoom; } + [[nodiscard]] Connection* connection() const + { + return targetRoom->connection(); + } + +private: + [[nodiscard]] bool checkFileSendingOutcome(const TestToken& thisTest, + const QString& txnId, + const QString& fileName); + [[nodiscard]] bool checkRedactionOutcome(const QByteArray& thisTest, + const QString& evtIdToRedact); + + [[nodiscard]] bool validatePendingEvent(const QString& txnId); + [[nodiscard]] bool checkDirectChat() const; + void finishTest(const TestToken& token, bool condition, const char* file, + int line); + +private: + Room* targetRoom; + QString origin; +}; + +#define TEST_IMPL(Name) bool TestSuite::Name(const TestToken& thisTest) + +// Returning true (rather than a void) allows to reuse the convention with +// connectUntil() to break the QMetaObject::Connection upon finishing the test +// item. +#define FINISH_TEST(Condition) \ + return (finishTest(thisTest, Condition, __FILE__, __LINE__), true) + +#define FAIL_TEST() FINISH_TEST(false) + +void TestSuite::doTest(const QByteArray& testName) +{ + clog << "Starting: " << testName.constData() << endl; + QMetaObject::invokeMethod(this, testName, Qt::DirectConnection, + Q_ARG(TestToken, testName)); +} + +bool TestSuite::validatePendingEvent(const QString& txnId) +{ + auto it = targetRoom->findPendingEvent(txnId); + return it != targetRoom->pendingEvents().end() + && it->deliveryStatus() == EventStatus::Submitted + && (*it)->transactionId() == txnId; +} + +void TestSuite::finishTest(const TestToken& token, bool condition, + const char* file, int line) +{ + const auto& item = testName(token); + if (condition) { + clog << item << " successful" << endl; + if (targetRoom) + targetRoom->postMessage(origin % ": " % item % " successful", + MessageEventType::Notice); + } else { + clog << item << " FAILED at " << file << ":" << line << endl; + if (targetRoom) + targetRoom->postPlainText(origin % ": " % item % " FAILED at " + % file % ", line " % QString::number(line)); + } + + emit finishedItem(item, condition); +} + +TestManager::TestManager(int& argc, char** argv) + : QCoreApplication(argc, argv), c(new Connection(this)) +{ + Q_ASSERT(argc >= 5); + clog << "Connecting to Matrix as " << argv[1] << endl; + c->loginWithPassword(argv[1], argv[2], argv[3]); + targetRoomName = argv[4]; + clog << "Test room name: " << argv[4] << endl; + if (argc > 5) { + origin = argv[5]; + clog << "Origin for the test message: " << origin.toStdString() << endl; + } + + connect(c, &Connection::connected, this, &TestManager::setupAndRun); + connect(c, &Connection::resolveError, this, + [](const QString& error) { + clog << "Failed to resolve the server: " << error.toStdString() + << endl; + QCoreApplication::exit(-2); + }, + Qt::QueuedConnection); + connect(c, &Connection::loginError, this, + [this](const QString& message, const QString& details) { + clog << "Failed to login to " + << c->homeserver().toDisplayString().toStdString() << ": " + << message.toStdString() << endl + << "Details:" << endl + << details.toStdString() << endl; + QCoreApplication::exit(-2); + }, + Qt::QueuedConnection); + connect(c, &Connection::loadedRoomState, this, &TestManager::onNewRoom); + + // Big countdown watchdog + QTimer::singleShot(180000, this, [this] { + if (testSuite) + conclude(); + else + finalize(); + }); +} + +void TestManager::setupAndRun() +{ + Q_ASSERT(!c->homeserver().isEmpty() && c->homeserver().isValid()); + Q_ASSERT(c->domain() == c->userId().section(':', 1)); + clog << "Connected, server: " + << c->homeserver().toDisplayString().toStdString() << endl; + clog << "Access token: " << c->accessToken().toStdString() << endl; + + c->setLazyLoading(true); + + clog << "Joining " << targetRoomName.toStdString() << endl; + auto joinJob = c->joinRoom(targetRoomName); + // Ensure that the room has been joined and filled with some events + // so that other tests could use that + connect(joinJob, &BaseJob::success, this, [this, joinJob] { + testSuite = new TestSuite(c->room(joinJob->roomId()), origin, this); + // Only start the sync after joining, to make sure the room just + // joined is in it + c->syncLoop(); + connect(c, &Connection::syncDone, this, [this] { + static int i = 0; + clog << "Sync " << ++i << " complete" << endl; + if (auto* r = testSuite->room()) { + clog << "Test room timeline size = " << r->timelineSize(); + if (r->pendingEvents().empty()) + clog << ", pending size = " << r->pendingEvents().size(); + clog << endl; + } + if (!running.empty()) { + clog << running.size() << " test(s) in the air:"; + for (const auto& test: qAsConst(running)) + clog << " " << testName(test); + clog << endl; + } + if (i == 1) { + testSuite->room()->getPreviousContent(); + connectSingleShot(testSuite->room(), &Room::addedMessages, this, + &TestManager::doTests); + } + }); + }); + connect(joinJob, &BaseJob::failure, this, [this] { + clog << "Failed to join the test room" << endl; + finalize(); + }); +} + +void TestManager::onNewRoom(Room* r) +{ + clog << "New room: " << r->id().toStdString() << endl + << " Name: " << r->name().toStdString() << endl + << " Canonical alias: " << r->canonicalAlias().toStdString() << endl + << endl; + connect(r, &Room::aboutToAddNewMessages, r, [r](RoomEventsRange timeline) { + clog << timeline.size() << " new event(s) in room " + << r->objectName().toStdString() << endl; + }); +} + +void TestManager::doTests() +{ + const auto* metaObj = testSuite->metaObject(); + for (auto i = metaObj->methodOffset(); i < metaObj->methodCount(); ++i) { + const auto metaMethod = metaObj->method(i); + if (metaMethod.access() != QMetaMethod::Private + || metaMethod.methodType() != QMetaMethod::Slot) + continue; + + const auto testName = metaMethod.name(); + running.push_back(testName); + // Some tests return the result immediately but we queue everything + // and process all tests asynchronously. + QMetaObject::invokeMethod(testSuite, "doTest", Qt::QueuedConnection, + Q_ARG(QByteArray, testName)); + } + clog << "Tests to do:"; + for (const auto& test: qAsConst(running)) + clog << " " << testName(test); + clog << endl; + connect(testSuite, &TestSuite::finishedItem, this, + [this](const QByteArray& itemName, bool condition) { + if (auto i = running.indexOf(itemName); i != -1) + (condition ? succeeded : failed).push_back(running.takeAt(i)); + else + Q_ASSERT_X(false, itemName, + "Test item is not in running state"); + if (running.empty()) { + clog << "All tests finished" << endl; + conclude(); + } + }); +} + +TEST_IMPL(findRoomByAlias) +{ + auto* roomByAlias = connection()->roomByAlias(targetRoom->canonicalAlias(), + JoinState::Join); + FINISH_TEST(roomByAlias == targetRoom); +} + +TEST_IMPL(loadMembers) +{ + // It's not exactly correct because an arbitrary server might not support + // lazy loading; but in the absence of capabilities framework we assume + // it does. + if (targetRoom->users().size() >= targetRoom->joinedCount()) { + clog << "Lazy loading doesn't seem to be enabled" << endl; + FAIL_TEST(); + } + targetRoom->setDisplayed(); + connect(targetRoom, &Room::allMembersLoaded, this, [this, thisTest] { + FINISH_TEST(targetRoom->users().size() >= targetRoom->joinedCount()); + }); + return false; +} + +TEST_IMPL(sendMessage) +{ + auto txnId = targetRoom->postPlainText("Hello, " % origin % " is here"); + if (!validatePendingEvent(txnId)) { + clog << "Invalid pending event right after submitting" << endl; + FAIL_TEST(); + } + connectUntil(targetRoom, &Room::pendingEventAboutToMerge, this, + [this, thisTest, txnId](const RoomEvent* evt, int pendingIdx) { + const auto& pendingEvents = targetRoom->pendingEvents(); + Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); + + if (evt->transactionId() != txnId) + return false; + + FINISH_TEST(is<RoomMessageEvent>(*evt) && !evt->id().isEmpty() + && pendingEvents[size_t(pendingIdx)]->transactionId() + == evt->transactionId()); + }); + return false; +} + +TEST_IMPL(sendReaction) +{ + clog << "Reacting to the newest message in the room" << endl; + Q_ASSERT(targetRoom->timelineSize() > 0); + const auto targetEvtId = targetRoom->messageEvents().back()->id(); + const auto key = QStringLiteral("+1"); + const auto txnId = targetRoom->postReaction(targetEvtId, key); + if (!validatePendingEvent(txnId)) { + clog << "Invalid pending event right after submitting" << endl; + FAIL_TEST(); + } + + connectUntil(targetRoom, &Room::updatedEvent, this, + [this, thisTest, txnId, key, targetEvtId](const QString& actualTargetEvtId) { + if (actualTargetEvtId != targetEvtId) + return false; + const auto reactions = targetRoom->relatedEvents( + targetEvtId, EventRelation::Annotation()); + // It's a test room, assuming no interference there should + // be exactly one reaction + if (reactions.size() != 1) + FAIL_TEST(); + + const auto* evt = + eventCast<const ReactionEvent>(reactions.back()); + FINISH_TEST(is<ReactionEvent>(*evt) && !evt->id().isEmpty() + && evt->relation().key == key + && evt->transactionId() == txnId); + // TODO: Test removing the reaction + }); + return false; +} + +TEST_IMPL(sendFile) +{ + auto* tf = new QTemporaryFile; + if (!tf->open()) { + clog << "Failed to create a temporary file" << endl; + FAIL_TEST(); + } + tf->write("Test"); + tf->close(); + // QFileInfo::fileName brings only the file name; QFile::fileName brings + // the full path + const auto tfName = QFileInfo(*tf).fileName(); + clog << "Sending file " << tfName.toStdString() << endl; + const auto txnId = + targetRoom->postFile("Test file", QUrl::fromLocalFile(tf->fileName())); + if (!validatePendingEvent(txnId)) { + clog << "Invalid pending event right after submitting" << endl; + delete tf; + FAIL_TEST(); + } + + // Using tf as a context object to clean away both connections + // once either of them triggers. + connectUntil(targetRoom, &Room::fileTransferCompleted, tf, + [this, thisTest, txnId, tf, tfName](const QString& id) { + auto fti = targetRoom->fileTransferInfo(id); + Q_ASSERT(fti.status == FileTransferInfo::Completed); + + if (id != txnId) + return false; + + tf->deleteLater(); + return checkFileSendingOutcome(thisTest, txnId, tfName); + }); + connectUntil(targetRoom, &Room::fileTransferFailed, tf, + [this, thisTest, txnId, tf](const QString& id, const QString& error) { + if (id != txnId) + return false; + + targetRoom->postPlainText(origin % ": File upload failed: " % error); + tf->deleteLater(); + FAIL_TEST(); + }); + return false; +} + +bool TestSuite::checkFileSendingOutcome(const TestToken& thisTest, + const QString& txnId, + const QString& fileName) +{ + auto it = targetRoom->findPendingEvent(txnId); + if (it == targetRoom->pendingEvents().end()) { + clog << "Pending file event dropped before upload completion" << endl; + FAIL_TEST(); + } + if (it->deliveryStatus() != EventStatus::FileUploaded) { + clog << "Pending file event status upon upload completion is " + << it->deliveryStatus() << " != FileUploaded(" + << EventStatus::FileUploaded << ')' << endl; + FAIL_TEST(); + } + + connectUntil(targetRoom, &Room::pendingEventAboutToMerge, this, + [this, thisTest, txnId, fileName](const RoomEvent* evt, int pendingIdx) { + const auto& pendingEvents = targetRoom->pendingEvents(); + Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); + + if (evt->transactionId() != txnId) + return false; + + clog << "File event " << txnId.toStdString() + << " arrived in the timeline" << endl; + // This part tests visit() + return visit( + *evt, + [&](const RoomMessageEvent& e) { + // TODO: actually try to download it to check, e.g., #366 + // (and #368 would help to test against bad file names). + FINISH_TEST( + !e.id().isEmpty() + && pendingEvents[size_t(pendingIdx)]->transactionId() + == txnId + && e.hasFileContent() + && e.content()->fileInfo()->originalName == fileName); + }, + [this, thisTest](const RoomEvent&) { FAIL_TEST(); }); + }); + return true; +} + +TEST_IMPL(setTopic) +{ + const auto newTopic = connection()->generateTxnId(); // Just a way to make + // a unique id + targetRoom->setTopic(newTopic); + connectUntil(targetRoom, &Room::topicChanged, this, + [this, thisTest, newTopic] { + if (targetRoom->topic() == newTopic) + FINISH_TEST(true); + + clog << "Requested topic was " << newTopic.toStdString() << ", " + << targetRoom->topic().toStdString() << " arrived instead" + << endl; + return false; + }); + return false; +} + +TEST_IMPL(changeName) +{ + auto* const localUser = connection()->user(); + const auto& newName = connection()->generateTxnId(); // See setTopic() + clog << "Renaming the user to " << newName.toStdString() << endl; + localUser->rename(newName); + connectUntil(localUser, &User::nameChanged, this, + [this, thisTest, newName](const QString& emittedName, QString, + const Room* r) { + if (r != nullptr) + return false; + FINISH_TEST(emittedName == newName); + }); + return false; +} + +TEST_IMPL(sendAndRedact) +{ + clog << "Sending a message to redact" << endl; + auto txnId = targetRoom->postPlainText(origin % ": message to redact"); + if (txnId.isEmpty()) + FAIL_TEST(); + + connectUntil(targetRoom, &Room::messageSent, this, + [this, thisTest, txnId](const QString& tId, const QString& evtId) { + if (tId != txnId) + return false; + + // The event may end up having been merged, and that's ok; + // but if it's not, it has to be in the ReachedServer state. + if (auto it = room()->findPendingEvent(tId); + it != room()->pendingEvents().cend() + && it->deliveryStatus() != EventStatus::ReachedServer) { + clog << "Incorrect sent event status (" + << it->deliveryStatus() << ')' << endl; + FAIL_TEST(); + } + + clog << "Redacting the message" << endl; + targetRoom->redactEvent(evtId, origin); + connectUntil(targetRoom, &Room::addedMessages, this, + [this, thisTest, evtId] { + return checkRedactionOutcome(thisTest, evtId); + }); + return false; + }); + return false; +} + +bool TestSuite::checkRedactionOutcome(const QByteArray& thisTest, + const QString& evtIdToRedact) +{ + // There are two possible (correct) outcomes: either the event comes already + // redacted at the next sync, or the nearest sync completes with + // the unredacted event but the next one brings redaction. + auto it = targetRoom->findInTimeline(evtIdToRedact); + if (it == targetRoom->timelineEdge()) + return false; // Waiting for the next sync + + if ((*it)->isRedacted()) { + clog << "The sync brought already redacted message" << endl; + FINISH_TEST(true); + } + + clog << "Message came non-redacted with the sync, waiting for redaction" + << endl; + connectUntil(targetRoom, &Room::replacedEvent, this, + [this, thisTest, evtIdToRedact](const RoomEvent* newEvent, + const RoomEvent* oldEvent) { + if (oldEvent->id() != evtIdToRedact) + return false; + + FINISH_TEST(newEvent->isRedacted() + && newEvent->redactionReason() == origin); + }); + return true; +} + +TEST_IMPL(addAndRemoveTag) +{ + static const auto TestTag = QStringLiteral("im.quotient.test"); + // Pre-requisite + if (targetRoom->tags().contains(TestTag)) + targetRoom->removeTag(TestTag); + + // Unlike for most of Quotient, tags are applied and tagsChanged is emitted + // synchronously, with the server being notified async. The test checks + // that the signal is emitted, not only that tags have changed; but there's + // (currently) no way to check that the server has been correctly notified + // of the tag change. + QSignalSpy spy(targetRoom, &Room::tagsChanged); + targetRoom->addTag(TestTag); + if (spy.count() != 1 || !targetRoom->tags().contains(TestTag)) { + clog << "Tag adding failed" << endl; + FAIL_TEST(); + } + clog << "Test tag set, removing it now" << endl; + targetRoom->removeTag(TestTag); + FINISH_TEST(spy.count() == 2 && !targetRoom->tags().contains(TestTag)); +} + +bool TestSuite::checkDirectChat() const +{ + return targetRoom->directChatUsers().contains(connection()->user()); +} + +TEST_IMPL(markDirectChat) +{ + if (checkDirectChat()) + connection()->removeFromDirectChats(targetRoom->id(), + connection()->user()); + + int id = qRegisterMetaType<DirectChatsMap>(); // For QSignalSpy + Q_ASSERT(id != -1); + + // Same as with tags (and unusual for the rest of Quotient), direct chat + // operations are synchronous. + QSignalSpy spy(connection(), &Connection::directChatsListChanged); + clog << "Marking the room as a direct chat" << endl; + connection()->addToDirectChats(targetRoom, connection()->user()); + if (spy.count() != 1 || !checkDirectChat()) + FAIL_TEST(); + + // Check that the first argument (added DCs) actually contains the room + const auto& addedDCs = spy.back().front().value<DirectChatsMap>(); + if (addedDCs.size() != 1 + || !addedDCs.contains(connection()->user(), targetRoom->id())) { + clog << "The room is not in added direct chats" << endl; + FAIL_TEST(); + } + + clog << "Unmarking the direct chat" << endl; + connection()->removeFromDirectChats(targetRoom->id(), connection()->user()); + if (spy.count() != 2 && checkDirectChat()) + FAIL_TEST(); + + // Check that the second argument (removed DCs) actually contains the room + const auto& removedDCs = spy.back().back().value<DirectChatsMap>(); + FINISH_TEST(removedDCs.size() == 1 + && removedDCs.contains(connection()->user(), targetRoom->id())); +} + +TEST_IMPL(visitResources) +{ + // Same as the two tests above, ResourceResolver emits signals + // synchronously so we use signal spies to intercept them instead of + // connecting lambdas before calling openResource(). NB: this test + // assumes that ResourceResolver::openResource is implemented in terms + // of ResourceResolver::visitResource, so the latter doesn't need a + // separate test. + static UriDispatcher ud; + + // This lambda returns true in case of error, false if it's fine so far + auto testResourceResolver = [this, thisTest](const QStringList& uris, + auto signal, auto* target, + QVariantList otherArgs = {}) { + int r = qRegisterMetaType<decltype(target)>(); + Q_ASSERT(r != 0); + QSignalSpy spy(&ud, signal); + for (const auto& uriString: uris) { + Uri uri { uriString }; + clog << "Checking " << uriString.toStdString() + << " -> " << uri.toDisplayString().toStdString() << endl; + if (auto matrixToUrl = uri.toUrl(Uri::MatrixToUri).toDisplayString(); + !matrixToUrl.startsWith("https://matrix.to/#/")) { + clog << "Incorrect matrix.to representation:" + << matrixToUrl.toStdString() << endl; + } + ud.visitResource(connection(), uriString); + if (spy.count() != 1) { + clog << "Wrong number of signal emissions (" << spy.count() + << ')' << endl; + FAIL_TEST(); + } + const auto& emission = spy.front(); + Q_ASSERT(emission.count() >= 2); + if (emission.front().value<decltype(target)>() != target) { + clog << "Signal emitted with an incorrect target" << endl; + FAIL_TEST(); + } + if (!otherArgs.empty()) { + if (emission.size() < otherArgs.size() + 1) { + clog << "Emission doesn't include all arguments" << endl; + FAIL_TEST(); + } + for (auto i = 0; i < otherArgs.size(); ++i) + if (otherArgs[i] != emission[i + 1]) { + clog << "Mismatch in argument #" << i + 1 << endl; + FAIL_TEST(); + } + } + spy.clear(); + } + return false; + }; + + // Basic tests + for (const auto& u: { Uri {}, Uri { QUrl {} } }) + if (u.isValid() || !u.isEmpty()) { + clog << "Empty Matrix URI test failed" << endl; + FAIL_TEST(); + } + if (Uri { QStringLiteral("#") }.isValid()) { + clog << "Bare sigil URI test failed" << endl; + FAIL_TEST(); + } + QUrl invalidUrl { "https://" }; + invalidUrl.setAuthority("---:@@@"); + const Uri matrixUriFromInvalidUrl { invalidUrl }, + invalidMatrixUri { QStringLiteral("matrix:&invalid@") }; + if (matrixUriFromInvalidUrl.isEmpty() || matrixUriFromInvalidUrl.isValid()) { + clog << "Invalid Matrix URI test failed" << endl; + FAIL_TEST(); + } + if (invalidMatrixUri.isEmpty() || invalidMatrixUri.isValid()) { + clog << "Invalid sigil in a Matrix URI - test failed" << endl; + FAIL_TEST(); + } + + // Matrix identifiers used throughout all URI tests + const auto& roomId = room()->id(); + const auto& roomAlias = room()->canonicalAlias(); + const auto& userId = connection()->userId(); + const auto& eventId = room()->messageEvents().back()->id(); + Q_ASSERT(!roomId.isEmpty()); + Q_ASSERT(!roomAlias.isEmpty()); + Q_ASSERT(!userId.isEmpty()); + Q_ASSERT(!eventId.isEmpty()); + + const QStringList roomUris { + roomId, "matrix:roomid/" + roomId.mid(1), + "https://matrix.to/#/%21"/*`!`*/ + roomId.mid(1), + roomAlias, "matrix:room/" + roomAlias.mid(1), + "https://matrix.to/#/" + roomAlias, + }; + const QStringList userUris { userId, "matrix:user/" + userId.mid(1), + "https://matrix.to/#/" + userId }; + const QStringList eventUris { + "matrix:room/" + roomAlias.mid(1) + "/event/" + eventId.mid(1), + "https://matrix.to/#/" + roomId + '/' + eventId + }; + // Check that reserved characters are correctly processed. + static const auto& joinRoomAlias = + QStringLiteral("##/?.@\"unjoined:example.org"); + static const auto& encodedRoomAliasNoSigil = + QUrl::toPercentEncoding(joinRoomAlias.mid(1), ":"); + static const QString joinQuery { "?action=join" }; + // These URIs are not supposed to be actually joined (and even exist, + // as yet) - only to be syntactically correct + static const QStringList joinByAliasUris { + Uri(joinRoomAlias.toUtf8(), {}, joinQuery.mid(1)).toDisplayString(), + "matrix:room/" + encodedRoomAliasNoSigil + joinQuery, + "https://matrix.to/#/%23"/*`#`*/ + encodedRoomAliasNoSigil + joinQuery, + "https://matrix.to/#/%23" + joinRoomAlias.mid(1) /* unencoded */ + joinQuery + }; + static const auto& joinRoomId = QStringLiteral("!anyid:example.org"); + static const QStringList viaServers { "matrix.org", "example.org" }; + static const auto viaQuery = + std::accumulate(viaServers.cbegin(), viaServers.cend(), joinQuery, + [](const QString& q, const QString& s) { + return q + "&via=" + s; + }); + static const QStringList joinByIdUris { + "matrix:roomid/" + joinRoomId.mid(1) + viaQuery, + "https://matrix.to/#/" + joinRoomId + viaQuery + }; + // If any test breaks, the breaking call will return true, and further + // execution will be cut by ||'s short-circuiting + if (testResourceResolver(roomUris, &UriDispatcher::roomAction, room()) + || testResourceResolver(userUris, &UriDispatcher::userAction, + connection()->user()) + || testResourceResolver(eventUris, &UriDispatcher::roomAction, + room(), { eventId }) + || testResourceResolver(joinByAliasUris, &UriDispatcher::joinAction, + connection(), { joinRoomAlias }) + || testResourceResolver(joinByIdUris, &UriDispatcher::joinAction, + connection(), { joinRoomId, viaServers })) + return true; + // TODO: negative cases + FINISH_TEST(true); +} + +void TestManager::conclude() +{ + // Clean up the room (best effort) + auto* room = testSuite->room(); + room->setTopic({}); + room->localUser()->rename({}); + + QString succeededRec { QString::number(succeeded.size()) % " of " + % QString::number(succeeded.size() + failed.size() + + running.size()) + % " tests succeeded" }; + QString plainReport = origin % ": Testing complete, " % succeededRec; + QString color = failed.empty() && running.empty() ? "00AA00" : "AA0000"; + QString htmlReport = origin % ": <strong><font data-mx-color='#" % color + % "' color='#" % color + % "'>Testing complete</font></strong>, " % succeededRec; + if (!failed.empty()) { + QByteArray failedList; + for (const auto& f : qAsConst(failed)) + failedList += ' ' + f; + plainReport += "\nFAILED:" + failedList; + htmlReport += "<br><strong>Failed:</strong>" + failedList; + } + if (!running.empty()) { + QByteArray dnfList; + for (const auto& r : qAsConst(running)) + dnfList += ' ' + r; + plainReport += "\nDID NOT FINISH:" + dnfList; + htmlReport += "<br><strong>Did not finish:</strong>" + dnfList; + } + + auto txnId = room->postHtmlText(plainReport, htmlReport); + // Now just wait until all the pending events reach the server + connectUntil(room, &Room::messageSent, this, + [this, txnId, room, plainReport] (const QString& sentTxnId) { + if (sentTxnId != txnId) + return false; + const auto& pendingEvents = room->pendingEvents(); + if (auto c = std::count_if(pendingEvents.cbegin(), + pendingEvents.cend(), + [](const PendingEventItem& pe) { + return pe.deliveryStatus() + < EventStatus::ReachedServer; + }); + c > 0) { + clog << "Events to reach the server: " << c + << ", not leaving yet" << endl; + return false; + } + + clog << "Leaving the room" << endl; + // TODO: Waiting for proper futures to come so that it could be: +// room->leaveRoom() +// .then(this, &TestManager::finalize); // Qt-style or +// .then([this] { finalize(); }); // STL-style + auto* job = room->leaveRoom(); + connect(job, &BaseJob::result, this, [this, job,plainReport] { + Q_ASSERT(job->status().good()); + finalize(); + // Still flying, as the exit() connection in finalize() is queued + clog << plainReport.toStdString() << endl; + }); + return true; + }); +} + +void TestManager::finalize() +{ + clog << "Logging out" << endl; + c->logout(); + connect(c, &Connection::loggedOut, this, + [this] { QCoreApplication::exit(failed.size() + running.size()); }, + Qt::QueuedConnection); +} + +int main(int argc, char* argv[]) +{ + // TODO: use QCommandLineParser + if (argc < 5) { + clog << "Usage: quotest <user> <passwd> <device_name> <room_alias> [origin]" + << endl; + return -1; + } + // NOLINTNEXTLINE(readability-static-accessed-through-instance) + return TestManager(argc, argv).exec(); +} + +#include "quotest.moc" |