summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-24 21:57:28 +0200
committerDanilo M. <danix@danix.xyz>2026-08-24 21:57:28 +0200
commitb7f2a4e0f8858b1aa0d86755ebab6826306f3eff (patch)
tree542934c034716b69b15258bf06e491798408af84 /tests
parentf6ceeacad8e1fe15c30db66dcfde8efa9dfb4758 (diff)
downloadqtmaildir-b7f2a4e0f8858b1aa0d86755ebab6826306f3eff.tar.gz
qtmaildir-b7f2a4e0f8858b1aa0d86755ebab6826306f3eff.zip
fix(drafts): index a saved draft so it appears without a sync
Autosave writes the draft to the Maildir drafts folder and stops, while the Drafts view is a notmuch path: query, so a freshly saved draft was invisible until notmuch new ran. saveDraftNow() now emits draftSaved, and MainWindow connects it to a new NotmuchWorker::indexDraftFile() that indexes the one file the way moveMessages() does, with the previous revision removed so a rewrite leaves no ghost. The send path unlinks a draft that was indexed while being composed, so draftRemoved -> removeIndexedFile() drops its entry too. Measured: notmuch_database_index_file assigns NO tags (unlike notmuch new, which adds draft inbox unread), so no tag-stripping is needed and the draft cannot leak into a tag:inbox view. Item 158.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_composewindow.cpp54
-rw-r--r--tests/test_notmuchworker.cpp106
2 files changed, 160 insertions, 0 deletions
diff --git a/tests/test_composewindow.cpp b/tests/test_composewindow.cpp
index 47f7d87..472c103 100644
--- a/tests/test_composewindow.cpp
+++ b/tests/test_composewindow.cpp
@@ -22,6 +22,7 @@
#include <QFile>
#include <QMenu>
#include <QPlainTextEdit>
+#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTextStream>
#include <QToolButton>
@@ -47,6 +48,7 @@ private slots:
void changingTheAccountFollowsItsSignature();
void changingTheAccountStopsFollowingOnceTheSwitchIsUsed();
void aResumedDraftDoesNotReseedOnAnAccountChange();
+ void savingADraftEmitsItsPathAndTheReplacedOne();
private:
/// A config pointing at a signatures directory holding \p files, with one
@@ -393,5 +395,57 @@ void TestComposeWindow::aResumedDraftDoesNotReseedOnAnAccountChange()
QVERIFY(!body->toPlainText().contains(QStringLiteral("Home sig")));
}
+void TestComposeWindow::savingADraftEmitsItsPathAndTheReplacedOne()
+{
+ // A config whose account has a drafts folder, which makeConfig() does not
+ // set, so the save can actually write somewhere.
+ const QString confPath = m_dir->path() + QStringLiteral("/qtmaildir.conf");
+ {
+ QString conf;
+ QTextStream out(&conf);
+ out << "[account.work]\n"
+ << "name = Someone\n"
+ << "address = someone@example.org\n"
+ << "maildir = work\n"
+ << "drafts = Drafts\n"
+ << "send_command = /bin/cat\n";
+ writeFile(confPath, conf);
+ }
+ Config config;
+ config.load(confPath);
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ QSignalSpy saved(&window, &ComposeWindow::draftSaved);
+
+ body->setPlainText(QStringLiteral("First revision."));
+ QVERIFY(window.saveDraftNow());
+
+ QCOMPARE(saved.size(), 1);
+ const QString first = saved.first().at(0).toString();
+ const QString firstPrevious = saved.first().at(1).toString();
+ QVERIFY(!first.isEmpty());
+ QVERIFY(firstPrevious.isEmpty());
+ QVERIFY(QFile::exists(first));
+
+ // A rewrite writes a fresh file and unlinks the old; the previous path
+ // comes back so the owner can drop the old index entry.
+ body->setPlainText(QStringLiteral("Second revision."));
+ QVERIFY(window.saveDraftNow());
+
+ QCOMPARE(saved.size(), 2);
+ const QString second = saved.at(1).at(0).toString();
+ const QString secondPrevious = saved.at(1).at(1).toString();
+ QVERIFY(!second.isEmpty());
+ QCOMPARE(secondPrevious, first);
+ QVERIFY2(second != first, "a rewrite reused the old filename");
+}
+
QTEST_MAIN(TestComposeWindow)
#include "test_composewindow.moc"
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 998696f..d02f8bd 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -93,6 +93,10 @@ private slots:
void moveMessagesGivesTheFileAFreshMaildirName();
void moveMessagesKeepsTheMaildirFlags();
+ void indexDraftFileMakesAFileFindable();
+ void indexDraftFileRemovesThePreviousFile();
+ void removeIndexedFileDropsTheEntry();
+
void aSplitIndexStillResolvesTheMailRoot();
void aSplitIndexMovesIntoTheMaildirNotTheIndex();
void aSplitIndexListsTheMaildirsFolders();
@@ -103,6 +107,9 @@ private:
/// Each of those takes its own message, because a move is destructive and
/// the fixture database is shared by every test in this class.
bool addMovableMessage(const QString &folder, const QString &messageId);
+ /// Writes a draft file into <folder>/cur with the "D" flag and returns its
+ /// path, WITHOUT indexing it, so a test can index just that file.
+ QString writeDraftFile(const QString &folder, const QString &messageId);
/// The single file backing `messageId`, or an empty string when the
/// database does not know the id.
QString fileOf(const QString &messageId,
@@ -215,6 +222,42 @@ bool TestNotmuchWorker::addMovableMessage(const QString &folder,
return m_fixture.index();
}
+QString TestNotmuchWorker::writeDraftFile(const QString &folder,
+ const QString &messageId)
+{
+ const QString dirPath = m_fixture.maildirPath() + QLatin1Char('/') + folder;
+ QDir dir;
+ if (!dir.mkpath(dirPath + QStringLiteral("/cur"))
+ || !dir.mkpath(dirPath + QStringLiteral("/new"))
+ || !dir.mkpath(dirPath + QStringLiteral("/tmp"))) {
+ return {};
+ }
+
+ // The same filename recipe addMessage() uses, with the draft flag instead
+ // of the seen flag, matching what DraftStore writes.
+ QString base = messageId;
+ base.remove(QLatin1Char('<')).remove(QLatin1Char('>'));
+ base.replace(QLatin1Char('@'), QLatin1Char('.'));
+ base.replace(QLatin1Char('/'), QLatin1Char('.'));
+ base += QStringLiteral(":2,D");
+
+ const QString path = dirPath + QStringLiteral("/cur/") + base;
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
+ return {};
+ QTextStream out(&file);
+ out << "From: You <you@example.org>\n"
+ << "To: someone@example.org\n"
+ << "Subject: A draft\n"
+ << "Message-ID: <" << messageId << ">\n"
+ << "Date: Sun, 7 Jun 2026 10:00:00 +0000\n"
+ << "\n"
+ << "draft body\n";
+ out.flush();
+ file.close();
+ return path;
+}
+
QString TestNotmuchWorker::fileOf(const QString &messageId,
const QString &configPath)
{
@@ -1383,6 +1426,69 @@ void TestNotmuchWorker::moveMessagesReportsOnlyWhatMoved()
QCOMPARE(inTrash.size(), 1);
}
+void TestNotmuchWorker::indexDraftFileMakesAFileFindable()
+{
+ const QString id = QStringLiteral("draft1@example.org");
+ const QString path = writeDraftFile(QStringLiteral("drafts"), id);
+ QVERIFY(!path.isEmpty());
+
+ // On disk but not indexed: no query sees it, which is item 158's defect.
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(id)).size(), 0);
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+ worker.indexDraftFile(path);
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(id)).size(), 1);
+}
+
+void TestNotmuchWorker::indexDraftFileRemovesThePreviousFile()
+{
+ const QString first = QStringLiteral("draft2@example.org");
+ const QString second = QStringLiteral("draft3@example.org");
+ const QString firstPath = writeDraftFile(QStringLiteral("drafts"), first);
+ QVERIFY(!firstPath.isEmpty());
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+ worker.indexDraftFile(firstPath);
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(first)).size(), 1);
+
+ // A rewrite: a new file (a fresh Message-ID) and the old one unlinked, as
+ // DraftStore does on every autosave. The old entry must not linger.
+ const QString secondPath = writeDraftFile(QStringLiteral("drafts"), second);
+ QVERIFY(!secondPath.isEmpty());
+ QVERIFY(QFile::remove(firstPath));
+
+ worker.indexDraftFile(secondPath, firstPath);
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(second)).size(), 1);
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(first)).size(), 0);
+}
+
+void TestNotmuchWorker::removeIndexedFileDropsTheEntry()
+{
+ const QString id = QStringLiteral("draft4@example.org");
+ const QString path = writeDraftFile(QStringLiteral("drafts"), id);
+ QVERIFY(!path.isEmpty());
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+ worker.indexDraftFile(path);
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(id)).size(), 1);
+
+ // The send path unlinks the draft and drops its entry, so it does not
+ // linger as a ghost until the next sync.
+ QVERIFY(QFile::remove(path));
+ worker.removeIndexedFile(path);
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+ QCOMPARE(runQuery(QStringLiteral("id:%1").arg(id)).size(), 0);
+}
+
// Item 124. notmuch can put the Xapian index outside the mail root
// (`mail_root` + `path`), which is how the index moves to faster storage while