summaryrefslogtreecommitdiffstats
path: root/tests/test_mailsync.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-09 09:32:09 +0200
committerDanilo M. <danix@danix.xyz>2026-08-09 09:32:09 +0200
commitd213bbffa9f77e78dc21c80308b48b522b0d5a8b (patch)
tree0d5e0357fc6b851f45ef5ea130d6aa866ac0f498 /tests/test_mailsync.cpp
parent8508ac49d2fe72e362379950b52a64ccd6a6e773 (diff)
downloadqtmaildir-d213bbffa9f77e78dc21c80308b48b522b0d5a8b.tar.gz
qtmaildir-d213bbffa9f77e78dc21c80308b48b522b0d5a8b.zip
fix(sync): clear the pending-edit count on a cron sync
Item 54. A sync fired by the user's cron carries tag edits to the mail store exactly as a local one does, but only onSyncFinished() cleared the pending state, so the indicator kept reporting work that had already shipped and the exit prompt asked to sync for it. Verified against a real cron run: 31 changes, cleared with no manual sync. The window cannot see an external run's exit status, and /proc/locks carries no outcome. It does not need to: mailsync.sh already ends every run with a "RUN END ... status=OK" banner in its log, which outlives the process that wrote it. MailSync::lastRunOutcome() reads a bounded tail of that file and takes the last completed marker, so no change to the script and no optimistic guessing were needed. Only a definite OK clears anything. A failed run, a missing or unreadable log, and a State::Unknown lock reading all leave the count alone: over-reporting costs a redundant sync, under-reporting costs the user their edits. m_editedAccounts is drained in the same place, before flushHeldEdits() and matching the local path's ordering. Item 49 uses it to choose which mbsync channels a run syncs, and a count that reached zero while the set stayed full would look correct and still sync the wrong channels. The log path comes from a new optional [sync] log key, defaulting to where the script writes, so a test never reads the developer's own log. Two notes on the verification, both recorded in the backlog: - A timing probe endorsed a tail read that was not happening. The first version of the huge-log test required the call under 100 ms and passed with the seek deleted, because reading 10 MB is fast either way. Replaced with an assertion on content. - Every fixture was invented and the first batch had the wrong timestamp format, since the script uses date -Iseconds. The tests passed anyway, because the parser keys on the prefix and the status token. One test now builds the banner the way the script does. The before-flushHeldEdits ordering has no test: without a held lock the flush is a no-op, so both orderings pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'tests/test_mailsync.cpp')
-rw-r--r--tests/test_mailsync.cpp177
1 files changed, 176 insertions, 1 deletions
diff --git a/tests/test_mailsync.cpp b/tests/test_mailsync.cpp
index e91de3e..45c7767 100644
--- a/tests/test_mailsync.cpp
+++ b/tests/test_mailsync.cpp
@@ -16,8 +16,8 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
-#include <QElapsedTimer>
#include <QFile>
+#include <QProcess>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QtTest>
@@ -56,6 +56,15 @@ private slots:
void theChannelNameIsShown();
void aChannelNameIsNotLetInVerbatim();
+ void lastRunOutcomeReadsAnOkRun();
+ void lastRunOutcomeReadsAFailedRun();
+ void lastRunOutcomeTakesTheLastMarkerNotTheFirst();
+ void lastRunOutcomeOnAMissingLogIsUnknown();
+ void lastRunOutcomeOnALogWithNoMarkerIsUnknown();
+ void lastRunOutcomeIgnoresATrailingPartialRun();
+ void lastRunOutcomeReadsATailOfAHugeLog();
+ void lastRunOutcomeReadsABannerTheScriptActuallyWrote();
+
private:
/// Writes an executable shell script into the temp dir, returns its path.
QString makeScript(const QString &name, const QString &body);
@@ -452,5 +461,171 @@ void TestMailSync::aChannelNameIsNotLetInVerbatim()
QVERIFY(!text.contains(QLatin1Char('\n')));
}
+// Item 54. A cron sync clears the pending-edit count only if it succeeded, and
+// the only evidence of that available to this process is the RUN END line the
+// script writes into its log. These tests pin the parser against the exact
+// shape assets/mailsync.sh emits.
+
+void TestMailSync::lastRunOutcomeReadsAnOkRun()
+{
+ const QString path = m_dir.filePath(QStringLiteral("ok.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN START: 2026-08-09T10:20:00+02:00 =====\n"
+ "10:20:01 Channel one\n"
+ "===== RUN END: 2026-08-09T10:20:03+02:00 status=OK =====\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Ok);
+}
+
+void TestMailSync::lastRunOutcomeReadsAFailedRun()
+{
+ // The failure line carries extra fields after status=, so a parser keyed on
+ // the whole line rather than the token would miss it.
+ const QString path = m_dir.filePath(QStringLiteral("failed.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T10:30:07+02:00 status=FAILED "
+ "mbsync=1 notmuch=0 =====\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Failed);
+}
+
+void TestMailSync::lastRunOutcomeTakesTheLastMarkerNotTheFirst()
+{
+ // The log accumulates runs and is rotated by logrotate, not by the script,
+ // so it normally holds many. Reading the first marker would report an
+ // outcome from hours ago, and in the direction that matters: an old OK
+ // would clear the count for a run that has just failed.
+ const QString path = m_dir.filePath(QStringLiteral("many.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T10:00:03+02:00 status=OK =====\n"
+ "===== RUN END: 2026-08-09T10:10:03+02:00 status=OK =====\n"
+ "===== RUN END: 2026-08-09T10:20:07+02:00 status=FAILED "
+ "mbsync=1 notmuch=0 =====\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Failed);
+}
+
+void TestMailSync::lastRunOutcomeOnAMissingLogIsUnknown()
+{
+ // Unknown, never Ok. The caller clears the user's pending count on Ok, so
+ // an absent log must not be able to assert that edits reached the store.
+ QCOMPARE(MailSync::lastRunOutcome(m_dir.filePath(QStringLiteral("nope.log"))),
+ SyncOutcome::Unknown);
+ QCOMPARE(MailSync::lastRunOutcome(QString()), SyncOutcome::Unknown);
+}
+
+void TestMailSync::lastRunOutcomeOnALogWithNoMarkerIsUnknown()
+{
+ const QString path = m_dir.filePath(QStringLiteral("nomarker.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("10:20:01 Channel one\n10:20:02 Channel two\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Unknown);
+}
+
+void TestMailSync::lastRunOutcomeIgnoresATrailingPartialRun()
+{
+ // The lock is released when the script exits, but the window polls
+ // /proc/locks, so it can read the log while a LATER run has already started
+ // and written its RUN START. Only END lines carry an outcome.
+ const QString path = m_dir.filePath(QStringLiteral("partial.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T10:20:03+02:00 status=OK =====\n"
+ "===== RUN START: 2026-08-09T10:30:00+02:00 =====\n"
+ "10:30:01 Channel one\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Ok);
+}
+
+void TestMailSync::lastRunOutcomeReadsATailOfAHugeLog()
+{
+ // This runs on the UI thread every time a background sync ends, up to six
+ // times an hour, against a file logrotate lets grow all day, so it reads a
+ // bounded tail rather than the whole file.
+ //
+ // Asserted by CONTENT, not by timing. A first version of this test timed
+ // the call and required it under 100 ms; it passed with the seek deleted,
+ // because reading 10 MB is quick enough either way. The probe measured
+ // nothing. A marker reachable only from the head of the file cannot be
+ // found by a tail read and cannot be missed by a whole-file read, so the
+ // two implementations give different answers here.
+ const QString path = m_dir.filePath(QStringLiteral("huge.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T09:00:03+02:00 status=OK =====\n");
+ const QByteArray filler(200, 'x');
+ for (int i = 0; i < 50000; ++i) {
+ file.write(filler);
+ file.write("\n");
+ }
+ file.close();
+ QVERIFY2(QFileInfo(path).size() > 4L * 1024 * 1024,
+ "the fixture must be big enough to matter");
+
+ // Unknown, because the only marker is megabytes above the tail. Reading the
+ // whole file would return Ok and fail this.
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Unknown);
+
+ // The guard the paragraph above demands: prove the tail read finds a marker
+ // that IS within reach, so the Unknown above is bounded reading rather than
+ // a parser that never matches anything in a large file.
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text));
+ file.write("===== RUN END: 2026-08-09T10:20:03+02:00 status=FAILED "
+ "mbsync=1 notmuch=0 =====\n");
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Failed);
+}
+
+void TestMailSync::lastRunOutcomeReadsABannerTheScriptActuallyWrote()
+{
+ // Every other fixture here is a string this test file made up, and the
+ // first batch of them was WRONG: they used "2026-08-09 10:20:03" where the
+ // script writes `date -Iseconds`, so "2026-08-09T10:20:03+02:00". The
+ // parser happened to survive it, because it keys on the "===== RUN END:"
+ // prefix and the status= token rather than on the timestamp, but nothing
+ // here proved the two formats agreed. A fixture invented to match the code
+ // tests the code against itself.
+ //
+ // So build the banner the way assets/mailsync.sh builds it, with the same
+ // command, and parse that. If the script's format changes, or the parser
+ // starts depending on the timestamp shape, this fails.
+ QProcess date;
+ date.start(QStringLiteral("date"), { QStringLiteral("-Iseconds") });
+ QVERIFY(date.waitForFinished(5000));
+ QCOMPARE(date.exitCode(), 0);
+ const QString stamp =
+ QString::fromUtf8(date.readAllStandardOutput()).trimmed();
+ QVERIFY(!stamp.isEmpty());
+
+ const QString path = m_dir.filePath(QStringLiteral("real.log"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write(QStringLiteral("===== RUN END: %1 status=OK =====\n")
+ .arg(stamp).toUtf8());
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Ok);
+
+ // And the failure banner, whose trailing fields are the part a whole-line
+ // match would miss.
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text));
+ file.write(QStringLiteral("===== RUN END: %1 status=FAILED mbsync=1 "
+ "notmuch=0 =====\n").arg(stamp).toUtf8());
+ file.close();
+
+ QCOMPARE(MailSync::lastRunOutcome(path), SyncOutcome::Failed);
+}
+
QTEST_MAIN(TestMailSync)
#include "test_mailsync.moc"