aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp459
-rw-r--r--tests/test_notmuchworker.cpp131
2 files changed, 588 insertions, 2 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 21c3661..f76ff70 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -260,6 +260,7 @@ private slots:
void narrowingAnEmptyQueryBarIsAPlainSearch();
void aMalformedAccountIsReportedWithoutBlockingTheConstructor();
void aWorkerBackedWindowReturnsRealThreads();
+ void aPurgeTakesTheRowsOutOfTheViewWithoutARefresh();
// Compose and send, item 123 task 12.
void theMailRootComesFromTheConfigNotTheIndex();
@@ -383,6 +384,15 @@ private slots:
void editTagsOnAReplyCountsItsOwnThreadNotTheFirstInTheList();
void markCurrentThreadReadResolvesTheThreadThroughTheIndex();
void deletingAReplyRepaintsThatReplyRow();
+ void deleteIsHiddenOnMailAlreadyInTheTrash();
+ void restoreIsHiddenOnMailThatWasNeverDeleted();
+ void deleteAlsoMarksTheMessageRead();
+ void emptyTrashAsksBeforeDestroyingAnything();
+ void theUnreadLabelSaysWhichDirectionItWillGo();
+ void theUnreadLabelFollowsAWriteWithoutReselecting();
+ void theUnreadActionIsHiddenOnAMixedSelection();
+ void markThreadUnreadReachesAMixedThread();
+ void markThreadReadAndUnreadAreSeparateActions();
void toggleUnreadOnAReplyReadsTheReplysOwnState();
void toggleUnreadOnAReplyRepaintsItInBothDirections();
void taggingTheOpenReplyUpdatesTheMessagePaneStrip();
@@ -5207,6 +5217,408 @@ void TestMainWindow::deletingAReplyRepaintsThatReplyRow()
"deleting one reply marked its whole thread deleted");
}
+/// A window whose one account owns `acct/`, with its trash at `acct/trash`.
+///
+/// Delete and Restore both ask about a row's PATH, so a test for either needs
+/// a config that says which prefix is a trash folder. Bare-window tests carry
+/// no account at all and would answer "not in the trash" for every row.
+static Config configWithTrash(QTemporaryDir &dir)
+{
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ QTextStream out(&file);
+ out << "[account.acct]\n"
+ << "maildir = acct\n"
+ << "trash = trash\n"
+ << "inbox = inbox\n";
+ }
+ Config config;
+ config.load(path);
+ return config;
+}
+
+/// One thread row whose displayed message sits at `filePath`.
+static ThreadSummary threadAtPath(const QString &id, const QString &filePath,
+ const QStringList &tags = {})
+{
+ ThreadSummary thread = makeThread(id, tags);
+ thread.firstMessagePath = filePath;
+ thread.firstMessageTags = tags;
+ return thread;
+}
+
+void TestMainWindow::deleteIsHiddenOnMailAlreadyInTheTrash()
+{
+ // Item 168, from the user: "I noticed I can hit delete via context menu on
+ // a message already in the trash."
+ //
+ // It was not dangerous, which is the part that made it survive: the file
+ // is already in the destination, so moveMessages() takes its
+ // already-there branch, reports the message as moved and counts an
+ // unsynced change for a move that never happened. The menu claimed to
+ // have done something and nothing had.
+ //
+ // The question is about the PATH, never the `deleted` TAG: a message
+ // trashed by another client carries no such tag, which is why item 103
+ // made the trash view path-based, and asking the tag would offer Delete on
+ // exactly the mail a trash view is full of.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const Config config = configWithTrash(dir);
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *deleteAction =
+ window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(deleteAction);
+
+ model->appendBatch({
+ threadAtPath(QStringLiteral("t1"),
+ QStringLiteral("acct/inbox/cur/1:2,S")),
+ threadAtPath(QStringLiteral("t2"),
+ QStringLiteral("acct/trash/cur/2:2,S")),
+ });
+
+ view->setCurrentIndex(model->index(0, 0, {}));
+ QVERIFY2(deleteAction->isVisible(),
+ "Delete is hidden on mail that is NOT in the trash, so this test "
+ "cannot tell the two cases apart");
+
+ view->setCurrentIndex(model->index(1, 0, {}));
+ QVERIFY2(!deleteAction->isVisible(),
+ "Delete is still offered on a message already in the trash, "
+ "where it reports success and does nothing");
+
+ // A folder whose name STARTS with the trash folder's is a different
+ // folder. Without the trailing separator `acct/trash-old` matches
+ // `acct/trash` and Delete silently disappears from mail that was never
+ // trashed, which is the quiet half of the same mistake.
+ model->appendBatch({ threadAtPath(QStringLiteral("t3"),
+ QStringLiteral("acct/trash-old/cur/3:2,S")) });
+ view->setCurrentIndex(model->index(2, 0, {}));
+ QVERIFY2(deleteAction->isVisible(),
+ "Delete vanished on mail in acct/trash-old, which is not the "
+ "trash: the prefix was compared without its separator");
+}
+
+void TestMainWindow::restoreIsHiddenOnMailThatWasNeverDeleted()
+{
+ // The mirror, shipped beside it: `restore` was added unconditionally to
+ // both menus, so it was offered on mail that was never deleted, where it
+ // has as little meaning as Delete has in the trash.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const Config config = configWithTrash(dir);
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *restore = window.findChild<QAction *>(QStringLiteral("restore"));
+ QVERIFY(restore);
+
+ model->appendBatch({
+ threadAtPath(QStringLiteral("t1"),
+ QStringLiteral("acct/inbox/cur/1:2,S")),
+ threadAtPath(QStringLiteral("t2"),
+ QStringLiteral("acct/trash/cur/2:2,S")),
+ });
+
+ view->setCurrentIndex(model->index(1, 0, {}));
+ QVERIFY2(restore->isVisible(), "Restore is hidden on trashed mail");
+
+ view->setCurrentIndex(model->index(0, 0, {}));
+ QVERIFY2(!restore->isVisible(),
+ "Restore is still offered on mail that was never deleted");
+}
+
+void TestMainWindow::deleteAlsoMarksTheMessageRead()
+{
+ // The user's second request on the same tangent: "messages moved to the
+ // trash should be automatically marked -unread". Deleting is a decision
+ // about the message, so the unread count must not go on including what
+ // the user threw away.
+ //
+ // Asserted on the undo TEXT and depth rather than on the tags: the write
+ // is a move, which a bare window cannot complete, but the tag change it
+ // composes is pushed as one command either way. One command, not two, is
+ // the property that matters: undo has to return the folder AND the tag
+ // together.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const Config config = configWithTrash(dir);
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ model->appendBatch({ threadAtPath(QStringLiteral("t1"),
+ QStringLiteral("acct/inbox/cur/1:2,S"),
+ { QStringLiteral("unread") }) });
+ const QModelIndex row = model->index(0, 0, {});
+ view->setCurrentIndex(row);
+
+ QVERIFY2(model->threadFor(row).isUnread(),
+ "the fixture is already read, so this test cannot see the tag go");
+
+ auto *deleteAction = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(deleteAction);
+ deleteAction->trigger();
+
+ QVERIFY2(!model->threadFor(row).isUnread(),
+ "Delete left the message unread in the trash");
+}
+
+void TestMainWindow::emptyTrashAsksBeforeDestroyingAnything()
+{
+ // Item 118, and the one place this application asks. CLAUDE.md rules out
+ // confirmation dialogs for mutations because every mutation pushes its
+ // inverse onto the undo stack; a purge has no inverse, so the rule does
+ // not reach it. What the rule protects is that a user never loses work to
+ // a keystroke, and here the dialog is what provides that rather than
+ // contradicting it.
+ //
+ // Asserting the action EXISTS and is wired, not the dialog's buttons: a
+ // modal cannot be driven from a test without blocking it (item 84), so
+ // the dialog itself is a hand test. What is pinned here is that nothing
+ // is destroyed without going through it.
+ const Config config;
+ MainWindow window(config);
+
+ auto *action = window.findChild<QAction *>(QStringLiteral("empty_trash"));
+ QVERIFY2(action, "empty_trash does not exist");
+
+ // Reachable from a menu, which everyActionIsReachableFromAMenu() also
+ // enforces globally. Named here as well because an unreachable purge is
+ // worse than an unreachable anything else: the user cannot discover the
+ // action, but a stray keybinding still runs it.
+ bool found = false;
+ const QList<QMenu *> menus = window.findChildren<QMenu *>();
+ for (QMenu *menu : menus) {
+ if (menu->actions().contains(action)) {
+ found = true;
+ break;
+ }
+ }
+ QVERIFY2(found, "empty_trash is in no menu");
+
+ // No shortcut, deliberately: this is the one irreversible action, and a
+ // chord is exactly how it would be run by accident.
+ QVERIFY2(action->shortcut().isEmpty(),
+ qPrintable(QStringLiteral("empty_trash carries the shortcut %1; "
+ "the one irreversible action must not "
+ "be a keystroke away")
+ .arg(action->shortcut().toString())));
+}
+
+void TestMainWindow::theUnreadLabelSaysWhichDirectionItWillGo()
+{
+ // The user's note: "the label for toggle unread should be dynamic. On an
+ // unread message it should be Mark as read, on a read message Mark as
+ // unread."
+ //
+ // "Toggle unread" reads the same whichever way it will go, so the only
+ // way to learn what it does is to press it and look. The action stays a
+ // toggle, because one message has a real two-valued state; what changes
+ // is that the label tells the truth about the direction it has chosen.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("toggle_unread"));
+ QVERIFY(action);
+
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") }),
+ makeThread(QStringLiteral("t2"), {}) });
+
+ view->setCurrentIndex(model->index(0, 0, {}));
+ QVERIFY2(action->text().contains(QStringLiteral("read")),
+ qPrintable(action->text()));
+ QVERIFY2(!action->text().contains(QStringLiteral("unread")),
+ qPrintable(QStringLiteral("an UNREAD row must offer Mark as "
+ "read, not: %1").arg(action->text())));
+
+ view->setCurrentIndex(model->index(1, 0, {}));
+ QVERIFY2(action->text().contains(QStringLiteral("unread")),
+ qPrintable(QStringLiteral("a READ row must offer Mark as unread, "
+ "not: %1").arg(action->text())));
+}
+
+void TestMainWindow::theUnreadLabelFollowsAWriteWithoutReselecting()
+{
+ // The label describes the selection's STATE, and a write moves that state
+ // without touching the selection. Marking the current row read has to
+ // leave the entry offering "Mark as unread" on the same row, or the menu
+ // offers to do again what was just done.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("toggle_unread"));
+ QVERIFY(action);
+
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") }) });
+ view->setCurrentIndex(model->index(0, 0, {}));
+ QVERIFY2(action->text().contains(QStringLiteral("read"))
+ && !action->text().contains(QStringLiteral("unread")),
+ qPrintable(action->text()));
+
+ action->trigger();
+
+ QVERIFY2(action->text().contains(QStringLiteral("unread")),
+ qPrintable(QStringLiteral("the label did not follow the write: "
+ "still offering %1 on a row it just "
+ "marked read").arg(action->text())));
+}
+
+void TestMainWindow::theUnreadActionIsHiddenOnAMixedSelection()
+{
+ // The other half of the same note: "on a thread with mixed states it
+ // should be hidden, we have a submenu for thread actions".
+ //
+ // A selection spanning an unread row and a read one has no single state,
+ // so no honest label exists for it. Hiding the entry sends the user to
+ // the thread submenu, whose entries are absolute and work regardless of
+ // the mix.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("toggle_unread"));
+ QVERIFY(action);
+
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") }),
+ makeThread(QStringLiteral("t2"), {}) });
+
+ // From a row that is already current, and NOT via selectAll(): a fresh
+ // selectAll emits no currentRowChanged at all and leaves the current
+ // index invalid, so a test using it passes against a missing guard
+ // (CLAUDE.md).
+ view->setCurrentIndex(model->index(0, 0, {}));
+ QVERIFY2(action->isVisible(), "a single row already has no single state");
+
+ view->selectionModel()->select(
+ model->index(1, 0, {}),
+ QItemSelectionModel::Select | QItemSelectionModel::Rows);
+ QCOMPARE(view->selectionModel()->selectedRows().size(), 2);
+
+ QVERIFY2(!action->isVisible(),
+ qPrintable(QStringLiteral("a mixed selection still offers the "
+ "unread action, labelled: %1")
+ .arg(action->text())));
+
+ // ...and it comes back when the selection agrees again, or the entry
+ // would be gone for the rest of the session.
+ view->selectionModel()->select(
+ model->index(1, 0, {}),
+ QItemSelectionModel::Deselect | QItemSelectionModel::Rows);
+ QVERIFY2(action->isVisible(),
+ "the action did not return when the selection agreed again");
+}
+
+void TestMainWindow::markThreadUnreadReachesAMixedThread()
+{
+ // Item 112. The user's report: on a thread with two unread replies, asking
+ // to mark the whole thread unread marked it READ instead.
+ //
+ // ThreadSummary::tags is notmuch's UNION over the conversation, so a
+ // thread containing even one unread message answers "unread" and a toggle
+ // reading that predicate always picks "mark read". There was no input that
+ // could reach "mark thread unread" on a mixed thread: the only threads
+ // taking that branch were the ones already entirely read.
+ //
+ // A union is not a state. The fix is two fixed-direction actions, so this
+ // asserts the direction rather than the resulting tags: on a mixed thread
+ // BOTH directions are reachable, which is the property that was missing.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ // MIXED: the union carries `unread` because some message is unread, while
+ // others are not. A thread whose messages are all in one state answers
+ // identically whichever way the direction is computed, so a uniform
+ // fixture passes against the bug (CLAUDE.md, item 88's opposite-states
+ // requirement).
+ model->appendBatch({ makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") }) });
+ const QModelIndex thread = model->index(0, 0, {});
+ QVERIFY(thread.isValid());
+ QVERIFY2(model->threadFor(thread).isUnread(),
+ "the fixture's union does not carry unread, so this test cannot "
+ "reach the branch the defect lives in");
+ view->setCurrentIndex(thread);
+
+ auto *markUnread =
+ window.findChild<QAction *>(QStringLiteral("mark_thread_unread"));
+ QVERIFY2(markUnread, "mark_thread_unread does not exist: the thread toggle "
+ "was not split, so a mixed thread still has no way to "
+ "be marked unread");
+ markUnread->trigger();
+
+ QVERIFY2(window.undoTextForTesting().contains(QStringLiteral("unread")),
+ qPrintable(QStringLiteral("wrong direction on a mixed thread: %1")
+ .arg(window.undoTextForTesting())));
+ QVERIFY2(!window.undoTextForTesting().contains(QStringLiteral("Mark thread read")),
+ qPrintable(QStringLiteral("marked the thread READ when asked to "
+ "mark it unread: %1")
+ .arg(window.undoTextForTesting())));
+}
+
+void TestMainWindow::markThreadReadAndUnreadAreSeparateActions()
+{
+ // The other half: the read direction must still be reachable, and must be
+ // its own action rather than the same one answering differently. Both are
+ // asserted on the SAME mixed thread, which a toggle cannot do: whichever
+ // direction it picks, the other is unreachable there.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ model->appendBatch({ makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") }) });
+ const QModelIndex thread = model->index(0, 0, {});
+ view->setCurrentIndex(thread);
+
+ auto *markRead =
+ window.findChild<QAction *>(QStringLiteral("mark_thread_read"));
+ QVERIFY(markRead);
+ markRead->trigger();
+ QVERIFY2(window.undoTextForTesting().contains(QStringLiteral("Mark thread read")),
+ qPrintable(window.undoTextForTesting()));
+
+ // The old toggle must be gone rather than left beside its replacements,
+ // which would leave the defect reachable from the menu it still sat in.
+ QVERIFY2(!window.findChild<QAction *>(QStringLiteral("toggle_unread_thread")),
+ "toggle_unread_thread still exists beside the split actions");
+}
+
void TestMainWindow::toggleUnreadOnAReplyReadsTheReplysOwnState()
{
// The user's report: "read/unread still doesn't trigger a repaint of the
@@ -5551,7 +5963,8 @@ void TestMainWindow::theThreadSubmenuIsReachableFromBothMenus()
QStringLiteral("archive_thread"),
QStringLiteral("delete_thread"),
QStringLiteral("spam_thread"),
- QStringLiteral("toggle_unread_thread"),
+ QStringLiteral("mark_thread_read"),
+ QStringLiteral("mark_thread_unread"),
QStringLiteral("flag_thread"),
};
@@ -7480,7 +7893,8 @@ void TestMainWindow::noTwoActionsShareAnIcon()
QStringLiteral("archive_thread"),
QStringLiteral("delete_thread"),
QStringLiteral("spam_thread"),
- QStringLiteral("toggle_unread_thread"),
+ QStringLiteral("mark_thread_read"),
+ QStringLiteral("mark_thread_unread"),
QStringLiteral("flag_thread"),
QStringLiteral("reply_no_quote"),
};
@@ -8458,6 +8872,47 @@ void TestMainWindow::aWorkerBackedWindowReturnsRealThreads()
QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
}
+void TestMainWindow::aPurgeTakesTheRowsOutOfTheViewWithoutARefresh()
+{
+ // Found by hand: the mail was destroyed correctly and the list went on
+ // showing it until the user re-ran the query themselves.
+ //
+ // A purge is the one mutation with no optimistic update to apply. Every
+ // other one CHANGES a row, so the model can rewrite it in place; this one
+ // takes the row away entirely, and the only honest view afterwards is the
+ // one the query gives now.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/trash"), QStringLiteral("doomed@example.org"),
+ QStringLiteral("A subject"), QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY2(backed.buildWithAccounts({ { QStringLiteral("acct"),
+ QStringLiteral("acct"),
+ QStringLiteral("trash"),
+ {}, {}, {} } }),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ queryEdit->setText(QStringLiteral("path:\"acct/trash/**\""));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+
+ // Straight to the purge, bypassing the confirmation: a modal cannot be
+ // driven from a test without blocking it (item 84), and what is under
+ // test is what happens AFTER the user has confirmed.
+ window.purgeForTesting({ QStringLiteral("doomed@example.org") });
+
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 0, 15000);
+}
+
namespace {
/// A worker-backed window with one message in one account's maildir.
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index e1a21cd..3f75898 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -91,6 +91,11 @@ private slots:
void moveMessagesKeepsTheMessagesTags();
void moveMessagesReportsOnlyWhatMoved();
void moveMessagesGivesTheFileAFreshMaildirName();
+ void purgeMessagesDeletesTheFileAndTheIndexEntry();
+ void purgeMessagesReportsWhatItDestroyed();
+ void purgeMessagesLeavesOtherMessagesAlone();
+ void purgeMessagesDoesNotClaimAnIdItCouldNotDelete();
+ void resolveQueryMessagesRefusesAnEmptyQuery();
void moveMessagesKeepsTheMaildirFlags();
void moveMessagesRecoversWhenASyncRenamedTheFile();
void moveMessagesStillReportsAMessageThatIsReallyGone();
@@ -1285,6 +1290,132 @@ void TestNotmuchWorker::moveMessagesRelocatesTheFile()
QVERIFY(!QFile::exists(before));
}
+void TestNotmuchWorker::purgeMessagesDoesNotClaimAnIdItCouldNotDelete()
+{
+ // The report drives what the UI tells the user, and the one number they
+ // will remember about an irreversible action is how much it destroyed. An
+ // id whose file the database names but that is not on disk contributes
+ // nothing: the index entry is still cleaned up, but claiming it as
+ // destroyed would overstate what happened.
+ const QString real = QStringLiteral("purge6@example.org");
+ QVERIFY2(addMovableMessage(QStringLiteral("trash"), real),
+ qPrintable(m_fixture.error()));
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy purged(&worker, &NotmuchWorker::messagesPurged);
+
+ // A KNOWN id whose file is already gone, which is the case that reaches
+ // the removal loop and finds nothing to unlink. An unknown id is skipped
+ // far earlier and proves nothing about it.
+ const QString stale = QStringLiteral("purge7@example.org");
+ QVERIFY2(addMovableMessage(QStringLiteral("trash"), stale),
+ qPrintable(m_fixture.error()));
+ const QString staleFile = fileOf(stale);
+ QVERIFY(!staleFile.isEmpty());
+ QVERIFY(QFile::remove(staleFile));
+
+ worker.purgeMessages({ real, stale });
+
+ QCOMPARE(purged.size(), 1);
+ const QStringList reported = purged.first().at(0).toStringList();
+ QVERIFY2(reported.contains(real), qPrintable(reported.join(QLatin1Char(','))));
+ QVERIFY2(!reported.contains(stale),
+ "claimed to have destroyed a message whose file was already gone");
+}
+
+void TestNotmuchWorker::resolveQueryMessagesRefusesAnEmptyQuery()
+{
+ // An EMPTY query means "match everything" to notmuch, and this walk is
+ // what Empty Trash enumerates from. An account with no trash folder
+ // configured produces an empty query, so without this guard the dialog
+ // would offer to destroy the entire Maildir and say so accurately.
+ QVERIFY2(addMovableMessage(QStringLiteral("trash"),
+ QStringLiteral("empty1@example.org")),
+ qPrintable(m_fixture.error()));
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy resolved(&worker, &NotmuchWorker::threadMessagesResolved);
+
+ worker.resolveQueryMessages(QString(), QStringLiteral("purge"));
+ QCOMPARE(resolved.size(), 0);
+}
+
+void TestNotmuchWorker::purgeMessagesDeletesTheFileAndTheIndexEntry()
+{
+ // Item 118. The one destructive action in this application: the file is
+ // removed from disk and the message from the index, with no undo. Both
+ // halves are asserted, because either one alone leaves a visible defect:
+ // a file without an index entry is invisible mail on disk, and an index
+ // entry without a file is a row that opens onto nothing.
+ const QString id = QStringLiteral("purge1@example.org");
+ QVERIFY2(addMovableMessage(QStringLiteral("trash"), id),
+ qPrintable(m_fixture.error()));
+
+ const QString before = fileOf(id);
+ QVERIFY(!before.isEmpty());
+ QVERIFY(QFile::exists(before));
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.purgeMessages({ id });
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ QVERIFY2(!QFile::exists(before), qPrintable(before));
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(id)).size(), 0);
+}
+
+void TestNotmuchWorker::purgeMessagesReportsWhatItDestroyed()
+{
+ // The count the confirmation named has to be the count that happened, and
+ // the UI has nothing else to report from: unlike a move, there is no new
+ // path to observe afterwards.
+ const QString first = QStringLiteral("purge2@example.org");
+ const QString second = QStringLiteral("purge3@example.org");
+ QVERIFY2(addMovableMessage(QStringLiteral("trash"), first),
+ qPrintable(m_fixture.error()));
+ QVERIFY2(addMovableMessage(QStringLiteral("trash"), second),
+ qPrintable(m_fixture.error()));
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy purged(&worker, &NotmuchWorker::messagesPurged);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.purgeMessages({ first, second });
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ QCOMPARE(purged.size(), 1);
+ QStringList reported = purged.first().at(0).toStringList();
+ reported.sort();
+ QCOMPARE(reported, (QStringList{ first, second }));
+}
+
+void TestNotmuchWorker::purgeMessagesLeavesOtherMessagesAlone()
+{
+ // The blast radius. A purge names ids, and nothing outside that list may
+ // be touched: this is the action with no undo, so an over-reach is not
+ // recoverable. The survivor is in the SAME folder, which is where a
+ // folder-wide delete would take everything with it.
+ const QString doomed = QStringLiteral("purge4@example.org");
+ const QString survivor = QStringLiteral("purge5@example.org");
+ QVERIFY2(addMovableMessage(QStringLiteral("trash"), doomed),
+ qPrintable(m_fixture.error()));
+ QVERIFY2(addMovableMessage(QStringLiteral("trash"), survivor),
+ qPrintable(m_fixture.error()));
+
+ const QString survivorFile = fileOf(survivor);
+ QVERIFY(!survivorFile.isEmpty());
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+ worker.purgeMessages({ doomed });
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(doomed)).size(), 0);
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(survivor)).size(), 1);
+ QVERIFY2(QFile::exists(survivorFile), qPrintable(survivorFile));
+}
+
void TestNotmuchWorker::moveMessagesReindexesAtTheNewPath()
{
// The half a filesystem check cannot see. A moved file with a stale index