diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | tests/test_composewindow.cpp | 451 | ||||
| -rw-r--r-- | tests/test_config.cpp | 109 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 106 | ||||
| -rw-r--r-- | tests/test_signatures.cpp | 294 |
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" |
