aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_messagesender.cpp532
2 files changed, 533 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 367d23d..e38d764 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -72,6 +72,7 @@ add_qtmaildir_test(markdownrenderer)
add_qtmaildir_test(messagebuilder)
add_qtmaildir_test(maildirname)
add_qtmaildir_test(draftstore)
+add_qtmaildir_test(messagesender)
add_qtmaildir_test(translations)
# Asserts on the tracked .ts rather than the generated .qm: an untranslated
# string is dropped by lrelease, so it is invisible in the .qm and shows up
diff --git a/tests/test_messagesender.cpp b/tests/test_messagesender.cpp
new file mode 100644
index 0000000..89e0fcf
--- /dev/null
+++ b/tests/test_messagesender.cpp
@@ -0,0 +1,532 @@
+/*
+ * 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 "messagesender.h"
+
+class TestMessageSender : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aSuccessfulCommandReportsSent();
+ void theMessageArrivesOnStdinIntact();
+ void aLargeMessageArrivesWhole();
+ void aFailingCommandReportsItsStderr();
+ void aCommandThatDoesNotExistReportsAFailure();
+ void aCommandThatIsNotExecutableReportsAFailure();
+ void anEmptyCommandIsRefusedWithoutRunning();
+ void aCommandOfOnlyWhitespaceIsRefusedWithoutRunning();
+ void exitCode75IsAnOrdinaryFailure();
+ void aSilentFailureStillReportsAReason();
+ void aCommandThatNeverReadsStdinIsStillJudgedByItsExitStatus();
+ void aCrashedCommandIsAFailureWithAReason();
+ void aSecondSendIsRefusedWhileOneIsRunning();
+ void shellMetacharactersReachNoShell();
+ void nothingIsEverReportedTwice();
+ void destroyingTheSenderLetsAnInFlightSendFinish();
+ void destroyingTheSenderEmitsNothing();
+ void aPerSendConnectionMustBeSingleShot();
+
+private:
+ QString writeStub(const QString &name, const QString &body,
+ bool executable = true);
+
+ QTemporaryDir m_dir;
+};
+
+QString TestMessageSender::writeStub(const QString &name, const QString &body,
+ bool executable)
+{
+ const QString path = m_dir.filePath(name);
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return {};
+ file.write(QStringLiteral("#!/bin/sh\n%1\n").arg(body).toUtf8());
+ file.close();
+ QFile::Permissions permissions = QFile::ReadOwner | QFile::WriteOwner;
+ if (executable)
+ permissions |= QFile::ExeOwner;
+ file.setPermissions(permissions);
+ return path;
+}
+
+void TestMessageSender::aSuccessfulCommandReportsSent()
+{
+ const QString stub = writeStub(QStringLiteral("ok.sh"), QStringLiteral("cat >/dev/null"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("From: a@example.org\r\n\r\nbody\r\n")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+ QVERIFY2(spy.at(0).at(1).toString().isEmpty(),
+ "a successful send carried an error message");
+ QVERIFY2(!sender.isRunning(), "the sender still reports a run in progress");
+}
+
+void TestMessageSender::theMessageArrivesOnStdinIntact()
+{
+ // The property that matters most: the bytes the builder produced are the
+ // bytes the command receives. A stub that writes stdin to a file is the
+ // only way to see it, since there is no MTA to ask.
+ const QString captured = m_dir.filePath(QStringLiteral("captured.eml"));
+ const QString stub = writeStub(QStringLiteral("capture.sh"),
+ QStringLiteral("cat > '%1'").arg(captured));
+ QVERIFY(!stub.isEmpty());
+
+ const QByteArray bytes(
+ "From: a@example.org\r\n"
+ "Subject: =?UTF-8?B?UGVyY2jDqQ==?=\r\n"
+ "\r\n"
+ "Perch=C3=A9 accented body.\r\n");
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, bytes));
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+
+ QFile file(captured);
+ QVERIFY2(file.open(QIODevice::ReadOnly), "the stub captured no stdin at all");
+ QCOMPARE(file.readAll(), bytes);
+}
+
+void TestMessageSender::aLargeMessageArrivesWhole()
+{
+ // A message with an attachment is megabytes, not bytes, and a pipe holds
+ // 64KB. If the write were not driven by the event loop the process would
+ // deadlock on a full pipe, or the tail would be silently dropped and a
+ // truncated message would be reported as sent. Measured: 1.6MB in one
+ // write() call returns the full count only because QProcess buffers it and
+ // drains it as the reader consumes; a probe confirmed the payload arrives
+ // byte-identical.
+ const QString captured = m_dir.filePath(QStringLiteral("big.eml"));
+ const QString stub = writeStub(QStringLiteral("bigcapture.sh"),
+ QStringLiteral("cat > '%1'").arg(captured));
+ QVERIFY(!stub.isEmpty());
+
+ QByteArray bytes("From: a@example.org\r\n\r\n");
+ // Well past a pipe buffer, and not a repeating single byte, so a partial
+ // write cannot accidentally compare equal.
+ for (int i = 0; i < 60000; ++i)
+ bytes += QByteArray::number(i) + "\r\n";
+ QVERIFY(bytes.size() > 300000);
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, bytes));
+ QVERIFY(spy.wait(10000));
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+
+ QFile file(captured);
+ QVERIFY(file.open(QIODevice::ReadOnly));
+ const QByteArray got = file.readAll();
+ QCOMPARE(got.size(), bytes.size());
+ QCOMPARE(got, bytes);
+}
+
+void TestMessageSender::aFailingCommandReportsItsStderr()
+{
+ // stderr is shown verbatim: network errors, authentication failures and
+ // server rejections all belong to send_command, and this application
+ // deliberately does not interpret them.
+ const QString stub = writeStub(
+ QStringLiteral("fail.sh"),
+ QStringLiteral("cat >/dev/null; echo 'auth failed: bad password' >&2; exit 1"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("auth failed")),
+ qPrintable(QStringLiteral("stderr was not reported: '%1'")
+ .arg(spy.at(0).at(1).toString())));
+}
+
+void TestMessageSender::aCommandThatDoesNotExistReportsAFailure()
+{
+ // A typo'd path is the likely cause, so the message names the command.
+ // QProcess emits errorOccurred(FailedToStart) INSTEAD OF finished(), which
+ // is the trap MailSync already documents: without handling it the signal
+ // never arrives and the popup waits forever. Measured on Qt 6.11:
+ // finCount 0, errCount 1.
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(QStringLiteral("/nonexistent/msmtp"), QByteArray("body")));
+
+ QVERIFY2(spy.wait(5000), "no result was ever reported for a missing command");
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("msmtp")),
+ qPrintable(QStringLiteral("the error does not name the command: '%1'")
+ .arg(spy.at(0).at(1).toString())));
+}
+
+void TestMessageSender::aCommandThatIsNotExecutableReportsAFailure()
+{
+ // A separate case from a missing file and reached by an ordinary mistake:
+ // a script written by the user and never chmod'd. It also arrives as
+ // FailedToStart with no finished(), so the same handler covers it, but a
+ // test asserting only the missing-file case would pass against a handler
+ // keyed on the errno rather than on the error enum.
+ const QString stub = writeStub(QStringLiteral("noexec.sh"),
+ QStringLiteral("cat >/dev/null"), false);
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY2(spy.wait(5000), "no result was ever reported for a non-executable command");
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY(!spy.at(0).at(1).toString().isEmpty());
+}
+
+void TestMessageSender::anEmptyCommandIsRefusedWithoutRunning()
+{
+ // A receive-only account. The compose actions are disabled on its mail, so
+ // this should be unreachable; refusing here rather than asserting means a
+ // future caller cannot accidentally send from an account that cannot.
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY2(!sender.send(QString(), QByteArray("body")),
+ "an empty command was accepted");
+ QCOMPARE(spy.count(), 0);
+ QVERIFY(!sender.isRunning());
+}
+
+void TestMessageSender::aCommandOfOnlyWhitespaceIsRefusedWithoutRunning()
+{
+ // A config file with `send_command = ` and a trailing space reaches
+ // exactly this, and it must not run anything.
+ //
+ // MEASURED, and worth stating precisely so this is not mistaken for a
+ // sharper test than it is: send() has TWO guards that both catch a blank
+ // command, the trimmed()-empty check and the parts.isEmpty() check after
+ // QProcess::splitCommand(" ") returns an empty list. Dropping either one
+ // alone leaves this test green, because the other still refuses. Dropping
+ // BOTH aborts the run outright: QProcess treats an empty program as fatal,
+ // and the mutation reports "Received a fatal error" rather than a failed
+ // comparison. The pair is what is under test here; the redundancy is
+ // deliberate, since the fatal path is the one thing a send must never
+ // reach.
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY2(!sender.send(QStringLiteral(" \t "), QByteArray("body")),
+ "a whitespace-only command was accepted");
+ QCOMPARE(spy.count(), 0);
+ QVERIFY(!sender.isRunning());
+}
+
+void TestMessageSender::exitCode75IsAnOrdinaryFailure()
+{
+ // Explicitly asserted so the sync path's special handling of 75 is never
+ // copied here. There is no lock to contend for, so 75 means only what the
+ // command chose it to mean: not sent.
+ const QString stub = writeStub(QStringLiteral("busy.sh"),
+ QStringLiteral("cat >/dev/null; exit 75"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+}
+
+void TestMessageSender::aSilentFailureStillReportsAReason()
+{
+ // The mailsync.sh lesson in the other direction: a command that fails
+ // without saying anything must not produce an empty error string, because
+ // the popup would then show a failure with a blank explanation and the
+ // user would have nothing to act on.
+ const QString stub = writeStub(QStringLiteral("silent.sh"),
+ QStringLiteral("cat >/dev/null; exit 3"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ const QString error = spy.at(0).at(1).toString();
+ QVERIFY2(!error.isEmpty(), "a silent failure reported no reason at all");
+ QVERIFY2(error.contains(QStringLiteral("3")),
+ qPrintable(QStringLiteral("the exit status is not named: '%1'").arg(error)));
+}
+
+void TestMessageSender::aCommandThatNeverReadsStdinIsStillJudgedByItsExitStatus()
+{
+ // Measured on Qt 6.11: a command that exits without draining a large stdin
+ // emits errorOccurred(WriteError) BEFORE finished(). A handler that treated
+ // any error as a failure to start would report the write error and swallow
+ // the real exit status; a handler that reported on every error would report
+ // twice. The exit status is the only authority, exactly as it is for the
+ // sync script, so this asserts the reason the command GAVE.
+ const QString stub = writeStub(
+ QStringLiteral("nonreading.sh"),
+ QStringLiteral("echo 'recipient rejected' >&2; exit 1"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray(1600 * 1024, 'x')));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("recipient rejected")),
+ qPrintable(QStringLiteral("the command's own reason was lost: '%1'")
+ .arg(spy.at(0).at(1).toString())));
+}
+
+void TestMessageSender::aCrashedCommandIsAFailureWithAReason()
+{
+ // A segfaulting MTA is a real failure mode and reaches a DIFFERENT branch
+ // from a nonzero exit: status is CrashExit and exitCode carries the signal
+ // number, so an error message built from the exit code alone would tell the
+ // user the command "exited with status 11", which is not what happened.
+ //
+ // Measured on Qt 6.11: a crash emits errorOccurred(Crashed) and THEN
+ // finished(11, CrashExit). Only finished() reports, because handleError
+ // filters to FailedToStart, so the count assertion below also proves that
+ // filter is doing work on a path that is not the write-error one.
+ const QString stub = writeStub(QStringLiteral("crash.sh"),
+ QStringLiteral("cat >/dev/null; kill -SEGV $$"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QTest::qWait(300);
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ const QString error = spy.at(0).at(1).toString();
+ QVERIFY2(!error.isEmpty(), "a crashed command reported no reason");
+ QVERIFY2(error.contains(QStringLiteral("crash")),
+ qPrintable(QStringLiteral("a crash was reported as an ordinary exit: '%1'")
+ .arg(error)));
+}
+
+void TestMessageSender::aSecondSendIsRefusedWhileOneIsRunning()
+{
+ // One QProcess, so a second send would overwrite the first's program and
+ // arguments mid-flight. Refusing is what makes the popup's Sending stage
+ // mean one message.
+ const QString stub = writeStub(QStringLiteral("slow.sh"),
+ QStringLiteral("cat >/dev/null; sleep 1"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("first")));
+ QVERIFY2(sender.isRunning(), "the sender does not report the run it just started");
+ QVERIFY2(!sender.send(stub, QByteArray("second")),
+ "a second send was accepted while one was running");
+
+ QVERIFY(spy.wait(10000));
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+}
+
+void TestMessageSender::shellMetacharactersReachNoShell()
+{
+ // The security property, asserted rather than asserted-about-in-a-comment.
+ // The command is split into an argument list and handed to execve, so a
+ // `;` in it is a literal argument and there is no shell to act on it. If
+ // this ever ran through `sh -c` the stub below would be invoked and the
+ // marker file would exist.
+ //
+ // Measured: QProcess::splitCommand("msmtp; rm x") yields ("msmtp;", "rm",
+ // "x"), so the semicolon does not even separate arguments.
+ const QString marker = m_dir.filePath(QStringLiteral("shell-ran"));
+ const QString stub = writeStub(QStringLiteral("args.sh"),
+ QStringLiteral("cat >/dev/null; exit 0"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(QStringLiteral("%1 ; touch %2").arg(stub, marker),
+ QByteArray("body")));
+ QVERIFY(spy.wait(5000));
+
+ QVERIFY2(!QFile::exists(marker),
+ "the send command was interpreted by a shell");
+
+ // And the same string quoted the way a shell would need it also reaches no
+ // shell: double quotes are the ONLY quoting splitCommand understands.
+ // Measured: single quotes are NOT stripped, so `-a 'my acct'` arrives as
+ // three arguments. Recorded here because the plan's comment claimed
+ // splitCommand "handles quoted arguments" without that qualification.
+ QCOMPARE(QProcess::splitCommand(QStringLiteral("m -a \"my acct\" -t")),
+ QStringList({QStringLiteral("m"), QStringLiteral("-a"),
+ QStringLiteral("my acct"), QStringLiteral("-t")}));
+ QCOMPARE(QProcess::splitCommand(QStringLiteral("m -a 'my acct' -t")),
+ QStringList({QStringLiteral("m"), QStringLiteral("-a"),
+ QStringLiteral("'my"), QStringLiteral("acct'"),
+ QStringLiteral("-t")}));
+}
+
+void TestMessageSender::nothingIsEverReportedTwice()
+{
+ // Reporting twice would close the send popup and then act on a second
+ // result, which for a caller that files a sent copy on success means two
+ // copies, or a success followed by a failure. Run every outcome through one
+ // sender and count.
+ const QString ok = writeStub(QStringLiteral("dup-ok.sh"),
+ QStringLiteral("cat >/dev/null"));
+ const QString bad = writeStub(QStringLiteral("dup-bad.sh"),
+ QStringLiteral("echo boom >&2; exit 1"));
+ QVERIFY(!ok.isEmpty() && !bad.isEmpty());
+
+ for (const QString &command :
+ {ok, bad, QStringLiteral("/nonexistent/msmtp")}) {
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(command, QByteArray(1600 * 1024, 'x')));
+ QVERIFY(spy.wait(10000));
+ // Give any second signal a chance to arrive before counting.
+ QTest::qWait(300);
+ QVERIFY2(spy.count() == 1,
+ qPrintable(QStringLiteral("%1 reported %2 times")
+ .arg(command)
+ .arg(spy.count())));
+ }
+}
+
+void TestMessageSender::destroyingTheSenderLetsAnInFlightSendFinish()
+{
+ // The composer's X button is reachable mid-send, and abandoning a live
+ // SMTP conversation has a genuinely unknown outcome. Measured before the
+ // destructor existed: plain destruction 100ms into a one-second command
+ // killed the child and the work did NOT complete, announced by nothing but
+ // a "QProcess: Destroyed while process is still running" warning.
+ //
+ // The marker file is the evidence, because it is written by the command
+ // itself after its work: if the destructor killed the child, it does not
+ // exist.
+ const QString marker = m_dir.filePath(QStringLiteral("send-completed"));
+ const QString stub = writeStub(
+ QStringLiteral("slowfinish.sh"),
+ QStringLiteral("cat >/dev/null; sleep 1; touch '%1'").arg(marker));
+ QVERIFY(!stub.isEmpty());
+ QVERIFY2(!QFile::exists(marker), "the marker existed before the send ran");
+
+ {
+ MessageSender sender;
+ QVERIFY(sender.send(stub, QByteArray("body")));
+ // Destroyed well before the command could finish, which is the case
+ // that matters; without the wait this scope kills it.
+ QTest::qWait(100);
+ QVERIFY2(sender.isRunning(), "the command finished before it was abandoned");
+ }
+
+ QVERIFY2(QFile::exists(marker),
+ "destroying the sender killed a send that was in flight");
+}
+
+void TestMessageSender::destroyingTheSenderEmitsNothing()
+{
+ // After a kill the outcome is unknown, and this class reports two outcomes
+ // only. A finished(false, ...) from the destructor would report "not sent"
+ // for a message that may have been delivered, which is the mailsync.sh
+ // mistake pointing the other way.
+ //
+ // A command that outlasts the shutdown wait is what forces the kill
+ // branch, so the wait is shortened by pointing the test at a command
+ // longer than it rather than by changing the constant.
+ const QString stub = writeStub(QStringLiteral("outlast.sh"),
+ QStringLiteral("cat >/dev/null; sleep 30"));
+ QVERIFY(!stub.isEmpty());
+
+ QSignalSpy *spy = nullptr;
+ {
+ MessageSender sender;
+ spy = new QSignalSpy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+ QTest::qWait(100);
+ QVERIFY(sender.isRunning());
+ // The destructor runs as this scope ends: it waits kShutdownWaitMs
+ // for a command that will not finish, then kills it.
+ }
+ // The spy outlives the sender deliberately: a signal emitted during
+ // destruction would have been recorded before the object went away.
+ QCOMPARE(spy->count(), 0);
+ delete spy;
+}
+
+void TestMessageSender::aPerSendConnectionMustBeSingleShot()
+{
+ // The header's contract, asserted. m_reported collapses two QProcess
+ // signals into one emit, but it cannot stop a caller from accumulating
+ // RECEIVERS: a long-lived sender that a caller connects to inside its send
+ // path runs every previous lambda on the next result, each still holding
+ // the previous message's bytes.
+ //
+ // This is the plan's own Task 11 shape, and it is why that step now
+ // specifies Qt::SingleShotConnection.
+ const QString stub = writeStub(QStringLiteral("twice.sh"),
+ QStringLiteral("cat >/dev/null"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender; // long-lived, as a ComposeWindow member is
+
+ // The broken shape: a bare connect() beside each send().
+ int bareDeliveries = 0;
+ for (int i = 0; i < 2; ++i) {
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ connect(&sender, &MessageSender::finished, this,
+ [&bareDeliveries](bool, const QString &) { ++bareDeliveries; });
+ QVERIFY(sender.send(stub, QByteArray("body")));
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.count(), 1); // ONE emit, both times
+ }
+ QVERIFY2(bareDeliveries == 3,
+ qPrintable(QStringLiteral("expected the documented 1+2 accumulation, got %1")
+ .arg(bareDeliveries)));
+
+ // The prescribed shape: the connection disconnects as it fires, so two
+ // sends deliver two results rather than three.
+ MessageSender clean;
+ int singleShotDeliveries = 0;
+ for (int i = 0; i < 2; ++i) {
+ QSignalSpy spy(&clean, &MessageSender::finished);
+ connect(&clean, &MessageSender::finished, this,
+ [&singleShotDeliveries](bool, const QString &) { ++singleShotDeliveries; },
+ Qt::SingleShotConnection);
+ QVERIFY(clean.send(stub, QByteArray("body")));
+ QVERIFY(spy.wait(5000));
+ }
+ QCOMPARE(singleShotDeliveries, 2);
+}
+
+QTEST_MAIN(TestMessageSender)
+#include "test_messagesender.moc"