diff options
-rw-r--r-- | CMakeLists.txt | 2 | ||||
-rw-r--r-- | lib/quotient_common.h | 14 | ||||
-rw-r--r-- | lib/uri.cpp | 191 | ||||
-rw-r--r-- | lib/uri.h | 85 | ||||
-rw-r--r-- | lib/uriresolver.cpp | 112 | ||||
-rw-r--r-- | lib/uriresolver.h | 168 | ||||
-rw-r--r-- | libquotient.pri | 4 | ||||
-rw-r--r-- | tests/quotest.cpp | 178 |
8 files changed, 724 insertions, 30 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 5b6410f1..808899ba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -149,6 +149,8 @@ set(lib_SRCS lib/room.cpp lib/user.cpp lib/avatar.cpp + lib/uri.cpp + lib/uriresolver.cpp lib/syncdata.cpp lib/settings.cpp lib/networksettings.cpp diff --git a/lib/quotient_common.h b/lib/quotient_common.h index 44541b42..bb05af05 100644 --- a/lib/quotient_common.h +++ b/lib/quotient_common.h @@ -14,15 +14,15 @@ enum RunningPolicy { ForegroundRequest = 0x0, BackgroundRequest = 0x1 }; Q_ENUM_NS(RunningPolicy) -enum ResourceResolveResult : short { +enum UriResolveResult : short { StillResolving = -1, - Resolved = 0, - UnknownMatrixId, - MalformedMatrixId, - NoAccount, - EmptyMatrixId + UriResolved = 0, + CouldNotResolve, + IncorrectAction, + InvalidUri, + NoAccount }; -Q_ENUM_NS(ResourceResolveResult) +Q_ENUM_NS(UriResolveResult) } // namespace Quotient /// \deprecated Use namespace Quotient instead diff --git a/lib/uri.cpp b/lib/uri.cpp new file mode 100644 index 00000000..f813794c --- /dev/null +++ b/lib/uri.cpp @@ -0,0 +1,191 @@ +#include "uri.h" + +#include "logging.h" + +#include <QtCore/QRegularExpression> + +using namespace Quotient; + +struct ReplacePair { QByteArray uriString; char sigil; }; +/// Defines bi-directional mapping of path prefixes and sigils +static const auto replacePairs = { + ReplacePair { "user/", '@' }, + { "roomid/", '!' }, + { "room/", '#' }, + // The notation for bare event ids is not proposed in MSC2312 but there's + // https://github.com/matrix-org/matrix-doc/pull/2644 + { "event/", '$' } +}; + +Uri::Uri(QByteArray primaryId, QByteArray secondaryId, QString query) +{ + if (primaryId.isEmpty()) + primaryType_ = Empty; + else { + setScheme("matrix"); + QString pathToBe; + primaryType_ = Invalid; + if (primaryId.size() < 2) // There should be something after sigil + return; + for (const auto& p: replacePairs) + if (primaryId[0] == p.sigil) { + primaryType_ = Type(p.sigil); + pathToBe = p.uriString + primaryId.mid(1); + break; + } + if (!secondaryId.isEmpty()) { + if (secondaryId.size() < 2) { + primaryType_ = Invalid; + return; + } + pathToBe += "/event/" + secondaryId.mid(1); + } + setPath(pathToBe); + } + setQuery(std::move(query)); +} + +Uri::Uri(QUrl url) : QUrl(std::move(url)) +{ + // NB: don't try to use `url` from here on, it's moved-from and empty + if (isEmpty()) + return; // primaryType_ == Empty + + if (!QUrl::isValid()) { // MatrixUri::isValid() checks primaryType_ + primaryType_ = Invalid; + return; + } + + if (scheme() == "matrix") { + // Check sanity as per https://github.com/matrix-org/matrix-doc/pull/2312 + const auto& urlPath = path(); + const auto& splitPath = urlPath.splitRef('/'); + switch (splitPath.size()) { + case 2: + break; + case 4: + if (splitPath[2] == "event") + break; + [[fallthrough]]; + default: + return; // Invalid + } + + for (const auto& p: replacePairs) + if (urlPath.startsWith(p.uriString)) { + primaryType_ = Type(p.sigil); + return; // The only valid return path for matrix: URIs + } + qCDebug(MAIN) << "The matrix: URI is not recognised:" + << toDisplayString(); + return; + } + + primaryType_ = NonMatrix; // Default, unless overridden by the code below + if (scheme() == "https" && authority() == "matrix.to") { + // See https://matrix.org/docs/spec/appendices#matrix-to-navigation + static const QRegularExpression MatrixToUrlRE { + R"(^/(?<main>[^/?]+)(/(?<sec>[^?]+))?(\?(?<query>.+))?$)" + }; + // matrix.to accepts both literal sigils (as well as & and ? used in + // its "query" substitute) and their %-encoded forms; + // so force QUrl to decode everything. + auto f = fragment(QUrl::FullyDecoded); + if (auto&& m = MatrixToUrlRE.match(f); m.hasMatch()) + *this = Uri { m.captured("main").toUtf8(), + m.captured("sec").toUtf8(), m.captured("query") }; + } +} + +Uri::Uri(const QString& uriOrId) : Uri(fromUserInput(uriOrId)) {} + +Uri Uri::fromUserInput(const QString& uriOrId) +{ + if (uriOrId.isEmpty()) + return {}; // type() == None + + // A quick check if uriOrId is a plain Matrix id + // Bare event ids cannot be resolved without a room scope as per the current + // spec but there's a movement towards making them navigable (see, e.g., + // https://github.com/matrix-org/matrix-doc/pull/2644) - so treat them + // as valid + if (QStringLiteral("!@#+$").contains(uriOrId[0])) + return Uri { uriOrId.toUtf8() }; + + return Uri { QUrl::fromUserInput(uriOrId) }; +} + +Uri::Type Uri::type() const { return primaryType_; } + +Uri::SecondaryType Uri::secondaryType() const +{ + return path().section('/', 2, 2) == "event" ? EventId : NoSecondaryId; +} + +QUrl Uri::toUrl(UriForm form) const +{ + if (!isValid()) + return {}; + + if (form == CanonicalUri || type() == NonMatrix) + return *this; + + QUrl url; + url.setScheme("https"); + url.setHost("matrix.to"); + url.setPath("/"); + auto fragment = primaryId(); + if (const auto& secId = secondaryId(); !secId.isEmpty()) + fragment += '/' + secId; + if (const auto& q = query(); !q.isEmpty()) + fragment += '?' + q; + url.setFragment(fragment); + return url; +} + +QString Uri::primaryId() const +{ + if (primaryType_ == Empty || primaryType_ == Invalid) + return {}; + + const auto& idStem = path().section('/', 1, 1); + return idStem.isEmpty() ? idStem : primaryType_ + idStem; +} + +QString Uri::secondaryId() const +{ + const auto& idStem = path().section('/', 3); + return idStem.isEmpty() ? idStem : secondaryType() + idStem; +} + +static const auto ActionKey = QStringLiteral("action"); + +QString Uri::action() const +{ + return type() == NonMatrix || !isValid() + ? QString() + : QUrlQuery { query() }.queryItemValue(ActionKey); +} + +void Uri::setAction(const QString& newAction) +{ + if (!isValid()) { + qCWarning(MAIN) << "Cannot set an action on an invalid Quotient::Uri"; + return; + } + QUrlQuery q { query() }; + q.removeQueryItem(ActionKey); + q.addQueryItem(ActionKey, newAction); + setQuery(q); +} + +QStringList Uri::viaServers() const +{ + return QUrlQuery { query() }.allQueryItemValues(QStringLiteral("via"), + QUrl::EncodeReserved); +} + +bool Uri::isValid() const +{ + return primaryType_ != Empty && primaryType_ != Invalid; +} diff --git a/lib/uri.h b/lib/uri.h new file mode 100644 index 00000000..270766dd --- /dev/null +++ b/lib/uri.h @@ -0,0 +1,85 @@ +#pragma once + +#include "quotient_common.h" + +#include <QtCore/QUrl> +#include <QtCore/QUrlQuery> + +namespace Quotient { + +/*! \brief A wrapper around a Matrix URI or identifier + * + * This class encapsulates a Matrix resource identifier, passed in either of + * 3 forms: a plain Matrix identifier (sigil, localpart, serverpart or, for + * modern event ids, sigil and base64 hash); an MSC2312 URI (aka matrix: URI); + * or a matrix.to URL. The input can be either encoded (serverparts with + * punycode, the rest with percent-encoding) or unencoded (in this case it is + * the caller's responsibility to resolve all possible ambiguities). + * + * The class provides functions to check the validity of the identifier, + * its type, and obtain components, also in either unencoded (for displaying) + * or encoded (for APIs) form. + */ +class Uri : private QUrl { + Q_GADGET +public: + enum Type : char { + Invalid = char(-1), + Empty = 0x0, + UserId = '@', + RoomId = '!', + RoomAlias = '#', + Group = '+', + BareEventId = '$', // https://github.com/matrix-org/matrix-doc/pull/2644 + NonMatrix = ':' + }; + Q_ENUM(Type) + enum SecondaryType : char { NoSecondaryId = 0x0, EventId = '$' }; + Q_ENUM(SecondaryType) + + enum UriForm : short { CanonicalUri, MatrixToUri }; + Q_ENUM(UriForm) + + /// Construct an empty Matrix URI + Uri() = default; + /*! \brief Decode a user input string to a Matrix identifier + * + * Accepts plain Matrix ids, MSC2312 URIs (aka matrix: URIs) and + * matrix.to URLs. In case of URIs/URLs, it uses QUrl's TolerantMode + * parser to decode common mistakes/irregularities (see QUrl documentation + * for more details). + */ + Uri(const QString& uriOrId); + + /// Construct a Matrix URI from components + explicit Uri(QByteArray primaryId, QByteArray secondaryId = {}, + QString query = {}); + /// Construct a Matrix URI from matrix.to or MSC2312 (matrix:) URI + explicit Uri(QUrl url); + + static Uri fromUserInput(const QString& uriOrId); + static Uri fromUrl(QUrl url); + + /// Get the primary type of the Matrix URI (user id, room id or alias) + /*! Note that this does not include an event as a separate type, since + * events can only be addressed inside of rooms, which, in turn, are + * addressed either by id or alias. If you need to check whether the URI + * is specifically an event URI, use secondaryType() instead. + */ + Q_INVOKABLE Type type() const; + Q_INVOKABLE SecondaryType secondaryType() const; + Q_INVOKABLE QUrl toUrl(UriForm form = CanonicalUri) const; + Q_INVOKABLE QString primaryId() const; + Q_INVOKABLE QString secondaryId() const; + Q_INVOKABLE QString action() const; + Q_INVOKABLE void setAction(const QString& newAction); + Q_INVOKABLE QStringList viaServers() const; + Q_INVOKABLE bool isValid() const; + using QUrl::path, QUrl::query, QUrl::fragment; + using QUrl::isEmpty, QUrl::toDisplayString; + +private: + Type primaryType_ = Empty; +}; + +} diff --git a/lib/uriresolver.cpp b/lib/uriresolver.cpp new file mode 100644 index 00000000..ec30512c --- /dev/null +++ b/lib/uriresolver.cpp @@ -0,0 +1,112 @@ +#include "uriresolver.h" + +#include "connection.h" +#include "user.h" + +using namespace Quotient; + +UriResolveResult UriResolverBase::visitResource(Connection* account, + const Uri& uri) +{ + switch (uri.type()) { + case Uri::NonMatrix: + return visitNonMatrix(uri.toUrl()) ? UriResolved : CouldNotResolve; + case Uri::Invalid: + case Uri::Empty: + return InvalidUri; + default:; + } + + if (!account) + return NoAccount; + + switch (uri.type()) { + case Uri::UserId: { + if (uri.action() == "join") + return IncorrectAction; + auto* user = account->user(uri.primaryId()); + Q_ASSERT(user != nullptr); + return visitUser(user, uri.action()); + } + case Uri::RoomId: + case Uri::RoomAlias: { + auto* room = uri.type() == Uri::RoomId + ? account->room(uri.primaryId()) + : account->roomByAlias(uri.primaryId()); + if (room != nullptr) { + visitRoom(room, uri.secondaryId()); + return UriResolved; + } + if (uri.action() == "join") { + joinRoom(account, uri.primaryId(), uri.viaServers()); + return UriResolved; + } + [[fallthrough]]; + } + default: + return CouldNotResolve; + } +} + +// This template is only instantiated once, for Quotient::visitResource() +template <typename... FnTs> +class StaticUriDispatcher : public UriResolverBase { +public: + StaticUriDispatcher(const FnTs&... fns) : fns_(fns...) {} + +private: + UriResolveResult visitUser(User* user, const QString& action) override + { + return std::get<0>(fns_)(user, action); + } + void visitRoom(Room* room, const QString& eventId) override + { + std::get<1>(fns_)(room, eventId); + } + void joinRoom(Connection* account, const QString& roomAliasOrId, + const QStringList& viaServers = {}) override + { + std::get<2>(fns_)(account, roomAliasOrId, viaServers); + } + bool visitNonMatrix(const QUrl& url) override + { + return std::get<3>(fns_)(url); + } + + std::tuple<FnTs...> fns_; +}; + +UriResolveResult Quotient::visitResource( + Connection* account, const Uri& uri, + std::function<UriResolveResult(User*, QString)> userHandler, + std::function<void(Room*, QString)> roomEventHandler, + std::function<void(Connection*, QString, QStringList)> joinHandler, + std::function<bool(const QUrl&)> nonMatrixHandler) +{ + return StaticUriDispatcher(userHandler, roomEventHandler, joinHandler, + nonMatrixHandler) + .visitResource(account, uri); +} + +UriResolveResult UriDispatcher::visitUser(User *user, const QString &action) +{ + emit userAction(user, action); + return UriResolved; +} + +void UriDispatcher::visitRoom(Room *room, const QString &eventId) +{ + emit roomAction(room, eventId); +} + +void UriDispatcher::joinRoom(Connection* account, const QString& roomAliasOrId, + const QStringList& viaServers) +{ + emit joinAction(account, roomAliasOrId, viaServers); +} + +bool UriDispatcher::visitNonMatrix(const QUrl &url) +{ + emit nonMatrixAction(url); + return true; +} diff --git a/lib/uriresolver.h b/lib/uriresolver.h new file mode 100644 index 00000000..9b2ced9d --- /dev/null +++ b/lib/uriresolver.h @@ -0,0 +1,168 @@ +#pragma once + +#include "uri.h" + +#include <QtCore/QObject> + +#include <functional> + +namespace Quotient { +class Connection; +class Room; +class User; + +/*! \brief Abstract class to resolve the resource and act on it + * + * This class encapsulates the logic of resolving a Matrix identifier or URI + * into a Quotient object (or objects) and calling an appropriate handler on it. + * It is a type-safe way of handling a URI with no prior context on its type + * in cases like, e.g., when a user clicks on a URI in the application. + * + * This class provides empty "handlers" for each type of URI to facilitate + * gradual implementation. Derived classes are encouraged to override as many + * of them as possible. + */ +class UriResolverBase { +public: + /*! \brief Resolve the resource and dispatch an action depending on its type + * + * This method: + * 1. Resolves \p uri into an actual object (e.g., Room or User), + * with possible additional data such as event id, in the context of + * \p account. + * 2. If the resolving is successful, depending on the type of the object, + * calls the appropriate virtual function (defined in a derived + * concrete class) to perform an action on the resource (open a room, + * mention a user etc.). + * 3. Returns the result of resolving the resource. + */ + UriResolveResult visitResource(Connection* account, const Uri& uri); + +protected: + /// Called by visitResource() when the passed URI identifies a Matrix user + /*! + * \return IncorrectAction if the action is not correct or not supported; + * UriResolved if it is accepted; other values are disallowed + */ + virtual UriResolveResult visitUser(User* user, const QString& action) + { + return IncorrectAction; + } + /// Called by visitResource() when the passed URI identifies a room or + /// an event in a room + virtual void visitRoom(Room* room, const QString& eventId) {} + /// Called by visitResource() when the passed URI has `action() == "join"` + /// and identifies a room that the user defined by the Connection argument + /// is not a member of + virtual void joinRoom(Connection* account, const QString& roomAliasOrId, + const QStringList& viaServers = {}) + {} + /// Called by visitResource() when the passed URI has `type() == NonMatrix` + /*! + * Should return true if the URI is considered resolved, false otherwise. + * A basic implementation in a graphical client can look like + * `return QDesktopServices::openUrl(url);` but it's strongly advised to + * ask for a user confirmation beforehand. + */ + virtual bool visitNonMatrix(const QUrl& url) { return false; } +}; + +/*! \brief Resolve the resource and invoke an action on it, via function objects + * + * This function encapsulates the logic of resolving a Matrix identifier or URI + * into a Quotient object (or objects) and calling an appropriate handler on it. + * Unlike UriResolverBase it accepts the list of handlers from + * the caller; internally it's uses a minimal UriResolverBase class + * + * \param account The connection used as a context to resolve the identifier + * + * \param uri A URI that can represent a Matrix entity + * + * \param userHandler Called when the passed URI identifies a Matrix user + * + * \param roomEventHandler Called when the passed URI identifies a room or + * an event in a room + * + * \param joinHandler Called when the passed URI has `action() == "join"` and + * identifies a room that the user defined by + * the Connection argument is not a member of + * + * \param nonMatrixHandler Called when the passed URI has `type() == NonMatrix`; + * should return true if the URI is considered resolved, + * false otherwise + * + * \sa UriResolverBase, UriDispatcher + */ +UriResolveResult +visitResource(Connection* account, const Uri& uri, + std::function<UriResolveResult(User*, QString)> userHandler, + std::function<void(Room*, QString)> roomEventHandler, + std::function<void(Connection*, QString, QStringList)> joinHandler, + std::function<bool(const QUrl&)> nonMatrixHandler); + +/*! \brief Check that the resource is resolvable with no action on it */ +inline UriResolveResult checkResource(Connection* account, const Uri& uri) +{ + return visitResource( + account, uri, [](auto, auto) { return UriResolved; }, [](auto, auto) {}, + [](auto, auto, auto) {}, [](auto) { return false; }); +} + +/*! \brief Resolve the resource and invoke an action on it, via Qt signals + * + * This is an implementation of UriResolverBase that is based on + * QObject and uses Qt signals instead of virtual functions to provide an + * open-ended interface for visitors. + * + * This class is aimed primarily at clients where invoking the resolving/action + * and handling the action are happening in decoupled parts of the code; it's + * also useful to operate on Matrix identifiers and URIs from QML/JS code + * that cannot call resolveResource() due to QML/C++ interface limitations. + * + * This class does not restrain the client code to a certain type of + * connections: both direct and queued (or a mix) will work fine. One limitation + * caused by that is there's no way to indicate if a non-Matrix URI has been + * successfully resolved - a signal always returns void. + * + * Note that in case of using (non-blocking) queued connections the code that + * calls resolveResource() should not expect the action to be performed + * synchronously - the returned value is the result of resolving the URI, + * not acting on it. + */ +class UriDispatcher : public QObject, public UriResolverBase { + Q_OBJECT +public: + explicit UriDispatcher(QObject* parent = nullptr) : QObject(parent) {} + + // It's actually UriResolverBase::visitResource() but with Q_INVOKABLE + Q_INVOKABLE UriResolveResult resolveResource(Connection* account, + const Uri& uri) + { + return UriResolverBase::visitResource(account, uri); + } + +signals: + /// An action on a user has been requested + void userAction(Quotient::User* user, QString action); + + /// An action on a room has been requested, with optional event id + void roomAction(Quotient::Room* room, QString eventId); + + /// A join action has been requested, with optional 'via' servers + void joinAction(Quotient::Connection* account, QString roomAliasOrId, + QStringList viaServers); + + /// An action on a non-Matrix URL has been requested + void nonMatrixAction(QUrl url); + +private: + UriResolveResult visitUser(User* user, const QString& action) override; + void visitRoom(Room* room, const QString& eventId) override; + void joinRoom(Connection* account, const QString& roomAliasOrId, + const QStringList& viaServers = {}) override; + bool visitNonMatrix(const QUrl& url) override; +}; + +} // namespace Quotient + + diff --git a/libquotient.pri b/libquotient.pri index a5a1459f..98fe3b03 100644 --- a/libquotient.pri +++ b/libquotient.pri @@ -34,6 +34,8 @@ HEADERS += \ $$SRCPATH/room.h \ $$SRCPATH/user.h \ $$SRCPATH/avatar.h \ + $$SRCPATH/uri.h \ + $$SRCPATH/uriresolver.h \ $$SRCPATH/syncdata.h \ $$SRCPATH/quotient_common.h \ $$SRCPATH/util.h \ @@ -90,6 +92,8 @@ SOURCES += \ $$SRCPATH/room.cpp \ $$SRCPATH/user.cpp \ $$SRCPATH/avatar.cpp \ + $$SRCPATH/uri.cpp \ + $$SRCPATH/uriresolver.cpp \ $$SRCPATH/syncdata.cpp \ $$SRCPATH/util.cpp \ $$SRCPATH/events/event.cpp \ diff --git a/tests/quotest.cpp b/tests/quotest.cpp index b06665a9..ae272622 100644 --- a/tests/quotest.cpp +++ b/tests/quotest.cpp @@ -2,6 +2,7 @@ #include "connection.h" #include "room.h" #include "user.h" +#include "uriresolver.h" #include "csapi/joining.h" #include "csapi/leaving.h" @@ -98,6 +99,7 @@ private slots: TEST_DECL(sendAndRedact) TEST_DECL(addAndRemoveTag) TEST_DECL(markDirectChat) + TEST_DECL(visitResources) // Add more tests above here public: @@ -215,18 +217,32 @@ void TestManager::setupAndRun() clog << "Access token: " << c->accessToken().toStdString() << endl; c->setLazyLoading(true); - c->syncLoop(); clog << "Joining " << targetRoomName.toStdString() << endl; auto joinJob = c->joinRoom(targetRoomName); - // Ensure, before this test is completed, that the room has been joined - // and filled with some events so that other tests could use that + // 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); - connectSingleShot(c, &Connection::syncDone, this, [this] { - if (testSuite->room()->timelineSize() > 0) - doTests(); - else { + // 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); @@ -262,8 +278,8 @@ void TestManager::doTests() const auto testName = metaMethod.name(); running.push_back(testName); - // Some tests return the result immediately, so queue everything - // so that we could process all tests asynchronously. + // 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)); } @@ -283,20 +299,6 @@ void TestManager::doTests() conclude(); } }); - - 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() - << ", pending size = " << r->pendingEvents().size() << endl; - if (!running.empty()) { - clog << running.size() << " test(s) in the air:"; - for (const auto& test: qAsConst(running)) - clog << " " << testName(test); - clog << endl; - } - }); } TEST_IMPL(loadMembers) @@ -612,6 +614,136 @@ TEST_IMPL(markDirectChat) && 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; + 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 + }; + // The following URIs are not supposed to be actually joined (and even + // exist, as yet) - only to be syntactically correct + static const auto& joinRoomAlias = + QStringLiteral("unjoined:example.org"); // # will be added below + static const QString joinQuery { "?action=join" }; + static const QStringList joinByAliasUris { + "matrix:room/" + joinRoomAlias + joinQuery, + "https://matrix.to/#/%23"/*`#`*/ + joinRoomAlias + 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, + [](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() { QString succeededRec { QString::number(succeeded.size()) % " of " |