aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt2
-rw-r--r--tests/test_composewindow.cpp451
-rw-r--r--tests/test_config.cpp109
-rw-r--r--tests/test_notmuchworker.cpp106
-rw-r--r--tests/test_signatures.cpp294
5 files changed, 953 insertions, 9 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 1af49bb..5938aeb 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -65,6 +65,7 @@ add_qtmaildir_test(tagdialog)
add_qtmaildir_test(tagrules)
add_qtmaildir_test(rulequery)
add_qtmaildir_test(searchterm)
+add_qtmaildir_test(signatures)
add_qtmaildir_test(busyindicator)
add_qtmaildir_test(tagstrip)
add_qtmaildir_test(messagedetailsdialog)
@@ -74,6 +75,7 @@ add_qtmaildir_test(maildirname)
add_qtmaildir_test(draftstore)
add_qtmaildir_test(messagesender)
add_qtmaildir_test(composecontext)
+add_qtmaildir_test(composewindow)
add_qtmaildir_test(formattoolbar)
add_qtmaildir_test(senddialog)
add_qtmaildir_test(translations)
diff --git a/tests/test_composewindow.cpp b/tests/test_composewindow.cpp
new file mode 100644
index 0000000..472c103
--- /dev/null
+++ b/tests/test_composewindow.cpp
@@ -0,0 +1,451 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QtTest>
+#include <QComboBox>
+#include <QDir>
+#include <QFile>
+#include <QMenu>
+#include <QPlainTextEdit>
+#include <QSignalSpy>
+#include <QTemporaryDir>
+#include <QTextStream>
+#include <QToolButton>
+
+#include "composecontext.h"
+#include "composewindow.h"
+#include "config.h"
+#include "signatures.h"
+
+class TestComposeWindow : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void init();
+ void cleanup();
+
+ void aNewMessageSeedsTheComposeSignature();
+ void anAccountSignatureOverridesTheComposeOne();
+ void aResumedDraftSeedsNoSignature();
+ void anUnknownSignatureNameSeedsNothing();
+ void theSwitchListsEveryFileAndNone();
+ void changingTheAccountFollowsItsSignature();
+ void changingTheAccountStopsFollowingOnceTheSwitchIsUsed();
+ void aResumedDraftDoesNotReseedOnAnAccountChange();
+ void savingADraftEmitsItsPathAndTheReplacedOne();
+
+private:
+ /// A config pointing at a signatures directory holding \p files, with one
+ /// account that can send.
+ Config makeConfig(const QList<QPair<QString, QString>> &files,
+ const QString &composeSignature,
+ const QString &accountSignature = {});
+
+ /// QVERIFY cannot appear inside makeConfig(), which returns a value: the
+ /// macro expands to a bare `return;` on failure, which is invalid in a
+ /// non-void function. A void helper keeps the check and sidesteps that.
+ void writeFile(const QString &path, const QString &content);
+
+ QTemporaryDir *m_dir = nullptr;
+ QString m_signatureDir;
+};
+
+void TestComposeWindow::init()
+{
+ m_dir = new QTemporaryDir;
+ QVERIFY(m_dir->isValid());
+ m_signatureDir = m_dir->path() + QStringLiteral("/signatures");
+ QVERIFY(QDir().mkpath(m_signatureDir));
+}
+
+void TestComposeWindow::cleanup()
+{
+ delete m_dir;
+ m_dir = nullptr;
+}
+
+void TestComposeWindow::writeFile(const QString &path, const QString &content)
+{
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << content;
+}
+
+Config TestComposeWindow::makeConfig(
+ const QList<QPair<QString, QString>> &files,
+ const QString &composeSignature, const QString &accountSignature)
+{
+ for (const auto &entry : files)
+ writeFile(m_signatureDir + QStringLiteral("/") + entry.first, entry.second);
+
+ QString conf;
+ {
+ QTextStream out(&conf);
+ out << "[compose]\n"
+ << "signature = " << composeSignature << "\n"
+ << "\n"
+ << "[account.work]\n"
+ << "name = Someone\n"
+ << "address = someone@example.org\n"
+ << "maildir = work\n"
+ << "send_command = /bin/cat\n";
+ if (!accountSignature.isEmpty())
+ out << "signature = " << accountSignature << "\n";
+ }
+ const QString path = m_dir->path() + QStringLiteral("/qtmaildir.conf");
+ writeFile(path, conf);
+
+ Config config;
+ config.load(path);
+ return config;
+}
+
+void TestComposeWindow::aNewMessageSeedsTheComposeSignature()
+{
+ const Config config = makeConfig(
+ { { QStringLiteral("work.md"), QStringLiteral("Jane Doe") } },
+ QStringLiteral("work"));
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setSignatureDir(m_signatureDir);
+ window.seedSignature();
+
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY(body->toPlainText().endsWith(QStringLiteral("-- \nJane Doe")));
+}
+
+void TestComposeWindow::anAccountSignatureOverridesTheComposeOne()
+{
+ const Config config = makeConfig(
+ { { QStringLiteral("work.md"), QStringLiteral("Long one") },
+ { QStringLiteral("brief.md"), QStringLiteral("Brief") } },
+ QStringLiteral("work"), QStringLiteral("brief"));
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setSignatureDir(m_signatureDir);
+ window.seedSignature();
+
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY(body->toPlainText().endsWith(QStringLiteral("-- \nBrief")));
+}
+
+void TestComposeWindow::aResumedDraftSeedsNoSignature()
+{
+ const Config config = makeConfig(
+ { { QStringLiteral("work.md"), QStringLiteral("Jane Doe") } },
+ QStringLiteral("work"));
+
+ // The saved body already carries whatever signature it was written with.
+ // Seeding again would put a SECOND one on a message written once.
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::Draft;
+ context.accountKey = QStringLiteral("work");
+ context.body = QStringLiteral("Half a thought.\n\n-- \nJane Doe");
+ context.draftPath = m_dir->path() + QStringLiteral("/draft");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setSignatureDir(m_signatureDir);
+ window.seedSignature();
+
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QCOMPARE(body->toPlainText().count(QStringLiteral("-- \nJane Doe")), 1);
+}
+
+void TestComposeWindow::anUnknownSignatureNameSeedsNothing()
+{
+ const Config config = makeConfig(
+ { { QStringLiteral("work.md"), QStringLiteral("Jane Doe") } },
+ QStringLiteral("absent"));
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setSignatureDir(m_signatureDir);
+ window.seedSignature();
+
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ // No signature, and the composer still opened rather than refusing.
+ QVERIFY(!body->toPlainText().contains(QStringLiteral("-- ")));
+}
+
+void TestComposeWindow::theSwitchListsEveryFileAndNone()
+{
+ const Config config = makeConfig(
+ { { QStringLiteral("work.md"), QStringLiteral("Jane Doe") },
+ { QStringLiteral("brief.md"), QStringLiteral("Brief") } },
+ QString());
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setSignatureDir(m_signatureDir);
+ window.seedSignature();
+
+ auto *button =
+ window.findChild<QToolButton *>(QStringLiteral("signatureSwitch"));
+ QVERIFY(button);
+ QVERIFY(button->menu());
+ // "None" plus one per file.
+ QCOMPARE(button->menu()->actions().size(), 3);
+}
+
+void TestComposeWindow::changingTheAccountFollowsItsSignature()
+{
+ for (const auto &entry :
+ QList<QPair<QString, QString>>{
+ { QStringLiteral("work.md"), QStringLiteral("Work sig") },
+ { QStringLiteral("home.md"), QStringLiteral("Home sig") } }) {
+ QFile file(m_signatureDir + QStringLiteral("/") + entry.first);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write(entry.second.toUtf8());
+ file.close();
+ }
+
+ const QString path = m_dir->path() + QStringLiteral("/qtmaildir.conf");
+ {
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.work]\n"
+ << "name = Someone\naddress = someone@example.org\n"
+ << "maildir = work\nsend_command = /bin/cat\n"
+ << "signature = work\n"
+ << "\n[account.home]\n"
+ << "name = Someone\naddress = other@example.org\n"
+ << "maildir = home\nsend_command = /bin/cat\n"
+ << "signature = home\n";
+ }
+ Config config;
+ config.load(path);
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setSignatureDir(m_signatureDir);
+ window.seedSignature();
+
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *from = window.findChild<QComboBox *>(QStringLiteral("from"));
+ QVERIFY(body);
+ QVERIFY(from);
+ QVERIFY(body->toPlainText().contains(QStringLiteral("Work sig")));
+
+ // Select the other account by its key, never by index: the order of the
+ // combo is the config's and an index assertion would pass on the wrong one.
+ const int home = from->findData(QStringLiteral("home"));
+ QVERIFY(home >= 0);
+ from->setCurrentIndex(home);
+
+ QVERIFY(body->toPlainText().contains(QStringLiteral("Home sig")));
+ QVERIFY(!body->toPlainText().contains(QStringLiteral("Work sig")));
+}
+
+void TestComposeWindow::changingTheAccountStopsFollowingOnceTheSwitchIsUsed()
+{
+ for (const auto &entry :
+ QList<QPair<QString, QString>>{
+ { QStringLiteral("work.md"), QStringLiteral("Work sig") },
+ { QStringLiteral("home.md"), QStringLiteral("Home sig") },
+ { QStringLiteral("chosen.md"), QStringLiteral("Chosen sig") } }) {
+ QFile file(m_signatureDir + QStringLiteral("/") + entry.first);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write(entry.second.toUtf8());
+ file.close();
+ }
+
+ const QString path = m_dir->path() + QStringLiteral("/qtmaildir.conf");
+ {
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.work]\n"
+ << "name = Someone\naddress = someone@example.org\n"
+ << "maildir = work\nsend_command = /bin/cat\n"
+ << "signature = work\n"
+ << "\n[account.home]\n"
+ << "name = Someone\naddress = other@example.org\n"
+ << "maildir = home\nsend_command = /bin/cat\n"
+ << "signature = home\n";
+ }
+ Config config;
+ config.load(path);
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setSignatureDir(m_signatureDir);
+ window.seedSignature();
+
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *from = window.findChild<QComboBox *>(QStringLiteral("from"));
+ auto *button =
+ window.findChild<QToolButton *>(QStringLiteral("signatureSwitch"));
+ QVERIFY(body);
+ QVERIFY(from);
+ QVERIFY(button);
+
+ // The user picks one deliberately.
+ for (QAction *action : button->menu()->actions()) {
+ if (action->data().toString() == QStringLiteral("chosen"))
+ action->trigger();
+ }
+ QVERIFY(body->toPlainText().contains(QStringLiteral("Chosen sig")));
+
+ const int home = from->findData(QStringLiteral("home"));
+ QVERIFY(home >= 0);
+ from->setCurrentIndex(home);
+
+ // The deliberate choice survives the account change. Overwriting it is
+ // the one behaviour that can silently discard something the user just did.
+ QVERIFY(body->toPlainText().contains(QStringLiteral("Chosen sig")));
+ QVERIFY(!body->toPlainText().contains(QStringLiteral("Home sig")));
+}
+
+void TestComposeWindow::aResumedDraftDoesNotReseedOnAnAccountChange()
+{
+ for (const auto &entry :
+ QList<QPair<QString, QString>>{
+ { QStringLiteral("work.md"), QStringLiteral("Work sig") },
+ { QStringLiteral("home.md"), QStringLiteral("Home sig") } }) {
+ QFile file(m_signatureDir + QStringLiteral("/") + entry.first);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write(entry.second.toUtf8());
+ file.close();
+ }
+
+ const QString path = m_dir->path() + QStringLiteral("/qtmaildir.conf");
+ {
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.work]\n"
+ << "name = Someone\naddress = someone@example.org\n"
+ << "maildir = work\nsend_command = /bin/cat\n"
+ << "signature = work\n"
+ << "\n[account.home]\n"
+ << "name = Someone\naddress = other@example.org\n"
+ << "maildir = home\nsend_command = /bin/cat\n"
+ << "signature = home\n";
+ }
+ Config config;
+ config.load(path);
+
+ // The saved body already carries its own signature, which does not match
+ // any on-disk file. A From: change must not replace it with the new
+ // account's: the draft is the message the user wrote, exactly as
+ // seedBody() takes its body verbatim.
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::Draft;
+ context.accountKey = QStringLiteral("work");
+ context.body = QStringLiteral("Half a thought.\n\n-- \nJane Doe");
+ context.draftPath = m_dir->path() + QStringLiteral("/draft");
+
+ ComposeWindow window(context, config, m_dir->path());
+ window.setSignatureDir(m_signatureDir);
+ window.seedSignature();
+
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *from = window.findChild<QComboBox *>(QStringLiteral("from"));
+ QVERIFY(body);
+ QVERIFY(from);
+ QVERIFY(body->toPlainText().contains(QStringLiteral("Jane Doe")));
+
+ const int home = from->findData(QStringLiteral("home"));
+ QVERIFY(home >= 0);
+ from->setCurrentIndex(home);
+
+ QVERIFY(body->toPlainText().contains(QStringLiteral("Jane Doe")));
+ 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_config.cpp b/tests/test_config.cpp
index a5dce9a..e69a073 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -25,6 +25,7 @@
#include <QJsonObject>
#include "config.h"
#include "mailsync.h"
+#include "signatures.h"
class TestConfig : public QObject
{
@@ -121,7 +122,7 @@ private slots:
void anAccountWithoutATrashFolderWarns();
void theDraftsFilterComposesPerAccount();
void theDraftsFilterMatchesNothingWithoutAFolder();
- void theDraftsFilterIsThreadedNotFlat();
+ void theDraftsFilterIsFlatLikeSent();
void theTrashFilterComposesPerAccount();
void theTrashFilterMatchesNothingWithoutAFolder();
void anAccountWithoutASendCommandIsReceiveOnly();
@@ -134,6 +135,9 @@ private slots:
void garbageAttachmentWarnBytesIsRejectedNotZero();
void zeroOrNegativeAutosaveIntervalIsClamped();
void unrecognisedQuotePositionWarnsAndFallsBackToBelow();
+ void theSignatureKeysAreRead();
+ void anAccountSignatureOverridesTheComposeDefault();
+ void aMalformedSignaturePositionIsReportedAndFallsBack();
};
static QString writeIni(const QTemporaryDir &dir, const QString &body)
@@ -1072,17 +1076,24 @@ void TestConfig::theDraftsFilterMatchesNothingWithoutAFolder()
Config::matchNothingQuery());
}
-void TestConfig::theDraftsFilterIsThreadedNotFlat()
+void TestConfig::theDraftsFilterIsFlatLikeSent()
{
- // Unlike Sent, and deliberately. Sent is flat because a thread would fold
- // the user's own message back into the conversation it answers, which is
- // item 63's finding. A draft reply belongs with its conversation for the
- // same reason a trashed message does, so drafts follow trash here.
+ // Item 138 shipped this THREADED, reasoning that a draft reply belongs
+ // with the conversation it answers. Item 159 reversed it on what that
+ // cost: a thread row stands for its first MATCHED message, which for a
+ // draft reply is the message being replied TO, so the draft had no row of
+ // its own and double-clicking the conversation opened nothing.
const SavedQuery drafts = Config::builtinFilter(QStringLiteral("drafts"));
- QVERIFY2(!drafts.flat, "the drafts filter is flat, like Sent");
+ QVERIFY2(drafts.flat, "the drafts filter went back to threaded, so a draft "
+ "reply has no row of its own (item 159)");
const SavedQuery sent = Config::builtinFilter(QStringLiteral("sent"));
QVERIFY2(sent.flat, "Sent stopped being flat, which item 63 requires");
+
+ // Trash deliberately did NOT follow. A deleted message still belongs to
+ // its conversation, and nothing has to be reachable for editing there.
+ const SavedQuery trash = Config::builtinFilter(QStringLiteral("trash"));
+ QVERIFY2(!trash.flat, "trash became flat; only sent and drafts should be");
}
void TestConfig::theTrashFilterComposesPerAccount()
@@ -2353,6 +2364,7 @@ void TestConfig::aGeneratedEntryWritesNoRedundantKeys()
"version": 1,
"queries": [
{ "name": "Sent", "generated": "sent", "pinned": true },
+ { "name": "Drafts", "generated": "drafts", "pinned": true },
{ "name": "Inbox", "query": "tag:inbox", "pinned": true }
]
})"));
@@ -2377,18 +2389,28 @@ void TestConfig::aGeneratedEntryWritesNoRedundantKeys()
QVERIFY2(!sent.contains(QStringLiteral("flat")),
"the sent generator implies flat; storing it says nothing");
+ // Drafts is the second flat generator (item 159) and must be skipped by
+ // the same rule, not by a second one that could disagree with it.
+ const QJsonObject drafts = array.at(1).toObject();
+ QCOMPARE(drafts.value(QStringLiteral("generated")).toString(),
+ QStringLiteral("drafts"));
+ QVERIFY2(!drafts.contains(QStringLiteral("flat")),
+ "the drafts generator implies flat; storing it says nothing");
+
// The ordinary entry is untouched by any of that.
- const QJsonObject inbox = array.at(1).toObject();
+ const QJsonObject inbox = array.at(2).toObject();
QCOMPARE(inbox.value(QStringLiteral("query")).toString(),
QStringLiteral("tag:inbox"));
// And it all still reads back the same.
Config reloaded;
reloaded.load(path);
- QCOMPARE(reloaded.savedQueries().size(), 2);
+ QCOMPARE(reloaded.savedQueries().size(), 3);
QVERIFY(reloaded.savedQueries().at(0).isGenerated());
QVERIFY2(reloaded.savedQueries().at(0).flat,
"flat must come back from the generator, not from the file");
+ QVERIFY2(reloaded.savedQueries().at(1).flat,
+ "drafts must come back flat too, from the same rule");
}
void TestConfig::anAccountWithoutASendCommandIsReceiveOnly()
@@ -2577,5 +2599,74 @@ void TestConfig::unrecognisedQuotePositionWarnsAndFallsBackToBelow()
"an unrecognised quote_position was accepted silently");
}
+void TestConfig::theSignatureKeysAreRead()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "signature=work\n"
+ "signature_position=above_quote\n")));
+
+ QCOMPARE(config.compose().signature, QStringLiteral("work"));
+ QVERIFY2(config.compose().signaturePosition
+ == Signatures::Position::AboveQuote,
+ "signature_position=above_quote was not read");
+}
+
+void TestConfig::anAccountSignatureOverridesTheComposeDefault()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "signature=work\n"
+ "\n"
+ "[account.personal]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=personal-mail\n"
+ "trash=Trash\n"
+ "signature=brief\n"
+ "\n"
+ "[account.other]\n"
+ "name=Test User\n"
+ "address=other@example.org\n"
+ "maildir=other-mail\n"
+ "trash=Trash\n")));
+
+ // The account SEEDS the choice; it does not own the signature. The key is
+ // a starting value and the switch keeps every signature reachable.
+ QCOMPARE(config.account(QStringLiteral("personal")).signature,
+ QStringLiteral("brief"));
+ // An account with no key of its own carries none, and the caller falls
+ // through to the [compose] default rather than this being resolved here.
+ QVERIFY2(config.account(QStringLiteral("other")).signature.isEmpty(),
+ "an account with no signature key must not inherit the "
+ "[compose] one: the composer resolves the fallback, not Config");
+ QCOMPARE(config.compose().signature, QStringLiteral("work"));
+}
+
+void TestConfig::aMalformedSignaturePositionIsReportedAndFallsBack()
+{
+ // Present and malformed is REPORTED, matching quote_position. A silent
+ // value(key, default) would accept "abov" as above_quote.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "signature_position=abov\n")));
+
+ QVERIFY2(config.compose().signaturePosition == Signatures::Position::End,
+ "an unrecognised signature_position must still fall back to End");
+ bool reported = false;
+ for (const QString &problem : config.problems()) {
+ if (problem.contains(QStringLiteral("signature_position")))
+ reported = true;
+ }
+ QVERIFY2(reported,
+ "an unrecognised signature_position was accepted silently");
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.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
diff --git a/tests/test_signatures.cpp b/tests/test_signatures.cpp
new file mode 100644
index 0000000..47b404b
--- /dev/null
+++ b/tests/test_signatures.cpp
@@ -0,0 +1,294 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QtTest>
+#include <QTemporaryDir>
+
+#include "signatures.h"
+
+class TestSignatures : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void namesAreTheFileStemsSorted();
+ void namesIgnoreFilesThatAreNotMarkdown();
+ void aMissingDirectoryHasNoNames();
+ void textIsTheFileContent();
+ void textOfAnUnknownNameIsEmpty();
+ void insertingAtTheEndAppendsAfterADelimiter();
+ void insertingAboveTheQuotePutsItBeforeTheFirstQuotedLine();
+ void insertingAboveTheQuoteWithNoQuoteIsTheSameAsEnd();
+ void insertingNothingLeavesTheBufferAlone();
+ void switchingReplacesAKnownSignature();
+ void switchingReplacesAKnownSignatureAboveAQuote();
+ void selectingNoneRemovesAKnownSignature();
+ void aBlockMatchingNoKnownSignatureIsNotRemoved();
+ void aDelimiterInsideTheQuoteIsNotTheSignature();
+ void aSignatureReadBackFromDiskIsReplaced();
+
+private:
+ /// Writes \p files as name -> content into a fresh temporary directory.
+ static void write(const QTemporaryDir &dir,
+ const QList<QPair<QString, QString>> &files);
+};
+
+void TestSignatures::write(const QTemporaryDir &dir,
+ const QList<QPair<QString, QString>> &files)
+{
+ for (const auto &entry : files) {
+ QFile file(dir.path() + QStringLiteral("/") + entry.first);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write(entry.second.toUtf8());
+ file.close();
+ }
+}
+
+void TestSignatures::namesAreTheFileStemsSorted()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ write(dir, { { QStringLiteral("work.md"), QStringLiteral("Work") },
+ { QStringLiteral("brief.md"), QStringLiteral("Brief") } });
+
+ QCOMPARE(Signatures::names(dir.path()),
+ QStringList({ QStringLiteral("brief"), QStringLiteral("work") }));
+}
+
+void TestSignatures::namesIgnoreFilesThatAreNotMarkdown()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ write(dir, { { QStringLiteral("work.md"), QStringLiteral("Work") },
+ { QStringLiteral("notes.txt"), QStringLiteral("Not one") },
+ { QStringLiteral("README"), QStringLiteral("Nor this") } });
+
+ QCOMPARE(Signatures::names(dir.path()),
+ QStringList({ QStringLiteral("work") }));
+}
+
+void TestSignatures::aMissingDirectoryHasNoNames()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString missing = dir.path() + QStringLiteral("/nothing-here");
+
+ QVERIFY(Signatures::names(missing).isEmpty());
+}
+
+void TestSignatures::textIsTheFileContent()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ write(dir, { { QStringLiteral("work.md"),
+ QStringLiteral("Jane Doe\n**qtmaildir**\n") } });
+
+ QCOMPARE(Signatures::text(dir.path(), QStringLiteral("work")),
+ QStringLiteral("Jane Doe\n**qtmaildir**\n"));
+}
+
+void TestSignatures::textOfAnUnknownNameIsEmpty()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+
+ QVERIFY(Signatures::text(dir.path(), QStringLiteral("absent")).isEmpty());
+}
+
+void TestSignatures::insertingAtTheEndAppendsAfterADelimiter()
+{
+ const QString buffer = QStringLiteral("Hello.\n");
+
+ const QString result = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), {}, Signatures::Position::End);
+
+ QCOMPARE(result, QStringLiteral("Hello.\n\n-- \nJane Doe"));
+}
+
+void TestSignatures::insertingAboveTheQuotePutsItBeforeTheFirstQuotedLine()
+{
+ const QString buffer = QStringLiteral(
+ "My reply.\n"
+ "\n"
+ "On Mon, someone wrote:\n"
+ "> the original\n"
+ "> second line\n");
+
+ const QString result = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), {},
+ Signatures::Position::AboveQuote);
+
+ // Before the QUOTED lines, and the attribution stays with the quote it
+ // introduces: it is the line the quote hangs from, not part of the reply.
+ QCOMPARE(result, QStringLiteral(
+ "My reply.\n"
+ "\n"
+ "-- \n"
+ "Jane Doe\n"
+ "\n"
+ "On Mon, someone wrote:\n"
+ "> the original\n"
+ "> second line\n"));
+}
+
+void TestSignatures::insertingAboveTheQuoteWithNoQuoteIsTheSameAsEnd()
+{
+ const QString buffer = QStringLiteral("A new message.\n");
+
+ const QString above = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), {},
+ Signatures::Position::AboveQuote);
+ const QString end = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), {}, Signatures::Position::End);
+
+ QCOMPARE(above, end);
+}
+
+void TestSignatures::insertingNothingLeavesTheBufferAlone()
+{
+ const QString buffer = QStringLiteral("Hello.\n");
+
+ QCOMPARE(Signatures::replace(buffer, QString(), {},
+ Signatures::Position::End),
+ buffer);
+}
+
+void TestSignatures::switchingReplacesAKnownSignature()
+{
+ const QStringList known = { QStringLiteral("Jane Doe"),
+ QStringLiteral("Jane Doe\nqtmaildir") };
+ const QString buffer = QStringLiteral("Hello.\n\n-- \nJane Doe");
+
+ const QString result = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe\nqtmaildir"), known,
+ Signatures::Position::End);
+
+ QCOMPARE(result,
+ QStringLiteral("Hello.\n\n-- \nJane Doe\nqtmaildir"));
+}
+
+void TestSignatures::switchingReplacesAKnownSignatureAboveAQuote()
+{
+ const QStringList known = { QStringLiteral("Jane Doe"),
+ QStringLiteral("Brief") };
+ const QString buffer = QStringLiteral(
+ "My reply.\n"
+ "\n"
+ "-- \n"
+ "Jane Doe\n"
+ "\n"
+ "On Mon, someone wrote:\n"
+ "> the original\n");
+
+ const QString result = Signatures::replace(
+ buffer, QStringLiteral("Brief"), known,
+ Signatures::Position::AboveQuote);
+
+ QCOMPARE(result, QStringLiteral(
+ "My reply.\n"
+ "\n"
+ "-- \n"
+ "Brief\n"
+ "\n"
+ "On Mon, someone wrote:\n"
+ "> the original\n"));
+}
+
+void TestSignatures::selectingNoneRemovesAKnownSignature()
+{
+ const QStringList known = { QStringLiteral("Jane Doe") };
+ const QString buffer = QStringLiteral("Hello.\n\n-- \nJane Doe");
+
+ const QString result = Signatures::replace(
+ buffer, QString(), known, Signatures::Position::End);
+
+ QCOMPARE(result, QStringLiteral("Hello.\n"));
+}
+
+void TestSignatures::aBlockMatchingNoKnownSignatureIsNotRemoved()
+{
+ // THE test for the data-loss guard, and it must not be dropped. A "-- "
+ // reaches a buffer without the user ever choosing a signature, pasted in
+ // with quoted text from another client. Replacing from there would delete
+ // everything after it silently.
+ const QStringList known = { QStringLiteral("Jane Doe") };
+ const QString buffer = QStringLiteral(
+ "Hello.\n"
+ "\n"
+ "-- \n"
+ "text the user pasted and wants to keep");
+
+ const QString result = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), known, Signatures::Position::End);
+
+ // The user's text survives, and the signature is ADDED. A wrong guess
+ // produces a visible duplicate, never a deletion.
+ QVERIFY(result.contains(
+ QStringLiteral("text the user pasted and wants to keep")));
+ QVERIFY(result.endsWith(QStringLiteral("-- \nJane Doe")));
+}
+
+void TestSignatures::aDelimiterInsideTheQuoteIsNotTheSignature()
+{
+ // The quoted original carries the sender's own signature, quoted. A tail
+ // rule would find it, and under End it would append after it; the block
+ // must not be treated as this message's signature whichever way it goes.
+ //
+ // This test DOCUMENTS the case rather than pinning it, and that is worth
+ // knowing before trying to strengthen it. Two mutations were measured
+ // against it and both stayed green: trimming the delimiter comparison so
+ // that "> -- " matches, and making the quoted text one of the known
+ // signatures so the match guard could not be what refuses the removal.
+ // Neither changes the output, because blockEnd() stops the block at the
+ // quote, so the quoted signature survives whether or not the delimiter
+ // inside it is recognised. The behaviour is correct under both, and no
+ // assertion on the result can separate them.
+ const QStringList known = { QStringLiteral("Jane Doe") };
+ const QString buffer = QStringLiteral(
+ "My reply.\n"
+ "\n"
+ "On Mon, someone wrote:\n"
+ "> the original\n"
+ "> -- \n"
+ "> Their Name\n");
+
+ const QString result = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), known, Signatures::Position::End);
+
+ QVERIFY(result.contains(QStringLiteral("> -- \n> Their Name")));
+ QVERIFY(result.endsWith(QStringLiteral("-- \nJane Doe")));
+}
+
+void TestSignatures::aSignatureReadBackFromDiskIsReplaced()
+{
+ // known here is what knownSignatures() produces: text() verbatim, carrying
+ // the trailing newline every editor writes into a file. The block scan
+ // treats a trailing blank line as separation rather than text, so a naive
+ // match compares "Jane Doe" against "Jane Doe\n" and silently fails, and
+ // switching then APPENDS a second signature instead of replacing the first.
+ const QStringList known = { QStringLiteral("Jane Doe\n") };
+ const QString buffer = QStringLiteral("Hello.\n\n-- \nJane Doe\n");
+
+ const QString result = Signatures::replace(
+ buffer, QStringLiteral("Brief"), known, Signatures::Position::End);
+
+ QCOMPARE(result, QStringLiteral("Hello.\n\n-- \nBrief"));
+}
+
+QTEST_MAIN(TestSignatures)
+#include "test_signatures.moc"