aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_notmuchworker.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_notmuchworker.cpp')
-rw-r--r--tests/test_notmuchworker.cpp133
1 files changed, 133 insertions, 0 deletions
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index d02f8bd..e1a21cd 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -92,6 +92,8 @@ private slots:
void moveMessagesReportsOnlyWhatMoved();
void moveMessagesGivesTheFileAFreshMaildirName();
void moveMessagesKeepsTheMaildirFlags();
+ void moveMessagesRecoversWhenASyncRenamedTheFile();
+ void moveMessagesStillReportsAMessageThatIsReallyGone();
void indexDraftFileMakesAFileFindable();
void indexDraftFileRemovesThePreviousFile();
@@ -102,6 +104,8 @@ private slots:
void aSplitIndexListsTheMaildirsFolders();
void twoMessagesMovedTogetherGetDistinctNames();
+ void aQuerySeesMailIndexedAfterTheWorkerOpened();
+
private:
/// Adds one read message in `folder` and reindexes, for the move tests.
/// Each of those takes its own message, because a move is destructive and
@@ -184,6 +188,59 @@ void TestNotmuchWorker::initTestCase()
QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error()));
}
+void TestNotmuchWorker::aQuerySeesMailIndexedAfterTheWorkerOpened()
+{
+ // The defect this covers is item 104, and it is the reason mail arriving
+ // while the window is open was invisible until the application restarted.
+ //
+ // A read-only notmuch handle is a Xapian SNAPSHOT taken when it is opened.
+ // `notmuch new` runs in a separate process, so nothing it writes is visible
+ // to a handle already open, however long it is held and however many
+ // queries are run through it. The worker opens once and keeps that handle
+ // for the process lifetime, so every query after the first sync answered
+ // from a stale index: a refresh missed the mail, and so did a query the
+ // user typed by hand, which is what ruled out the model and the generation
+ // counter when this was diagnosed.
+ //
+ // ONE worker across both queries is the whole point. The runQuery() helper
+ // builds a fresh worker per call, which opens a fresh handle and therefore
+ // cannot reproduce this at all: a test written through it passes against
+ // the bug.
+ NotmuchWorker worker(m_fixture.configPath());
+
+ const QString query = QStringLiteral("subject:\"Arrived mid-session\"");
+
+ {
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+ worker.runQuery(query, 1);
+ QVector<ThreadSummary> before;
+ for (const QList<QVariant> &args : ready)
+ before += args.at(0).value<QVector<ThreadSummary>>();
+ // Establishes that the handle is open and the query is well-formed,
+ // rather than leaving "found nothing" to mean either.
+ QCOMPARE(before.size(), 0);
+ }
+
+ // A second process writes to the index, exactly as the sync script's
+ // `notmuch new` does while the window is open.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("mid@example.org"),
+ QStringLiteral("Arrived mid-session"),
+ QStringLiteral("Carol <carol@example.org>"),
+ QStringLiteral("Tue, 9 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("new mail")));
+ QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error()));
+
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+ worker.runQuery(query, 2);
+ QVector<ThreadSummary> after;
+ for (const QList<QVariant> &args : ready)
+ after += args.at(0).value<QVector<ThreadSummary>>();
+
+ QCOMPARE(after.size(), 1);
+ QCOMPARE(after.first().subject, QStringLiteral("Arrived mid-session"));
+}
+
QVector<ThreadSummary> TestNotmuchWorker::runQuery(
const QString &query, NotmuchWorker::SortOrder sort, bool withRecipients)
{
@@ -1365,6 +1422,82 @@ void TestNotmuchWorker::moveMessagesKeepsTheMaildirFlags()
QVERIFY(!name.contains(QStringLiteral(",U=")));
}
+void TestNotmuchWorker::moveMessagesRecoversWhenASyncRenamedTheFile()
+{
+ // Item 162. mbsync uploads a file and RENAMES it to record the server UID,
+ // and notmuch keeps the pre-`U=` name until that sync's `notmuch new`
+ // runs. moveMessages() then renames a path that no longer exists, reports
+ // "Cannot move <file> to <folder>", and silently does nothing.
+ //
+ // The ordinary fixture layout cannot see this: nothing renames a file
+ // underneath the index. Driving it means renaming the file WITHOUT
+ // reindexing, which is exactly the window mbsync opens.
+ const QString id = QStringLiteral("move-stale@example.org");
+ QVERIFY2(addMovableMessage(QStringLiteral("inbox"), id),
+ qPrintable(m_fixture.error()));
+
+ const QString indexed = fileOf(id);
+ QVERIFY(!indexed.isEmpty());
+
+ // mbsync's rename, and deliberately NO m_fixture.index() afterwards: the
+ // database must still name the old path, which is the whole precondition.
+ const QString renamed = QFileInfo(indexed).absolutePath()
+ + QStringLiteral("/move-stale.example.org,U=7:2,D");
+ QVERIFY2(QFile::rename(indexed, renamed), "could not stage the sync rename");
+
+ // The guard that proves this test can fail: without it, a fixture that
+ // quietly reindexed would make the assertions below pass against the bug.
+ QCOMPARE(fileOf(id), indexed);
+ QVERIFY2(!QFile::exists(indexed), "the stale path should no longer exist");
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy moved(&worker, &NotmuchWorker::messagesMoved);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.moveMessages({ id }, QStringLiteral("trash"));
+
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+ QCOMPARE(moved.size(), 1);
+ QCOMPARE(moved.first().at(0).toStringList(), QStringList{ id });
+
+ // The file really moved, and the database followed it.
+ const QString after = fileOf(id);
+ QVERIFY2(!after.isEmpty(), "the message is not in the database after the move");
+ QCOMPARE(QFileInfo(after).absolutePath(),
+ m_fixture.maildirPath() + QStringLiteral("/trash/cur"));
+ QVERIFY2(QFile::exists(after), qPrintable(after));
+ QVERIFY(!QFile::exists(renamed));
+
+ // The `,U=` infix must not be carried across the folder boundary: that is
+ // what produced `Maildir error: duplicate UID` on real mail.
+ QVERIFY(!QFileInfo(after).fileName().contains(QStringLiteral(",U=")));
+}
+
+void TestNotmuchWorker::moveMessagesStillReportsAMessageThatIsReallyGone()
+{
+ // The bounded half of the recovery above. A file that is genuinely absent,
+ // rather than merely renamed, must still be REPORTED: recovering silently
+ // from every missing path would turn a real defect into a move that
+ // claims success and does nothing.
+ const QString id = QStringLiteral("move-gone@example.org");
+ QVERIFY2(addMovableMessage(QStringLiteral("inbox"), id),
+ qPrintable(m_fixture.error()));
+
+ const QString indexed = fileOf(id);
+ QVERIFY(!indexed.isEmpty());
+ QVERIFY2(QFile::remove(indexed), "could not remove the file");
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy moved(&worker, &NotmuchWorker::messagesMoved);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.moveMessages({ id }, QStringLiteral("trash"));
+
+ QCOMPARE(errors.size(), 1);
+ // Nothing is claimed to have moved.
+ QVERIFY(moved.isEmpty() || moved.first().at(0).toStringList().isEmpty());
+}
+
void TestNotmuchWorker::twoMessagesMovedTogetherGetDistinctNames()
{
// The generated name must be unique, since a collision is the entire class