nextcloud-desktop/test/testunifiedsearchlistmodel.cpp
Matthieu Gallien 098f4ef164 better automated tests log
Signed-off-by: Matthieu Gallien <matthieu.gallien@nextcloud.com>
2024-05-15 15:06:11 +02:00

645 lines
23 KiB
C++

/*
* Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
#include "gui/tray/unifiedsearchresultslistmodel.h"
#include "account.h"
#include "accountstate.h"
#include "syncenginetestutils.h"
#include <QAbstractItemModelTester>
#include <QDesktopServices>
#include <QSignalSpy>
#include <QTest>
namespace {
/**
* @brief The FakeDesktopServicesUrlHandler
* overrides QDesktopServices::openUrl
**/
class FakeDesktopServicesUrlHandler : public QObject
{
Q_OBJECT
public:
FakeDesktopServicesUrlHandler(QObject *parent = nullptr)
: QObject(parent)
{}
public:
signals:
void resultClicked(const QUrl &url);
};
/**
* @brief The FakeProvider
* is a simple structure that represents initial list of providers and their properties
**/
class FakeProvider
{
public:
QString _id;
QString _name;
qint32 _order = std::numeric_limits<qint32>::max();
quint32 _numItemsToInsert = 5; // how many fake results to insert
};
// this will be used when initializing fake search results data for each provider
static const QVector<FakeProvider> fakeProvidersInitInfo = {
{QStringLiteral("settings_apps"), QStringLiteral("Apps"), -50, 10},
{QStringLiteral("talk-message"), QStringLiteral("Messages"), -2, 17},
{QStringLiteral("files"), QStringLiteral("Files"), 5, 3},
{QStringLiteral("deck"), QStringLiteral("Deck"), 10, 5},
{QStringLiteral("comments"), QStringLiteral("Comments"), 10, 2},
{QStringLiteral("mail"), QStringLiteral("Mails"), 10, 15},
{QStringLiteral("calendar"), QStringLiteral("Events"), 30, 11}
};
static QByteArray fake404Response = R"(
{"ocs":{"meta":{"status":"failure","statuscode":404,"message":"Invalid query, please check the syntax. API specifications are here: http:\/\/www.freedesktop.org\/wiki\/Specifications\/open-collaboration-services.\n"},"data":[]}}
)";
static QByteArray fake400Response = R"(
{"ocs":{"meta":{"status":"failure","statuscode":400,"message":"Parameter is incorrect.\n"},"data":[]}}
)";
static QByteArray fake500Response = R"(
{"ocs":{"meta":{"status":"failure","statuscode":500,"message":"Internal Server Error.\n"},"data":[]}}
)";
/**
* @brief The FakeSearchResultsStorage
* emulates the real server storage that contains all the results that UnifiedSearchListmodel will search for
**/
class FakeSearchResultsStorage
{
class Provider
{
public:
class SearchResult
{
public:
QString _thumbnailUrl;
QString _title;
QString _subline;
QString _resourceUrl;
QString _icon;
bool _rounded;
};
QString _id;
QString _name;
qint32 _order = std::numeric_limits<qint32>::max();
qint32 _cursor = 0;
bool _isPaginated = false;
QVector<SearchResult> _results;
};
FakeSearchResultsStorage() = default;
public:
static FakeSearchResultsStorage *instance()
{
if (!_instance) {
_instance = new FakeSearchResultsStorage();
_instance->init();
}
return _instance;
};
static void destroy()
{
if (_instance) {
delete _instance;
}
_instance = nullptr;
}
void init()
{
if (!_searchResultsData.isEmpty()) {
return;
}
_metaSuccess = {{QStringLiteral("status"), QStringLiteral("ok")}, {QStringLiteral("statuscode"), 200},
{QStringLiteral("message"), QStringLiteral("OK")}};
initProvidersResponse();
initSearchResultsData();
}
// initialize the JSON response containing the fake list of providers and their properties
void initProvidersResponse()
{
QList<QVariant> providersList;
for (const auto &fakeProviderInitInfo : fakeProvidersInitInfo) {
providersList.push_back(QVariantMap{
{QStringLiteral("id"), fakeProviderInitInfo._id},
{QStringLiteral("name"), fakeProviderInitInfo._name},
{QStringLiteral("order"), fakeProviderInitInfo._order},
});
}
const QVariantMap ocsMap = {
{QStringLiteral("meta"), _metaSuccess},
{QStringLiteral("data"), providersList}
};
_providersResponse =
QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}).toJson(QJsonDocument::Compact);
}
// init the map of fake search results for each provider
void initSearchResultsData()
{
for (const auto &fakeProvider : fakeProvidersInitInfo) {
auto &providerData = _searchResultsData[fakeProvider._id];
providerData._id = fakeProvider._id;
providerData._name = fakeProvider._name;
providerData._order = fakeProvider._order;
if (fakeProvider._numItemsToInsert > pageSize) {
providerData._isPaginated = true;
}
for (quint32 i = 0; i < fakeProvider._numItemsToInsert; ++i) {
providerData._results.push_back(
{"http://example.de/avatar/john/64", QString(QStringLiteral("John Doe in ") + fakeProvider._name),
QString(QStringLiteral("We a discussion about ") + fakeProvider._name
+ QStringLiteral(" already. But, let's have a follow up tomorrow afternoon.")),
"http://example.de/call/abcde12345#message_12345", QStringLiteral("icon-talk"), true});
}
}
}
const QList<QVariant> resultsForProvider(const QString &providerId, int cursor)
{
QList<QVariant> list;
const auto results = resultsForProviderAsVector(providerId, cursor);
if (results.isEmpty()) {
return list;
}
for (const auto &result : results) {
list.push_back(QVariantMap{
{"thumbnailUrl", result._thumbnailUrl},
{"title", result._title},
{"subline", result._subline},
{"resourceUrl", result._resourceUrl},
{"icon", result._icon},
{"rounded", result._rounded}
});
}
return list;
}
const QVector<Provider::SearchResult> resultsForProviderAsVector(const QString &providerId, int cursor)
{
QVector<Provider::SearchResult> results;
const auto provider = _searchResultsData.value(providerId, Provider());
if (provider._id.isEmpty() || cursor > provider._results.size()) {
return results;
}
const int n = cursor + pageSize > provider._results.size()
? 0
: cursor + pageSize;
for (int i = cursor; i < n; ++i) {
results.push_back(provider._results[i]);
}
return results;
}
const QByteArray queryProvider(const QString &providerId, const QString &searchTerm, int cursor)
{
if (!_searchResultsData.contains(providerId)) {
return fake404Response;
}
if (searchTerm == QStringLiteral("[HTTP500]")) {
return fake500Response;
}
if (searchTerm == QStringLiteral("[empty]")) {
const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name},
{QStringLiteral("isPaginated"), false}, {QStringLiteral("cursor"), 0},
{QStringLiteral("entries"), QVariantList{}}};
const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}};
return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}})
.toJson(QJsonDocument::Compact);
}
const auto provider = _searchResultsData.value(providerId, Provider());
const auto nextCursor = cursor + pageSize;
const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name},
{QStringLiteral("isPaginated"), _searchResultsData[providerId]._isPaginated},
{QStringLiteral("cursor"), nextCursor},
{QStringLiteral("entries"), resultsForProvider(providerId, cursor)}};
const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}};
return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}).toJson(QJsonDocument::Compact);
}
[[nodiscard]] const QByteArray &fakeProvidersResponseJson() const { return _providersResponse; }
private:
static FakeSearchResultsStorage *_instance;
static const int pageSize = 5;
QMap<QString, Provider> _searchResultsData;
QByteArray _providersResponse = fake404Response;
QVariantMap _metaSuccess;
};
FakeSearchResultsStorage *FakeSearchResultsStorage::_instance = nullptr;
}
class TestUnifiedSearchListmodel : public QObject
{
Q_OBJECT
public:
TestUnifiedSearchListmodel() = default;
QScopedPointer<FakeQNAM> fakeQnam;
OCC::AccountPtr account;
QScopedPointer<OCC::AccountState> accountState;
QScopedPointer<OCC::UnifiedSearchResultsListModel> model;
QScopedPointer<QAbstractItemModelTester> modelTester;
QScopedPointer<FakeDesktopServicesUrlHandler> fakeDesktopServicesUrlHandler;
static const int searchResultsReplyDelay = 100;
private slots:
void initTestCase()
{
OCC::Logger::instance()->setLogFlush(true);
OCC::Logger::instance()->setLogDebug(true);
QStandardPaths::setTestModeEnabled(true);
fakeQnam.reset(new FakeQNAM({}));
account = OCC::Account::create();
account->setCredentials(new FakeCredentials{fakeQnam.data()});
account->setUrl(QUrl(("http://example.de")));
accountState.reset(new OCC::AccountState(account));
fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) {
Q_UNUSED(device);
QNetworkReply *reply = nullptr;
const auto urlQuery = QUrlQuery(req.url());
const auto format = urlQuery.queryItemValue(QStringLiteral("format"));
const auto cursor = urlQuery.queryItemValue(QStringLiteral("cursor")).toInt();
const auto searchTerm = urlQuery.queryItemValue(QStringLiteral("term"));
const auto path = req.url().path();
if (!req.url().toString().startsWith(accountState->account()->url().toString())) {
reply = new FakeErrorReply(op, req, this, 404, fake404Response);
}
if (format != QStringLiteral("json")) {
reply = new FakeErrorReply(op, req, this, 400, fake400Response);
}
// handle fetch of providers list
if (path.startsWith(QStringLiteral("/ocs/v2.php/search/providers")) && searchTerm.isEmpty()) {
reply = new FakePayloadReply(op, req,
FakeSearchResultsStorage::instance()->fakeProvidersResponseJson(), fakeQnam.data());
// handle search for provider
} else if (path.startsWith(QStringLiteral("/ocs/v2.php/search/providers")) && !searchTerm.isEmpty()) {
const auto pathSplit = path.mid(QString(QStringLiteral("/ocs/v2.php/search/providers")).size())
.split(QLatin1Char('/'), Qt::SkipEmptyParts);
if (!pathSplit.isEmpty() && path.contains(pathSplit.first())) {
reply = new FakePayloadReply(op, req,
FakeSearchResultsStorage::instance()->queryProvider(pathSplit.first(), searchTerm, cursor),
searchResultsReplyDelay, fakeQnam.data());
}
}
if (!reply) {
return qobject_cast<QNetworkReply*>(new FakeErrorReply(op, req, this, 404, QByteArrayLiteral("{error: \"Not found!\"}")));
}
return reply;
});
model.reset(new OCC::UnifiedSearchResultsListModel(accountState.data()));
modelTester.reset(new QAbstractItemModelTester(model.data()));
fakeDesktopServicesUrlHandler.reset(new FakeDesktopServicesUrlHandler);
}
void testSetSearchTermStartStopSearch()
{
// make sure the model is empty
model->setSearchTerm(QStringLiteral(""));
QVERIFY(model->rowCount() == 0);
// #1 test setSearchTerm actually sets the search term and the signal is emitted
QSignalSpy searhTermChanged(model.data(), &OCC::UnifiedSearchResultsListModel::searchTermChanged);
model->setSearchTerm(QStringLiteral("dis"));
QCOMPARE(searhTermChanged.count(), 1);
QCOMPARE(model->searchTerm(), QStringLiteral("dis"));
// #2 test setSearchTerm actually sets the search term and the signal is emitted
searhTermChanged.clear();
model->setSearchTerm(model->searchTerm() + QStringLiteral("cuss"));
QCOMPARE(model->searchTerm(), QStringLiteral("discuss"));
QCOMPARE(searhTermChanged.count(), 1);
// #3 test that model has not started search yet
QVERIFY(!model->isSearchInProgress());
// #4 test that model has started the search after specific delay
QSignalSpy searchInProgressChanged(model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
// allow search jobs to get created within the model
QVERIFY(searchInProgressChanged.wait());
QCOMPARE(searchInProgressChanged.count(), 1);
QVERIFY(model->isSearchInProgress());
// #5 test that model has stopped the search after setting empty search term
model->setSearchTerm(QStringLiteral(""));
QVERIFY(!model->isSearchInProgress());
}
void testSetSearchTermResultsFound()
{
// make sure the model is empty
model->setSearchTerm(QStringLiteral(""));
QVERIFY(model->rowCount() == 0);
// test that search term gets set, search gets started and enough results get returned
model->setSearchTerm(model->searchTerm() + QStringLiteral("discuss"));
QSignalSpy searchInProgressChanged(
model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
QVERIFY(searchInProgressChanged.wait());
// make sure search has started
QCOMPARE(searchInProgressChanged.count(), 1);
QVERIFY(model->isSearchInProgress());
QVERIFY(searchInProgressChanged.wait());
// make sure search has finished
QVERIFY(!model->isSearchInProgress());
QVERIFY(model->rowCount() > 0);
}
void testSetSearchTermResultsNotFound()
{
// make sure the model is empty
model->setSearchTerm(QStringLiteral(""));
QVERIFY(model->rowCount() == 0);
// test that search term gets set, search gets started and enough results get returned
model->setSearchTerm(model->searchTerm() + QStringLiteral("[empty]"));
QSignalSpy searchInProgressChanged(
model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
QVERIFY(searchInProgressChanged.wait());
// make sure search has started
QCOMPARE(searchInProgressChanged.count(), 1);
QVERIFY(model->isSearchInProgress());
QVERIFY(searchInProgressChanged.wait());
// make sure search has finished
QVERIFY(!model->isSearchInProgress());
QVERIFY(model->rowCount() == 0);
}
void testFetchMoreClicked()
{
// make sure the model is empty
model->setSearchTerm(QStringLiteral(""));
QVERIFY(model->rowCount() == 0);
QSignalSpy searchInProgressChanged(
model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
// test that search term gets set, search gets started and enough results get returned
model->setSearchTerm(model->searchTerm() + QStringLiteral("whatever"));
QVERIFY(searchInProgressChanged.wait());
// make sure search has started
QVERIFY(model->isSearchInProgress());
QVERIFY(searchInProgressChanged.wait());
// make sure search has finished
QVERIFY(!model->isSearchInProgress());
const auto numRowsInModelPrev = model->rowCount();
// test fetch more results
QSignalSpy currentFetchMoreInProgressProviderIdChanged(
model.data(), &OCC::UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged);
QSignalSpy rowsInserted(model.data(), &OCC::UnifiedSearchResultsListModel::rowsInserted);
for (int i = 0; i < model->rowCount(); ++i) {
const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole);
if (type == OCC::UnifiedSearchResult::Type::FetchMoreTrigger) {
const auto providerId =
model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ProviderIdRole)
.toString();
model->fetchMoreTriggerClicked(providerId);
break;
}
}
// make sure the currentFetchMoreInProgressProviderId was set back and forth and correct number fows has been inserted
QCOMPARE(currentFetchMoreInProgressProviderIdChanged.count(), 1);
const auto providerIdFetchMoreTriggered = model->currentFetchMoreInProgressProviderId();
QVERIFY(!providerIdFetchMoreTriggered.isEmpty());
QVERIFY(currentFetchMoreInProgressProviderIdChanged.wait());
QVERIFY(model->currentFetchMoreInProgressProviderId().isEmpty());
QCOMPARE(rowsInserted.count(), 1);
const auto arguments = rowsInserted.takeFirst();
QVERIFY(arguments.size() > 0);
const auto first = arguments.at(0).toInt();
const auto last = arguments.at(1).toInt();
const int numInsertedExpected = last - first;
QCOMPARE(model->rowCount() - numRowsInModelPrev, numInsertedExpected);
// make sure the FetchMoreTrigger gets removed when no more results available
if (!providerIdFetchMoreTriggered.isEmpty()) {
currentFetchMoreInProgressProviderIdChanged.clear();
rowsInserted.clear();
QSignalSpy rowsRemoved(model.data(), &OCC::UnifiedSearchResultsListModel::rowsRemoved);
for (int i = 0; i < 10; ++i) {
model->fetchMoreTriggerClicked(providerIdFetchMoreTriggered);
QVERIFY(currentFetchMoreInProgressProviderIdChanged.wait());
if (rowsRemoved.count() > 0) {
break;
}
}
QCOMPARE(rowsRemoved.count(), 1);
bool isFetchMoreTriggerFound = false;
for (int i = 0; i < model->rowCount(); ++i) {
const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole);
const auto providerId = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ProviderIdRole)
.toString();
if (type == OCC::UnifiedSearchResult::Type::FetchMoreTrigger
&& providerId == providerIdFetchMoreTriggered) {
isFetchMoreTriggerFound = true;
break;
}
}
QVERIFY(!isFetchMoreTriggerFound);
}
}
void testSearchResultlicked()
{
// make sure the model is empty
model->setSearchTerm(QStringLiteral(""));
QVERIFY(model->rowCount() == 0);
// test that search term gets set, search gets started and enough results get returned
model->setSearchTerm(model->searchTerm() + QStringLiteral("discuss"));
QSignalSpy searchInProgressChanged(
model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
QVERIFY(searchInProgressChanged.wait());
// make sure search has started
QCOMPARE(searchInProgressChanged.count(), 1);
QVERIFY(model->isSearchInProgress());
QVERIFY(searchInProgressChanged.wait());
// make sure search has finished and some results has been received
QVERIFY(!model->isSearchInProgress());
QVERIFY(model->rowCount() != 0);
QDesktopServices::setUrlHandler("http", fakeDesktopServicesUrlHandler.data(), "resultClicked");
QDesktopServices::setUrlHandler("https", fakeDesktopServicesUrlHandler.data(), "resultClicked");
QSignalSpy resultClicked(fakeDesktopServicesUrlHandler.data(), &FakeDesktopServicesUrlHandler::resultClicked);
// test click on a result item
QString urlForClickedResult;
for (int i = 0; i < model->rowCount(); ++i) {
const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole);
if (type == OCC::UnifiedSearchResult::Type::Default) {
const auto providerId =
model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ProviderIdRole)
.toString();
urlForClickedResult = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ResourceUrlRole).toString();
if (!providerId.isEmpty() && !urlForClickedResult.isEmpty()) {
model->resultClicked(providerId, QUrl(urlForClickedResult));
break;
}
}
}
QCOMPARE(resultClicked.count(), 1);
const auto arguments = resultClicked.takeFirst();
const auto urlOpenTriggeredViaDesktopServices = arguments.at(0).toString();
QCOMPARE(urlOpenTriggeredViaDesktopServices, urlForClickedResult);
}
void testSetSearchTermResultsError()
{
// make sure the model is empty
model->setSearchTerm(QStringLiteral(""));
QVERIFY(model->rowCount() == 0);
QSignalSpy errorStringChanged(model.data(), &OCC::UnifiedSearchResultsListModel::errorStringChanged);
QSignalSpy searchInProgressChanged(
model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
model->setSearchTerm(model->searchTerm() + QStringLiteral("[HTTP500]"));
QVERIFY(searchInProgressChanged.wait());
// make sure search has started
QVERIFY(model->isSearchInProgress());
QVERIFY(searchInProgressChanged.wait());
// make sure search has finished
QVERIFY(!model->isSearchInProgress());
// make sure the model is empty and an error string has been set
QVERIFY(model->rowCount() == 0);
QVERIFY(errorStringChanged.count() > 0);
QVERIFY(!model->errorString().isEmpty());
}
void cleanupTestCase()
{
FakeSearchResultsStorage::destroy();
}
};
QTEST_MAIN(TestUnifiedSearchListmodel)
#include "testunifiedsearchlistmodel.moc"