aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp289
1 files changed, 289 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 3dbb227..a6aa7f5 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -53,6 +53,9 @@
#include "carddelegate.h"
#include "composewindow.h"
#include "senddialog.h"
+#include "composecontext.h"
+#include "messagebuilder.h"
+#include "draftstore.h"
#include "messagesender.h"
#include <QCheckBox>
#include <QPlainTextEdit>
@@ -118,6 +121,9 @@ public:
QString trash;
QString sendCommand;
QString address;
+ /// Written only when non-empty, like trash: an account without one
+ /// offers no Drafts filter and no Edit draft (items 138 and 153).
+ QString drafts;
};
/// Writes several accounts, for the compose cases.
@@ -194,6 +200,8 @@ public:
// send_command is receive-only, which is the shape under test.
if (!account.sendCommand.isEmpty())
out << "send_command=" << account.sendCommand << "\n";
+ if (!account.drafts.isEmpty())
+ out << "drafts=" << account.drafts << "\n";
}
}
file.close();
@@ -478,6 +486,11 @@ private slots:
// fixture.
void aComposerOpensClean();
void ctrlWClosesTheComposer();
+ void aDraftReopensWithItsOwnContent();
+ void editDraftIsOfferedOnlyForADraft();
+ void doubleClickingADraftOpensTheComposer();
+ void aResumedDraftReplacesItsFileRatherThanAddingOne();
+ void aResumedDraftKeepsItsBlindRecipients();
void theComposerSplitsItsToolbarByScope();
void ccAndBccHideBehindADisclosure();
void ccAndBccAreRevealedWhenTheyCarryAValue();
@@ -12449,6 +12462,282 @@ void TestMainWindow::removeAttachmentAppearsOnlyWithAttachments()
"Remove attachment is not offered with a file attached");
}
+namespace {
+
+/// Writes a draft the way ComposeWindow's autosave does, and returns its path.
+///
+/// Built through MessageBuilder rather than by hand, so the test resumes the
+/// bytes the application really writes: a draft assembled from a string
+/// literal could disagree with the builder and the round trip would prove
+/// nothing about the real file.
+QString writeDraftFile(const QString &folder, const OutgoingMessage &message,
+ const Account &account)
+{
+ QDir().mkpath(folder + QStringLiteral("/cur"));
+ QDir().mkpath(folder + QStringLiteral("/new"));
+ QDir().mkpath(folder + QStringLiteral("/tmp"));
+ const MessageBuilder::Result built = MessageBuilder::build(message, account);
+ if (!built.ok()) {
+ qWarning("draft fixture: build failed: %s", qPrintable(built.error));
+ return {};
+ }
+ const DraftStore::Result written =
+ DraftStore::write(folder, built.bytes, QStringLiteral("D"));
+ if (!written.ok())
+ qWarning("draft fixture: write failed: %s", qPrintable(written.error));
+ return written.path;
+}
+
+} // namespace
+
+void TestMainWindow::doubleClickingADraftOpensTheComposer()
+{
+ // The user's own words: "Double clicking on a draft should open the
+ // message in the editor window." Every other row opens its thread, which
+ // for an unfinished message means looking at it rendered and being unable
+ // to touch it.
+ WorkerComposeFixture fixture;
+ QVERIFY(fixture.backed.fixture().addMessage(
+ QStringLiteral("acct/Drafts"), QStringLiteral("draft2@example.org"),
+ QStringLiteral("Unfinished"), QStringLiteral("you@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Half a thought.")));
+ QVERIFY2(fixture.seed({ { QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash"),
+ QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org"),
+ QStringLiteral("Drafts") } },
+ QStringLiteral("acct/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(model && view && queryEdit);
+
+ queryEdit->setText(QStringLiteral("id:draft2@example.org"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1
+ && !window.mailRootForTesting().isEmpty(),
+ 15000);
+
+ const QModelIndex row = model->index(0, 0, QModelIndex());
+ view->setCurrentIndex(row);
+
+ const int before = window.openComposerCount();
+ emit view->doubleClicked(row);
+
+ // Through the worker, so the composer arrives on a later turn.
+ QTRY_VERIFY_WITH_TIMEOUT(
+ window.openComposerCount() == before + 1, 15000);
+}
+
+void TestMainWindow::editDraftIsOfferedOnlyForADraft()
+{
+ // A draft renders like any other message, so the action has to say which
+ // rows it applies to. Offered on ordinary mail it would open a composer
+ // that owns a file it did not write, and the first autosave would then
+ // delete a received message.
+ WorkerComposeFixture fixture;
+ // The draft goes in BEFORE the window opens: the worker holds the database
+ // open, so a message added afterwards is not in the index it queries.
+ QVERIFY(fixture.backed.fixture().addMessage(
+ QStringLiteral("acct/Drafts"), QStringLiteral("draft1@example.org"),
+ QStringLiteral("Half written"), QStringLiteral("you@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body.")));
+ QVERIFY2(fixture.seed({ { QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash"),
+ QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org"),
+ QStringLiteral("Drafts") } },
+ QStringLiteral("acct/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ auto *edit = window.findChild<QAction *>(QStringLiteral("edit_draft"));
+ QVERIFY(model && view && queryEdit && edit);
+
+ // Selected by id, not through selectTheMessage(): there are two messages
+ // here, and this test is about which FOLDER each sits in.
+ const auto selectById = [&](const QString &id) {
+ queryEdit->setText(QStringLiteral("id:") + id);
+ queryEdit->returnPressed();
+ bool ready = false;
+ for (int attempt = 0; attempt < 150 && !ready; ++attempt) {
+ ready = model->rowCount(QModelIndex()) == 1
+ && !window.mailRootForTesting().isEmpty();
+ if (!ready)
+ QTest::qWait(100);
+ }
+ if (!ready)
+ return false;
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ return true;
+ };
+
+ QVERIFY2(selectById(QStringLiteral("compose1@example.org")),
+ "the inbox message was not found");
+ QVERIFY2(!edit->isEnabled(),
+ "Edit draft is offered on a message in the inbox");
+
+ // The guard, and it is the half that matters: an action disabled
+ // everywhere passes the assertion above while the feature does not exist.
+ QVERIFY2(selectById(QStringLiteral("draft1@example.org")),
+ "the draft was not found");
+ QVERIFY2(edit->isEnabled(),
+ "Edit draft is not offered on a message in the drafts folder");
+}
+
+void TestMainWindow::aDraftReopensWithItsOwnContent()
+{
+ // Item 153. A draft was write-only: DraftStore had a write() and no
+ // reader, and nothing opened a composer from an existing message, so a
+ // draft rendered as ordinary mail and could never be finished.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ OutgoingMessage message;
+ message.accountKey = QStringLiteral("acct");
+ message.to = { QStringLiteral("someone@example.org") };
+ message.cc = { QStringLiteral("copied@example.org") };
+ message.subject = QStringLiteral("A half-written note");
+ message.markdownBody = QStringLiteral("The first half.\n\nAnd more.");
+
+ const QString folder = fixture.mailRoot() + QStringLiteral("/acct/Drafts");
+ const QString path = writeDraftFile(folder, message,
+ fixture.config().account(
+ QStringLiteral("acct")));
+ QVERIFY2(!path.isEmpty(), "the draft fixture was not written");
+
+ ComposeContext context =
+ ComposeContextBuilder::forDraft(fixture.config(), path);
+ QVERIFY2(context.kind == ComposeContext::Kind::Draft,
+ "forDraft did not produce a Draft context");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *to = window.findChild<QLineEdit *>(QStringLiteral("to"));
+ auto *cc = window.findChild<QLineEdit *>(QStringLiteral("cc"));
+ auto *subject = window.findChild<QLineEdit *>(QStringLiteral("subject"));
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(to && cc && subject && body);
+
+ QVERIFY2(to->text().contains(QStringLiteral("someone@example.org")),
+ qPrintable(QStringLiteral("To reads '%1'").arg(to->text())));
+ QVERIFY2(cc->text().contains(QStringLiteral("copied@example.org")),
+ qPrintable(QStringLiteral("Cc reads '%1'").arg(cc->text())));
+ QCOMPARE(subject->text(), QStringLiteral("A half-written note"));
+
+ // The body VERBATIM: no attribution, no quote markers, and no blank lines
+ // added. A draft is the message itself, not something being answered, so
+ // seedBody()'s quote framing must not touch it.
+ QVERIFY2(body->toPlainText().contains(QStringLiteral("The first half.")),
+ qPrintable(QStringLiteral("the body reads '%1'")
+ .arg(body->toPlainText())));
+ QVERIFY2(!body->toPlainText().contains(QStringLiteral("wrote:")),
+ "the draft body was framed as a quote");
+ QVERIFY2(!body->toPlainText().startsWith(QLatin1Char('>')),
+ "the draft body was quote-marked");
+ QVERIFY2(!body->toPlainText().startsWith(QStringLiteral("\n\n")),
+ "blank lines were prepended to a draft, as if it were a reply");
+}
+
+void TestMainWindow::aResumedDraftReplacesItsFileRatherThanAddingOne()
+{
+ // The half that makes resuming safe rather than merely possible. Maildir
+ // has no in-place edit, so an autosave writes a new file and unlinks the
+ // old one; a resumed draft that did not know its own path would leave the
+ // original behind and the user would have two drafts of one message.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ OutgoingMessage message;
+ message.accountKey = QStringLiteral("acct");
+ message.to = { QStringLiteral("someone@example.org") };
+ message.subject = QStringLiteral("Resumed");
+ message.markdownBody = QStringLiteral("Body.");
+
+ const QString folder = fixture.mailRoot() + QStringLiteral("/acct/Drafts");
+ const QString path = writeDraftFile(folder, message,
+ fixture.config().account(
+ QStringLiteral("acct")));
+ QVERIFY(!path.isEmpty());
+
+ const ComposeContext context =
+ ComposeContextBuilder::forDraft(fixture.config(), path);
+ QCOMPARE(context.draftPath, path);
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ const auto draftCount = [&folder]() {
+ return QDir(folder + QStringLiteral("/cur"))
+ .entryList(QDir::Files).size();
+ };
+ QCOMPARE(draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("Body, continued."));
+
+ // Through the real timer, which is what production uses: the edit above
+ // starts it, and firing it here runs the same autosave() a pause would.
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY2(timer, "the composer has no autosave timer");
+ QVERIFY2(timer->isActive(),
+ "editing the body did not arm the autosave timer");
+ timer->setInterval(0);
+ QTRY_VERIFY_WITH_TIMEOUT(!timer->isActive(), 5000);
+
+ QCOMPARE(draftCount(), 1);
+ QVERIFY2(!QFile::exists(path),
+ "the original draft file survived the autosave, so the message "
+ "now exists twice");
+}
+
+void TestMainWindow::aResumedDraftKeepsItsBlindRecipients()
+{
+ // MessageBuilder writes Bcc into the draft file deliberately, and says
+ // why. A resumed draft that did not read it back would drop every blind
+ // recipient silently: the user finishes the message, sends it, and the
+ // people they addressed blindly never receive it and nothing reports so.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ OutgoingMessage message;
+ message.accountKey = QStringLiteral("acct");
+ message.to = { QStringLiteral("someone@example.org") };
+ message.bcc = { QStringLiteral("blind@example.org") };
+ message.subject = QStringLiteral("With a blind copy");
+ message.markdownBody = QStringLiteral("Body.");
+
+ const QString folder = fixture.mailRoot() + QStringLiteral("/acct/Drafts");
+ const QString path = writeDraftFile(folder, message,
+ fixture.config().account(
+ QStringLiteral("acct")));
+ QVERIFY(!path.isEmpty());
+
+ const ComposeContext context =
+ ComposeContextBuilder::forDraft(fixture.config(), path);
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+
+ auto *bcc = window.findChild<QLineEdit *>(QStringLiteral("bcc"));
+ QVERIFY(bcc);
+ QVERIFY2(bcc->text().contains(QStringLiteral("blind@example.org")),
+ qPrintable(QStringLiteral("Bcc reads '%1', so a blind recipient "
+ "was dropped").arg(bcc->text())));
+
+ // And it is VISIBLE, per item 145: a hidden field holding an address is a
+ // message going somewhere the sender cannot see.
+ QVERIFY2(!bcc->isHidden(),
+ "the resumed draft hid a Bcc it actually carries");
+}
+
void TestMainWindow::ctrlWClosesTheComposer()
{
// Item 148. Ctrl+W closes a window in every application the user runs, and