diff options
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 316 | ||||
| -rw-r--r-- | src/notmuchworker.h | 69 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/notmuchfixture.h | 114 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 419 |
6 files changed, 920 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fc368cb..5198429 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(qtmaildir_lib STATIC requestinterceptor.cpp htmlbuilder.cpp cidschemehandler.cpp + notmuchworker.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp new file mode 100644 index 0000000..ae4cafe --- /dev/null +++ b/src/notmuchworker.cpp @@ -0,0 +1,316 @@ +#include "notmuchworker.h" + +#include <notmuch.h> + +#include <QSet> + +#include <cstdlib> + +#include "nmraii.h" + +namespace { + +QStringList tagsOf(notmuch_message_t *message) +{ + QStringList result; + NmTags tags(notmuch_message_get_tags(message)); + for (; notmuch_tags_valid(tags.get()); notmuch_tags_move_to_next(tags.get())) + result.append(QString::fromUtf8(notmuch_tags_get(tags.get()))); + return result; +} + +QStringList tagsOf(notmuch_thread_t *thread) +{ + QStringList result; + NmTags tags(notmuch_thread_get_tags(thread)); + for (; notmuch_tags_valid(tags.get()); notmuch_tags_move_to_next(tags.get())) + result.append(QString::fromUtf8(notmuch_tags_get(tags.get()))); + return result; +} + +/// Collects the message ids a query matches. Returns false if the query could +/// not be run at all, which is different from a query that matched nothing. +bool collectMessageIds(notmuch_database_t *db, const QString &query, + QStringList *ids) +{ + NmQuery nmQuery(notmuch_query_create(db, query.toUtf8().constData())); + if (!nmQuery) + return false; + + notmuch_messages_t *raw = nullptr; + if (notmuch_query_search_messages(nmQuery.get(), &raw) != NOTMUCH_STATUS_SUCCESS) + return false; + + NmMessages messages(raw); + for (; notmuch_messages_valid(messages.get()); + notmuch_messages_move_to_next(messages.get())) { + NmMessage message(notmuch_messages_get(messages.get())); + if (message) + ids->append(QString::fromUtf8(notmuch_message_get_message_id(message.get()))); + } + return true; +} + +} // namespace + +NotmuchWorker::NotmuchWorker(const QString ¬muchConfigPath, QObject *parent) + : QObject(parent), m_configPath(notmuchConfigPath) +{ +} + +NotmuchWorker::~NotmuchWorker() +{ + close(); +} + +/// An empty config path means "let notmuch resolve its own config", which +/// libnotmuch spells as NULL. The QByteArray is returned by value so callers +/// keep it alive for as long as they use constData(). +QByteArray NotmuchWorker::configPathArg() const +{ + return m_configPath.isEmpty() ? QByteArray() : m_configPath.toLocal8Bit(); +} + +bool NotmuchWorker::openReadOnly() +{ + if (m_db) + return true; + + const QByteArray configPath = configPathArg(); + char *error = nullptr; + const notmuch_status_t status = notmuch_database_open_with_config( + nullptr, // let config decide path + NOTMUCH_DATABASE_MODE_READ_ONLY, + configPath.isEmpty() ? nullptr : configPath.constData(), + nullptr, + &m_db, + &error); + + if (status != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Cannot open notmuch database: %1") + .arg(QString::fromUtf8(error ? error : notmuch_status_to_string(status)))); + free(error); + m_db = nullptr; + return false; + } + return true; +} + +void NotmuchWorker::close() +{ + if (m_db) { + notmuch_database_destroy(m_db); + m_db = nullptr; + } +} + +void NotmuchWorker::runQuery(const QString &query, quint64 generation) +{ + if (!openReadOnly()) + return; + + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) { + emit errorOccurred(QStringLiteral("Invalid query: %1").arg(query)); + return; + } + notmuch_query_set_sort(nmQuery.get(), NOTMUCH_SORT_NEWEST_FIRST); + + notmuch_threads_t *rawThreads = nullptr; + const notmuch_status_t status = + notmuch_query_search_threads(nmQuery.get(), &rawThreads); + if (status != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Query failed: %1") + .arg(QString::fromUtf8(notmuch_status_to_string(status)))); + return; + } + NmThreads threads(rawThreads); + + QVector<ThreadSummary> batch; + batch.reserve(kBatchSize); + int total = 0; + + for (; notmuch_threads_valid(threads.get()); + notmuch_threads_move_to_next(threads.get())) { + + NmThread thread(notmuch_threads_get(threads.get())); + if (!thread) + continue; + + ThreadSummary summary; + summary.threadId = QString::fromUtf8(notmuch_thread_get_thread_id(thread.get())); + summary.subject = QString::fromUtf8(notmuch_thread_get_subject(thread.get())); + summary.authors = QString::fromUtf8(notmuch_thread_get_authors(thread.get())); + summary.date = QDateTime::fromSecsSinceEpoch( + notmuch_thread_get_newest_date(thread.get())); + summary.totalCount = notmuch_thread_get_total_messages(thread.get()); + summary.matchedCount = notmuch_thread_get_matched_messages(thread.get()); + summary.tags = tagsOf(thread.get()); + + batch.append(summary); + ++total; + + if (batch.size() >= kBatchSize) { + emit threadsReady(batch, generation); + batch.clear(); + batch.reserve(kBatchSize); + } + } + + if (!batch.isEmpty()) + emit threadsReady(batch, generation); + + emit queryFinished(total, generation); +} + +void NotmuchWorker::loadThread(const QString &threadId, + const QString &matchQuery, + quint64 generation) +{ + if (!openReadOnly()) + return; + + // Which messages of the thread matched the user's query. Running the query + // intersected with the thread is cheaper than testing each message. + // + // haveMatchSet distinguishes "no query was given, so everything counts as + // matched" from "a query was given and matched nothing in this thread". + // Collapsing those would render a whole thread expanded precisely when the + // user filtered it down to nothing. + QSet<QString> matchedIds; + bool haveMatchSet = false; + if (!matchQuery.trimmed().isEmpty()) { + const QString intersect = + QStringLiteral("thread:%1 and (%2)").arg(threadId, matchQuery); + QStringList ids; + if (collectMessageIds(m_db, intersect, &ids)) { + matchedIds = QSet<QString>(ids.begin(), ids.end()); + haveMatchSet = true; + } + } + + const QString query = QStringLiteral("thread:%1").arg(threadId); + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) { + emit errorOccurred(QStringLiteral("Cannot load thread %1").arg(threadId)); + return; + } + notmuch_query_set_sort(nmQuery.get(), NOTMUCH_SORT_OLDEST_FIRST); + + notmuch_messages_t *rawMessages = nullptr; + if (notmuch_query_search_messages(nmQuery.get(), &rawMessages) + != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Cannot search thread %1").arg(threadId)); + return; + } + NmMessages messages(rawMessages); + + QVector<MessageRef> result; + for (; notmuch_messages_valid(messages.get()); + notmuch_messages_move_to_next(messages.get())) { + + NmMessage message(notmuch_messages_get(messages.get())); + if (!message) + continue; + + MessageRef ref; + ref.messageId = QString::fromUtf8(notmuch_message_get_message_id(message.get())); + ref.filePath = QString::fromUtf8(notmuch_message_get_filename(message.get())); + ref.tags = tagsOf(message.get()); + ref.matched = !haveMatchSet || matchedIds.contains(ref.messageId); + result.append(ref); + } + + emit threadLoaded(result, generation); +} + +void NotmuchWorker::applyTagsToThreads(const QStringList &threadIds, + const QStringList &add, + const QStringList &remove, + const QString &description) +{ + if (threadIds.isEmpty()) + return; + + if (!openReadOnly()) + return; + + // Resolve every thread to its message ids in ONE query. Issuing a query per + // thread would reopen the same Xapian cursor hundreds of times on a large + // selection. + QStringList terms; + terms.reserve(threadIds.size()); + for (const QString &id : threadIds) + terms.append(QStringLiteral("thread:%1").arg(id)); + + QStringList messageIds; + if (!collectMessageIds(m_db, terms.join(QStringLiteral(" or ")), &messageIds)) { + emit errorOccurred(QStringLiteral("Cannot resolve selected threads")); + return; + } + + if (messageIds.isEmpty()) { + emit errorOccurred(QStringLiteral("Selected threads contain no messages")); + return; + } + + applyTags(TagChange{ messageIds, add, remove, description }); +} + +void NotmuchWorker::applyTags(const TagChange &change) +{ + if (change.messageIds.isEmpty()) + return; + + // The read-only handle must be closed first: notmuch allows only one open + // handle per process. + close(); + + const QByteArray configPath = configPathArg(); + notmuch_database_t *db = nullptr; + char *error = nullptr; + const notmuch_status_t status = notmuch_database_open_with_config( + nullptr, + NOTMUCH_DATABASE_MODE_READ_WRITE, + configPath.isEmpty() ? nullptr : configPath.constData(), + nullptr, + &db, + &error); + + if (status != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred( + QStringLiteral("Cannot open database for writing (is a sync running?): %1") + .arg(QString::fromUtf8(error ? error + : notmuch_status_to_string(status)))); + free(error); + return; + } + + for (const QString &id : change.messageIds) { + notmuch_message_t *raw = nullptr; + // find_message reports SUCCESS with a null message when the id is not + // in the database, so both have to be checked. A stale id must not + // abort the batch: the live ids alongside it still need tagging. + if (notmuch_database_find_message(db, id.toUtf8().constData(), &raw) + != NOTMUCH_STATUS_SUCCESS || !raw) { + continue; + } + NmMessage message(raw); + + notmuch_message_freeze(message.get()); + for (const QString &tag : change.removed) + notmuch_message_remove_tag(message.get(), tag.toUtf8().constData()); + for (const QString &tag : change.added) + notmuch_message_add_tag(message.get(), tag.toUtf8().constData()); + notmuch_message_thaw(message.get()); + + // Renames the file on disk when the seen/flagged tags changed, keeping + // the Maildir and the index in agreement for the next `notmuch new`. + notmuch_message_tags_to_maildir_flags(message.get()); + } + + notmuch_database_close(db); + notmuch_database_destroy(db); + + emit tagsApplied(change); +} diff --git a/src/notmuchworker.h b/src/notmuchworker.h new file mode 100644 index 0000000..2551004 --- /dev/null +++ b/src/notmuchworker.h @@ -0,0 +1,69 @@ +#pragma once + +#include <QObject> +#include <QStringList> +#include <QVector> + +#include "types.h" + +struct _notmuch_database; +typedef struct _notmuch_database notmuch_database_t; + +/// Owns the only notmuch database handle in the process. +/// +/// libnotmuch is not thread-safe and queries over a large database block, so +/// this object lives on its own thread and the UI reaches it only through +/// queued signals. No notmuch pointer ever leaves this class. +class NotmuchWorker : public QObject +{ + Q_OBJECT +public: + /// notmuchConfigPath may be empty, in which case notmuch resolves its own + /// config and therefore its own database.path. + explicit NotmuchWorker(const QString ¬muchConfigPath, QObject *parent = nullptr); + ~NotmuchWorker() override; + + /// Threads emitted per threadsReady() signal. + static constexpr int kBatchSize = 200; + +public slots: + /// Runs a query. generation lets the UI discard results from a superseded + /// query without the worker needing to know about cancellation. + void runQuery(const QString &query, quint64 generation); + + /// Loads the messages of one thread, oldest first. matchQuery is the + /// user's current query; messages matching it render expanded, the rest + /// as stubs. + void loadThread(const QString &threadId, const QString &matchQuery, + quint64 generation); + + /// Applies tag changes. Opens the database read-write, applies, and closes + /// immediately: notmuch's write lock is exclusive process-wide, so holding + /// it would block the user's cron `notmuch new`. + void applyTags(const TagChange &change); + + /// Batch tagging over whole threads. The UI holds thread ids, not message + /// ids, for rows it has not opened, so the resolution happens here where + /// the database handle lives. This is the path the archive/flag/delete + /// actions use on a multi-row selection. + void applyTagsToThreads(const QStringList &threadIds, + const QStringList &add, + const QStringList &remove, + const QString &description); + +signals: + void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation); + void queryFinished(int totalThreads, quint64 generation); + void threadLoaded(const QVector<MessageRef> &messages, quint64 generation); + void tagsApplied(const TagChange &change); + void errorOccurred(const QString &message); + +private: + bool openReadOnly(); + void close(); + + QByteArray configPathArg() const; + + QString m_configPath; + notmuch_database_t *m_db = nullptr; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d0faeec..a24a60b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,3 +12,4 @@ target_compile_definitions(test_mimeparser PRIVATE FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") add_qtmaildir_test(interceptor) add_qtmaildir_test(htmlbuilder) +add_qtmaildir_test(notmuchworker) diff --git a/tests/notmuchfixture.h b/tests/notmuchfixture.h new file mode 100644 index 0000000..b7287f4 --- /dev/null +++ b/tests/notmuchfixture.h @@ -0,0 +1,114 @@ +#pragma once + +#include <QDir> +#include <QFile> +#include <QProcess> +#include <QString> +#include <QTemporaryDir> +#include <QTextStream> + +/// A throwaway notmuch database in a temporary directory. +/// +/// Builds a Maildir tree, writes a notmuch config pointing at it, and runs +/// `notmuch new`. Nothing touches the developer's own ~/Mail or +/// ~/.notmuch-config: the config path is handed to the worker explicitly. +/// +/// Maildir flags are not decoration. notmuch synchronizes them with tags at +/// index time, so a file named `...:2,S` ("seen") comes out WITHOUT the unread +/// tag no matter what [new] tags says. addMessage() takes the unread state and +/// picks the filename accordingly. +class NotmuchFixture +{ +public: + /// True when the temporary tree was created. Check before use. + bool isValid() const { return m_dir.isValid(); } + + QString configPath() const { return m_dir.filePath(QStringLiteral("config")); } + QString maildirPath() const { return m_dir.filePath(QStringLiteral("mail")); } + + /// Writes one message into <folder>/cur (or new/ when unread). + /// + /// Returns false if the file could not be written. Call index() afterwards. + bool addMessage(const QString &folder, const QString &messageId, + const QString &subject, const QString &from, + const QString &date, const QString &body, + bool unread = true, const QString &inReplyTo = QString()) + { + // Unread messages must not carry the maildir "S" flag, so they go to + // new/ where no flags exist at all. + const QString sub = unread ? QStringLiteral("new") : QStringLiteral("cur"); + const QString dirPath = maildirPath() + QLatin1Char('/') + folder; + QDir dir; + if (!dir.mkpath(dirPath + QStringLiteral("/cur")) + || !dir.mkpath(dirPath + QStringLiteral("/new")) + || !dir.mkpath(dirPath + QStringLiteral("/tmp"))) { + return false; + } + + // The local part of the id makes a safe, unique, flag-free filename. + QString base = messageId; + base.remove(QLatin1Char('<')).remove(QLatin1Char('>')); + base.replace(QLatin1Char('@'), QLatin1Char('.')); + base.replace(QLatin1Char('/'), QLatin1Char('.')); + if (!unread) + base += QStringLiteral(":2,S"); + + QFile file(dirPath + QLatin1Char('/') + sub + QLatin1Char('/') + base); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + return false; + + QTextStream out(&file); + out << "From: " << from << "\n" + << "To: danix@danix.xyz\n" + << "Subject: " << subject << "\n" + << "Message-ID: <" << messageId << ">\n" + << "Date: " << date << "\n"; + if (!inReplyTo.isEmpty()) + out << "In-Reply-To: <" << inReplyTo << ">\n" + << "References: <" << inReplyTo << ">\n"; + out << "\n" << body << "\n"; + out.flush(); + file.close(); + return true; + } + + /// Writes the config and runs `notmuch new`. Safe to call repeatedly. + /// Returns false (with error() set) if notmuch is missing or fails. + bool index() + { + QFile config(configPath()); + if (!config.open(QIODevice::WriteOnly | QIODevice::Text)) { + m_error = QStringLiteral("cannot write fixture config"); + return false; + } + QTextStream out(&config); + out << "[database]\n" + << "path=" << maildirPath() << "\n" + << "[new]\n" + << "tags=unread;inbox;\n"; + out.flush(); + config.close(); + + QProcess proc; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("NOTMUCH_CONFIG"), configPath()); + proc.setProcessEnvironment(env); + proc.start(QStringLiteral("notmuch"), { QStringLiteral("new") }); + if (!proc.waitForStarted(5000)) { + m_error = QStringLiteral("notmuch not found on PATH"); + return false; + } + if (!proc.waitForFinished(30000) || proc.exitCode() != 0) { + m_error = QStringLiteral("notmuch new failed: %1") + .arg(QString::fromLocal8Bit(proc.readAllStandardError())); + return false; + } + return true; + } + + QString error() const { return m_error; } + +private: + QTemporaryDir m_dir; + QString m_error; +}; diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp new file mode 100644 index 0000000..e31dfcc --- /dev/null +++ b/tests/test_notmuchworker.cpp @@ -0,0 +1,419 @@ +#include <QSignalSpy> +#include <QtTest> + +#include "notmuchfixture.h" +#include "notmuchworker.h" +#include "types.h" + +/// NotmuchWorker against a throwaway database. This is the only code in the +/// project that writes to a notmuch index, so applyTags gets the most +/// attention: a bug there corrupts real mail state. +class TestNotmuchWorker : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + void queryReturnsAllThreads(); + void queryFiltersByTag(); + void queryReportsThreadMetadata(); + void malformedQueryYieldsNoThreads(); + void unreadableConfigEmitsError(); + void queryPassesGenerationThrough(); + + void loadThreadReturnsMessagesOldestFirst(); + void loadThreadMarksMatchedMessages(); + void loadThreadWithEmptyQueryMatchesEverything(); + void loadThreadWithNonMatchingQueryMatchesNothing(); + + void applyTagsAddsAndRemoves(); + void applyTagsEmitsTheChange(); + void applyTagsIgnoresUnknownMessageIds(); + void applyTagsWithNoIdsDoesNothing(); + void queryStillWorksAfterWrite(); + + void applyTagsToThreadsTagsEveryMessage(); + void applyTagsToThreadsSpansMultipleThreads(); + void applyTagsToThreadsWithNoThreadsDoesNothing(); + +private: + /// Tags of one message, read back through a fresh worker query. + QStringList tagsOf(const QString &messageId); + QVector<MessageRef> messagesOfThread(const QString &threadId, + const QString &matchQuery = QString()); + QVector<ThreadSummary> runQuery(const QString &query); + QString threadIdOf(const QString &subject); + + NotmuchFixture m_fixture; +}; + +void TestNotmuchWorker::initTestCase() +{ + QVERIFY(m_fixture.isValid()); + + // Thread A: two messages, a reply. Both read. + QVERIFY(m_fixture.addMessage(QStringLiteral("inbox"), QStringLiteral("a1@example.org"), + QStringLiteral("Release notes"), + QStringLiteral("Alice <alice@example.org>"), + QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"), + QStringLiteral("first message"), false)); + QVERIFY(m_fixture.addMessage(QStringLiteral("inbox"), QStringLiteral("a2@example.org"), + QStringLiteral("Re: Release notes"), + QStringLiteral("Bob <bob@example.org>"), + QStringLiteral("Tue, 2 Jun 2026 10:00:00 +0000"), + QStringLiteral("second message with hamsterwheel"), false, + QStringLiteral("a1@example.org"))); + + // Thread B: one unread message. + QVERIFY(m_fixture.addMessage(QStringLiteral("inbox"), QStringLiteral("b1@example.org"), + QStringLiteral("Newsletter"), + QStringLiteral("Carol <carol@example.org>"), + QStringLiteral("Wed, 3 Jun 2026 10:00:00 +0000"), + QStringLiteral("third message"))); + + // Thread C: in a different folder, for path-scoped queries. + QVERIFY(m_fixture.addMessage(QStringLiteral("archive"), QStringLiteral("c1@example.org"), + QStringLiteral("Old thing"), + QStringLiteral("Dave <dave@example.org>"), + QStringLiteral("Thu, 4 Jun 2026 10:00:00 +0000"), + QStringLiteral("fourth message"), false)); + + QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error())); +} + +QVector<ThreadSummary> TestNotmuchWorker::runQuery(const QString &query) +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy ready(&worker, &NotmuchWorker::threadsReady); + QSignalSpy finished(&worker, &NotmuchWorker::queryFinished); + + worker.runQuery(query, 1); + + QVector<ThreadSummary> all; + for (const QList<QVariant> &args : ready) + all += args.at(0).value<QVector<ThreadSummary>>(); + return all; +} + +QString TestNotmuchWorker::threadIdOf(const QString &subject) +{ + const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*")); + for (const ThreadSummary &t : threads) { + if (t.subject == subject) + return t.threadId; + } + return QString(); +} + +QVector<MessageRef> TestNotmuchWorker::messagesOfThread(const QString &threadId, + const QString &matchQuery) +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded); + worker.loadThread(threadId, matchQuery, 1); + if (loaded.isEmpty()) + return {}; + return loaded.first().at(0).value<QVector<MessageRef>>(); +} + +QStringList TestNotmuchWorker::tagsOf(const QString &messageId) +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded); + worker.loadThread(QStringLiteral("{id:%1}").arg(messageId), QString(), 1); + if (loaded.isEmpty()) + return {}; + const auto messages = loaded.first().at(0).value<QVector<MessageRef>>(); + for (const MessageRef &m : messages) { + if (m.messageId == messageId) + return m.tags; + } + return {}; +} + +void TestNotmuchWorker::queryReturnsAllThreads() +{ + const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*")); + QCOMPARE(threads.size(), 3); +} + +void TestNotmuchWorker::queryFiltersByTag() +{ + const QVector<ThreadSummary> unread = runQuery(QStringLiteral("tag:unread")); + QCOMPARE(unread.size(), 1); + QCOMPARE(unread.first().subject, QStringLiteral("Newsletter")); + QVERIFY(unread.first().isUnread()); +} + +void TestNotmuchWorker::queryReportsThreadMetadata() +{ + const QVector<ThreadSummary> threads = runQuery(QStringLiteral("subject:\"Release notes\"")); + QCOMPARE(threads.size(), 1); + + const ThreadSummary &t = threads.first(); + QVERIFY(!t.threadId.isEmpty()); + QCOMPARE(t.subject, QStringLiteral("Release notes")); + QVERIFY(t.authors.contains(QStringLiteral("Alice"))); + QCOMPARE(t.totalCount, 2); + QVERIFY(t.date.isValid()); + QVERIFY(t.tags.contains(QStringLiteral("inbox"))); +} + +void TestNotmuchWorker::malformedQueryYieldsNoThreads() +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy ready(&worker, &NotmuchWorker::threadsReady); + QSignalSpy finished(&worker, &NotmuchWorker::queryFinished); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + // notmuch's query parser is lenient: an unbalanced quote is accepted and + // simply matches nothing, rather than failing. Verified against notmuch + // 0.39, which exits 0 on this query. So the contract here is "no threads, + // no error, one queryFinished with zero" — not an error path. + worker.runQuery(QStringLiteral("subject:\"unterminated"), 1); + + QVERIFY(ready.isEmpty()); + QVERIFY(errors.isEmpty()); + QCOMPARE(finished.size(), 1); + QCOMPARE(finished.first().at(0).toInt(), 0); +} + +void TestNotmuchWorker::unreadableConfigEmitsError() +{ + // Fails closed: a bad config path must report an error, never silently + // fall through to the user's real database. + NotmuchWorker worker(QStringLiteral("/nonexistent/qtmaildir-test/config")); + QSignalSpy ready(&worker, &NotmuchWorker::threadsReady); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.runQuery(QStringLiteral("*"), 1); + + QCOMPARE(errors.size(), 1); + QVERIFY(ready.isEmpty()); +} + +void TestNotmuchWorker::queryPassesGenerationThrough() +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy ready(&worker, &NotmuchWorker::threadsReady); + QSignalSpy finished(&worker, &NotmuchWorker::queryFinished); + + worker.runQuery(QStringLiteral("*"), 42); + + QCOMPARE(ready.size(), 1); + QCOMPARE(ready.first().at(1).value<quint64>(), quint64(42)); + QCOMPARE(finished.size(), 1); + QCOMPARE(finished.first().at(0).toInt(), 3); + QCOMPARE(finished.first().at(1).value<quint64>(), quint64(42)); +} + +void TestNotmuchWorker::loadThreadReturnsMessagesOldestFirst() +{ + const QString threadId = threadIdOf(QStringLiteral("Release notes")); + QVERIFY(!threadId.isEmpty()); + + const QVector<MessageRef> messages = messagesOfThread(threadId); + QCOMPARE(messages.size(), 2); + QCOMPARE(messages.at(0).messageId, QStringLiteral("a1@example.org")); + QCOMPARE(messages.at(1).messageId, QStringLiteral("a2@example.org")); + QVERIFY(QFile::exists(messages.at(0).filePath)); + QVERIFY(messages.at(0).tags.contains(QStringLiteral("inbox"))); +} + +void TestNotmuchWorker::loadThreadMarksMatchedMessages() +{ + const QString threadId = threadIdOf(QStringLiteral("Release notes")); + QVERIFY(!threadId.isEmpty()); + + // Only the second message contains this word. + const QVector<MessageRef> messages = + messagesOfThread(threadId, QStringLiteral("hamsterwheel")); + QCOMPARE(messages.size(), 2); + QVERIFY(!messages.at(0).matched); + QVERIFY(messages.at(1).matched); +} + +void TestNotmuchWorker::loadThreadWithEmptyQueryMatchesEverything() +{ + const QString threadId = threadIdOf(QStringLiteral("Release notes")); + const QVector<MessageRef> messages = messagesOfThread(threadId, QString()); + QCOMPARE(messages.size(), 2); + for (const MessageRef &m : messages) + QVERIFY(m.matched); +} + +void TestNotmuchWorker::loadThreadWithNonMatchingQueryMatchesNothing() +{ + const QString threadId = threadIdOf(QStringLiteral("Release notes")); + + // A real query that matches nothing in this thread must mark every message + // unmatched. Treating "no matches" as "everything matched" would render a + // whole thread expanded when the user filtered it down to nothing. + const QVector<MessageRef> messages = + messagesOfThread(threadId, QStringLiteral("tag:thistagdoesnotexist")); + QCOMPARE(messages.size(), 2); + for (const MessageRef &m : messages) + QVERIFY(!m.matched); +} + +void TestNotmuchWorker::applyTagsAddsAndRemoves() +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + const TagChange change{ { QStringLiteral("a1@example.org") }, + { QStringLiteral("flagged") }, + { QStringLiteral("inbox") }, + QStringLiteral("Flag and archive") }; + worker.applyTags(change); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + + const QStringList tags = tagsOf(QStringLiteral("a1@example.org")); + QVERIFY(tags.contains(QStringLiteral("flagged"))); + QVERIFY(!tags.contains(QStringLiteral("inbox"))); + + // Put it back so later tests see the original state. + NotmuchWorker restore(m_fixture.configPath()); + restore.applyTags(change.inverted()); + const QStringList back = tagsOf(QStringLiteral("a1@example.org")); + QVERIFY(back.contains(QStringLiteral("inbox"))); + QVERIFY(!back.contains(QStringLiteral("flagged"))); +} + +void TestNotmuchWorker::applyTagsEmitsTheChange() +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied); + + const TagChange change{ { QStringLiteral("b1@example.org") }, + { QStringLiteral("testtag") }, + {}, + QStringLiteral("Add testtag") }; + worker.applyTags(change); + + QCOMPARE(applied.size(), 1); + const TagChange emitted = applied.first().at(0).value<TagChange>(); + QCOMPARE(emitted.messageIds, change.messageIds); + QCOMPARE(emitted.added, change.added); + QCOMPARE(emitted.description, change.description); + + NotmuchWorker restore(m_fixture.configPath()); + restore.applyTags(change.inverted()); +} + +void TestNotmuchWorker::applyTagsIgnoresUnknownMessageIds() +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied); + + // A stale id from a since-deleted message must not abort the whole batch: + // the real ids alongside it still need tagging. + const TagChange change{ { QStringLiteral("nosuchmessage@example.org"), + QStringLiteral("b1@example.org") }, + { QStringLiteral("survivor") }, + {}, + QStringLiteral("Partially stale batch") }; + worker.applyTags(change); + + QCOMPARE(applied.size(), 1); + QVERIFY(tagsOf(QStringLiteral("b1@example.org")).contains(QStringLiteral("survivor"))); + + NotmuchWorker restore(m_fixture.configPath()); + restore.applyTags(change.inverted()); +} + +void TestNotmuchWorker::applyTagsWithNoIdsDoesNothing() +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.applyTags(TagChange{ {}, { QStringLiteral("x") }, {}, QStringLiteral("Nothing") }); + + QVERIFY(applied.isEmpty()); + QVERIFY(errors.isEmpty()); +} + +void TestNotmuchWorker::queryStillWorksAfterWrite() +{ + // applyTags closes the read-only handle to take the write lock. The same + // worker must be able to query again afterwards. + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy ready(&worker, &NotmuchWorker::threadsReady); + + worker.runQuery(QStringLiteral("*"), 1); + QCOMPARE(ready.size(), 1); + + const TagChange change{ { QStringLiteral("b1@example.org") }, + { QStringLiteral("roundtrip") }, + {}, + QStringLiteral("Round trip") }; + worker.applyTags(change); + + worker.runQuery(QStringLiteral("*"), 2); + QCOMPARE(ready.size(), 2); + QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 3); + + worker.applyTags(change.inverted()); +} + +void TestNotmuchWorker::applyTagsToThreadsTagsEveryMessage() +{ + const QString threadId = threadIdOf(QStringLiteral("Release notes")); + QVERIFY(!threadId.isEmpty()); + + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied); + + worker.applyTagsToThreads({ threadId }, { QStringLiteral("batched") }, {}, + QStringLiteral("Batch tag")); + + QCOMPARE(applied.size(), 1); + // Both messages of the thread, resolved by the worker, not by the caller. + const TagChange emitted = applied.first().at(0).value<TagChange>(); + QCOMPARE(emitted.messageIds.size(), 2); + QVERIFY(tagsOf(QStringLiteral("a1@example.org")).contains(QStringLiteral("batched"))); + QVERIFY(tagsOf(QStringLiteral("a2@example.org")).contains(QStringLiteral("batched"))); + + NotmuchWorker restore(m_fixture.configPath()); + restore.applyTags(emitted.inverted()); +} + +void TestNotmuchWorker::applyTagsToThreadsSpansMultipleThreads() +{ + const QString threadA = threadIdOf(QStringLiteral("Release notes")); + const QString threadB = threadIdOf(QStringLiteral("Newsletter")); + QVERIFY(!threadA.isEmpty()); + QVERIFY(!threadB.isEmpty()); + + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied); + + worker.applyTagsToThreads({ threadA, threadB }, { QStringLiteral("multi") }, {}, + QStringLiteral("Multi-thread tag")); + + QCOMPARE(applied.size(), 1); + const TagChange emitted = applied.first().at(0).value<TagChange>(); + QCOMPARE(emitted.messageIds.size(), 3); + QVERIFY(tagsOf(QStringLiteral("a1@example.org")).contains(QStringLiteral("multi"))); + QVERIFY(tagsOf(QStringLiteral("b1@example.org")).contains(QStringLiteral("multi"))); + + NotmuchWorker restore(m_fixture.configPath()); + restore.applyTags(emitted.inverted()); +} + +void TestNotmuchWorker::applyTagsToThreadsWithNoThreadsDoesNothing() +{ + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.applyTagsToThreads({}, { QStringLiteral("x") }, {}, QStringLiteral("Nothing")); + + QVERIFY(applied.isEmpty()); + QVERIFY(errors.isEmpty()); +} + +QTEST_MAIN(TestNotmuchWorker) +#include "test_notmuchworker.moc" |
