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.cpp180
1 files changed, 176 insertions, 4 deletions
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 970f51d..b2810d1 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -54,6 +54,7 @@ private slots:
void applyTagsWithNoIdsDoesNothing();
void applyTagsReportsOnlyTheMessagesItChanged();
void applyTagsThatChangeNothingEmitNothing();
+ void applyTagsKeepsOnlyTheNewestOriginTag();
void queryStillWorksAfterWrite();
void aThreadCarriesItsCardMessagesOwnTags();
@@ -99,6 +100,8 @@ private slots:
void requestFoldersOnUnreadableConfigEmitsError();
void moveMessagesRelocatesTheFile();
+ void moveMessagesToSpamRelocatesTheFileAndTagsIt();
+ void aSecondMoveToSpamKeepsOnlyTheNewestOriginTag();
void moveMessagesReindexesAtTheNewPath();
void moveMessagesKeepsTheMessagesTags();
void moveMessagesReportsOnlyWhatMoved();
@@ -142,8 +145,10 @@ private:
QString fileOf(const QString &messageId,
const QString &configPath = QString());
- /// Tags of one message, read back through a fresh worker query.
- QStringList tagsOf(const QString &messageId);
+ /// Tags of one message, read back through a fresh worker query. Defaults to
+ /// the shared fixture; a test with its own fixture passes its own path.
+ QStringList tagsOf(const QString &messageId,
+ const QString &configPath = QString());
QVector<MessageRef> messagesOfThread(const QString &threadId,
const QString &matchQuery = QString(),
bool matchedOnly = false);
@@ -401,9 +406,11 @@ QVector<MessageRef> TestNotmuchWorker::messagesOfThread(const QString &threadId,
return loaded.first().at(0).value<QVector<MessageRef>>();
}
-QStringList TestNotmuchWorker::tagsOf(const QString &messageId)
+QStringList TestNotmuchWorker::tagsOf(const QString &messageId,
+ const QString &configPath)
{
- NotmuchWorker worker(m_fixture.configPath());
+ NotmuchWorker worker(configPath.isEmpty() ? m_fixture.configPath()
+ : configPath);
QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded);
worker.loadThread(QStringLiteral("{id:%1}").arg(messageId), QString(), 1);
if (loaded.isEmpty())
@@ -1117,6 +1124,68 @@ void TestNotmuchWorker::applyTagsThatChangeNothingEmitNothing()
QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
}
+void TestNotmuchWorker::applyTagsKeepsOnlyTheNewestOriginTag()
+{
+ // A message that travelled inbox -> spam -> trash must hold exactly ONE
+ // `moved-from:` tag, the newest, or Restore's first-match scan picks an
+ // origin arbitrarily. Its own fixture, so the tags this test leaves behind
+ // cannot move the shared one's thread counts.
+ NotmuchFixture fixture;
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("origin1@example.org"),
+ QStringLiteral("Travelled"),
+ QStringLiteral("Erin <erin@example.org>"),
+ QStringLiteral("Sun, 7 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), false));
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("origin2@example.org"),
+ QStringLiteral("Fresh"),
+ QStringLiteral("Erin <erin@example.org>"),
+ QStringLiteral("Sun, 7 Jun 2026 11:00:00 +0000"),
+ QStringLiteral("body"), false));
+ QVERIFY(fixture.index());
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ // An earlier move left an origin behind.
+ worker.applyTags(TagChange{ { QStringLiteral("origin1@example.org") },
+ { QStringLiteral("moved-from:inbox") },
+ {},
+ QStringLiteral("Earlier move") });
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+ QVERIFY(tagsOf(QStringLiteral("origin1@example.org"), fixture.configPath())
+ .contains(QStringLiteral("moved-from:inbox")));
+
+ // The move under test: a new origin lands while the old one is still there.
+ worker.applyTags(TagChange{
+ { QStringLiteral("origin1@example.org"),
+ QStringLiteral("origin2@example.org") },
+ { QStringLiteral("moved-from:Spam"), QStringLiteral("spam") },
+ { QStringLiteral("inbox"), QStringLiteral("unread") },
+ QStringLiteral("Mark spam") });
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ const QStringList travelled =
+ tagsOf(QStringLiteral("origin1@example.org"), fixture.configPath());
+ QVERIFY(!travelled.contains(QStringLiteral("moved-from:inbox")));
+ QVERIFY(travelled.contains(QStringLiteral("moved-from:Spam")));
+ int origins = 0;
+ for (const QString &tag : travelled) {
+ if (tag.startsWith(QStringLiteral("moved-from:")))
+ ++origins;
+ }
+ QCOMPARE(origins, 1);
+
+ // The message that never carried an origin keeps exactly the new one, and
+ // the move still removed what a move removes.
+ const QStringList fresh =
+ tagsOf(QStringLiteral("origin2@example.org"), fixture.configPath());
+ QVERIFY(fresh.contains(QStringLiteral("moved-from:Spam")));
+ QVERIFY(fresh.contains(QStringLiteral("spam")));
+ QVERIFY(!fresh.contains(QStringLiteral("inbox")));
+}
+
void TestNotmuchWorker::queryStillWorksAfterWrite()
{
// applyTags closes the read-only handle to take the write lock. The same
@@ -1729,6 +1798,109 @@ void TestNotmuchWorker::moveMessagesRelocatesTheFile()
QVERIFY(!QFile::exists(before));
}
+void TestNotmuchWorker::moveMessagesToSpamRelocatesTheFileAndTagsIt()
+{
+ // Mark spam is Delete's sibling, and its own fixture rather than the
+ // shared one: this needs an UNREAD message so the `unread` removal the
+ // account's spam folder config implies is actually observable. The shared
+ // fixture's movable messages are all read, which would make that assertion
+ // pass against nothing.
+ NotmuchFixture fixture;
+ QVERIFY(fixture.isValid());
+ const QString id = QStringLiteral("spam1@example.org");
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"), id,
+ QStringLiteral("Suspect"),
+ QStringLiteral("Erin <erin@example.org>"),
+ QStringLiteral("Sun, 7 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), true));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ QVERIFY2(tagsOf(id, fixture.configPath()).contains(QStringLiteral("unread")),
+ "the fixture message is already read, so the unread removal below "
+ "would prove nothing");
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy moved(&worker, &NotmuchWorker::messagesMoved);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.moveMessages({ id }, QStringLiteral("spam"));
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ QCOMPARE(moved.size(), 1);
+ QCOMPARE(moved.first().at(0).toStringList(), QStringList{ id });
+ QCOMPARE(moved.first().at(1).toString(), QStringLiteral("spam"));
+
+ const QString expectedDir =
+ fixture.maildirPath() + QStringLiteral("/spam/cur");
+ const QString after = fileOf(id, fixture.configPath());
+ QVERIFY2(!after.isEmpty(),
+ "the message is not in the database after the move");
+ QCOMPARE(QFileInfo(after).absolutePath(), expectedDir);
+ QVERIFY2(QFile::exists(after), qPrintable(after));
+
+ // The tag half travels with the move the way onMessagesMoved() composes
+ // it: `spam` and the origin in, `unread` and `inbox` out.
+ worker.applyTags(TagChange{ { id },
+ { QStringLiteral("spam"),
+ QStringLiteral("moved-from:inbox") },
+ { QStringLiteral("unread"),
+ QStringLiteral("inbox") },
+ QStringLiteral("Mark spam") });
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ const QStringList tags = tagsOf(id, fixture.configPath());
+ QVERIFY(tags.contains(QStringLiteral("spam")));
+ QVERIFY(tags.contains(QStringLiteral("moved-from:inbox")));
+ QVERIFY(!tags.contains(QStringLiteral("unread")));
+}
+
+void TestNotmuchWorker::aSecondMoveToSpamKeepsOnlyTheNewestOriginTag()
+{
+ // The overwrite rule, reached through a second move: inbox -> spam -> a
+ // later move that writes `moved-from:Spam` must leave exactly ONE
+ // `moved-from:` tag, the newest. Two would make Restore's first-match scan
+ // pick an origin arbitrarily, and the message would go home by a coin toss.
+ NotmuchFixture fixture;
+ QVERIFY(fixture.isValid());
+ const QString id = QStringLiteral("reorigin@example.org");
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"), id,
+ QStringLiteral("Travelled"),
+ QStringLiteral("Erin <erin@example.org>"),
+ QStringLiteral("Sun, 7 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), true));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ // An earlier move left an origin behind.
+ worker.applyTags(TagChange{ { id },
+ { QStringLiteral("moved-from:inbox") },
+ {},
+ QStringLiteral("Earlier move") });
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ // The move under test: a new origin lands while the old one is still there.
+ worker.applyTags(TagChange{ { id },
+ { QStringLiteral("spam"),
+ QStringLiteral("moved-from:Spam") },
+ { QStringLiteral("unread"),
+ QStringLiteral("inbox") },
+ QStringLiteral("Mark spam") });
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ const QStringList tags = tagsOf(id, fixture.configPath());
+ QVERIFY(!tags.contains(QStringLiteral("moved-from:inbox")));
+ QVERIFY(tags.contains(QStringLiteral("moved-from:Spam")));
+
+ int origins = 0;
+ for (const QString &tag : tags) {
+ if (tag.startsWith(QStringLiteral("moved-from:")))
+ ++origins;
+ }
+ QCOMPARE(origins, 1);
+}
+
void TestNotmuchWorker::purgeMessagesDoesNotClaimAnIdItCouldNotDelete()
{
// The report drives what the UI tells the user, and the one number they