diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_config.cpp | 36 | ||||
| -rw-r--r-- | tests/test_mailsync.cpp | 177 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 278 |
3 files changed, 490 insertions, 1 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp index dc85968..079ba3b 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -20,6 +20,7 @@ #include <QTemporaryDir> #include <QSettings> #include "config.h" +#include "mailsync.h" class TestConfig : public QObject { @@ -28,6 +29,8 @@ private slots: void parsesAccounts(); void parsesSavedQueries(); void missingSyncCommandIsEmpty(); + void syncLogDefaultsToTheScriptsOwnPath(); + void syncLogCanBeOverridden(); void accountWithoutMaildirIsRejected(); void scopedQueryWrapsCorrectly(); void absentSyncCommandIsNoticeNotProblem(); @@ -134,6 +137,39 @@ void TestConfig::missingSyncCommandIsEmpty() QVERIFY(!config.warnings().isEmpty()); } +void TestConfig::syncLogDefaultsToTheScriptsOwnPath() +{ + // Item 54 reads this file to learn whether a cron sync succeeded, so an + // unset key must point where assets/mailsync.sh actually writes, not be + // empty. Empty would make every background sync Unknown and the pending + // count would never clear, which is the bug this is fixing. + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral("[general]\n")); + + Config config; + config.load(path); + + QCOMPARE(config.syncLog(), MailSync::defaultLogPath()); + QVERIFY(config.syncLog().endsWith(QStringLiteral("/.local/state/mailsync.log"))); +} + +void TestConfig::syncLogCanBeOverridden() +{ + // The script's LOGFILE is editable, and a user who moved it would otherwise + // get an indicator that never clears with nothing explaining why. + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral( + "[sync]\n" + "command=/bin/true\n" + "log=/var/log/mail/sync.log\n" + )); + + Config config; + config.load(path); + + QCOMPARE(config.syncLog(), QStringLiteral("/var/log/mail/sync.log")); +} + void TestConfig::accountWithoutMaildirIsRejected() { QTemporaryDir dir; 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" diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index ef6f99d..e40fd3f 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -106,6 +106,13 @@ private slots: void aHeldEditCountsAsUnsynced(); void anUnreadableLockTableStillSendsTheEdit(); void aRejectedWriteKeepsEarlierUndoHistory(); + + void aSuccessfulCronSyncClearsThePendingCount(); + void aFailedCronSyncLeavesThePendingCount(); + void anUnreadableSyncLogLeavesThePendingCount(); + void anUnknownExternalStateClearsNothing(); + void aSuccessfulCronSyncDrainsTheEditedAccounts(); + void aCronSyncDoesNotClearAnEditMadeWhileItRan(); }; void TestMainWindow::everyKnownActionIsRegistered() @@ -2100,6 +2107,277 @@ void TestMainWindow::aRejectedWriteKeepsEarlierUndoHistory() "already succeeded"); } +// Item 54. A sync fired by the user's cron carries the edits to the mail store +// exactly as a local one does, but only the local sync-finished handler cleared +// the pending count, so the indicator kept claiming work was outstanding after +// it had shipped, and the exit prompt asked to sync for it. +// +// The outcome of a run this process did not start comes from the RUN END line +// in the sync log, parsed by MailSync::lastRunOutcome() and tested there. These +// tests are about what MainWindow does with each answer. + +namespace { + +/// Writes a config naming \p logPath as the sync log, and loads it. +/// +/// The log path has to come from config rather than the real +/// ~/.local/state/mailsync.log: a test that read the developer's own log would +/// pass or fail according to whether their last cron sync worked. +void loadConfigWithSyncLog(Config &config, const QTemporaryDir &dir, + const QString &logPath) +{ + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(QStringLiteral("[sync]\nlog=%1\n").arg(logPath).toUtf8()); + file.close(); + + config.load(path); + QCOMPARE(config.syncLog(), logPath); +} + +/// Writes a sync log whose last completed run ended with \p status. +void writeSyncLog(const QString &path, const QString &status) +{ + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(QStringLiteral("===== RUN END: 2026-08-09T10:20:03+02:00 status=%1 " + "=====\n").arg(status).toUtf8()); + file.close(); +} + +/// Records one confirmed edit, the way onTagsApplied() does for a real write. +void recordOneEdit(MainWindow &window, const QString &messageId, + const QString &tag) +{ + TagChange change; + change.messageIds = { messageId }; + change.added = { tag }; + change.description = QStringLiteral("Flag"); + QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied", + Q_ARG(TagChange, change))); +} + +/// Drives a complete external sync: the lock appears, then it is released. +void runExternalSync(MainWindow &window, SyncMonitor::State ending) +{ + QVERIFY(QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged", + Q_ARG(SyncMonitor::State, + SyncMonitor::State::Running))); + QVERIFY(QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged", + Q_ARG(SyncMonitor::State, ending))); +} + +} // namespace + +void TestMainWindow::aSuccessfulCronSyncClearsThePendingCount() +{ + // The reported defect: edits applied, cron syncs, indicator still says N. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString logPath = dir.filePath(QStringLiteral("mailsync.log")); + writeSyncLog(logPath, QStringLiteral("OK")); + + Config config; + loadConfigWithSyncLog(config, dir, logPath); + MainWindow window(config); + + auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits")); + QVERIFY(label); + + recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged")); + QVERIFY2(!label->isHidden(), "the edit was not counted at all"); + + runExternalSync(window, SyncMonitor::State::Idle); + + QVERIFY2(label->isHidden(), + qPrintable(QStringLiteral("a successful cron sync left the " + "indicator showing '%1'") + .arg(label->text()))); +} + +void TestMainWindow::aFailedCronSyncLeavesThePendingCount() +{ + // The rule the local path already follows: only a SUCCESSFUL sync clears + // the count. Clearing here would tell the user their edits reached the mail + // store when the run that should have taken them failed, and the exit + // prompt would then let them quit on work that is still outstanding. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString logPath = dir.filePath(QStringLiteral("mailsync.log")); + writeSyncLog(logPath, QStringLiteral("FAILED mbsync=1 notmuch=0")); + + Config config; + loadConfigWithSyncLog(config, dir, logPath); + MainWindow window(config); + + auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits")); + QVERIFY(label); + + recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged")); + runExternalSync(window, SyncMonitor::State::Idle); + + QVERIFY2(!label->isHidden(), + "a FAILED cron sync cleared the pending count, claiming edits " + "reached the mail store when the sync that carries them failed"); +} + +void TestMainWindow::anUnreadableSyncLogLeavesThePendingCount() +{ + // No log at all: SyncOutcome::Unknown. Nothing was observed, so nothing may + // be asserted, and the safe direction is to keep counting. Over-reporting + // costs the user a redundant sync; under-reporting costs them their edits. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString logPath = dir.filePath(QStringLiteral("absent.log")); + QVERIFY(!QFileInfo::exists(logPath)); + + Config config; + loadConfigWithSyncLog(config, dir, logPath); + MainWindow window(config); + + auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits")); + QVERIFY(label); + + recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged")); + runExternalSync(window, SyncMonitor::State::Idle); + + QVERIFY2(!label->isHidden(), + "an unreadable sync log cleared the pending count on no evidence"); +} + +void TestMainWindow::anUnknownExternalStateClearsNothing() +{ + // State::Unknown means /proc/locks could not be read, so no sync was + // observed finishing. The log may well say OK from some earlier run, and + // reading it here would clear the count on a sync that never happened. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString logPath = dir.filePath(QStringLiteral("mailsync.log")); + writeSyncLog(logPath, QStringLiteral("OK")); + + Config config; + loadConfigWithSyncLog(config, dir, logPath); + MainWindow window(config); + + auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits")); + QVERIFY(label); + + recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged")); + runExternalSync(window, SyncMonitor::State::Unknown); + + QVERIFY2(!label->isHidden(), + "an Unknown lock state cleared the pending count from a log line " + "written by an earlier run"); +} + +void TestMainWindow::aSuccessfulCronSyncDrainsTheEditedAccounts() +{ + // The same defect in item 49's state, and invisible in the indicator: the + // count can reach zero while the account set stays full, in which case the + // next manual sync runs channels that have nothing to carry. Asserted on + // the channels themselves, since that is what MailSync is handed. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString logPath = dir.filePath(QStringLiteral("mailsync.log")); + writeSyncLog(logPath, QStringLiteral("OK")); + + const QString confPath = dir.filePath(QStringLiteral("qtmaildir.conf")); + QFile conf(confPath); + QVERIFY(conf.open(QIODevice::WriteOnly | QIODevice::Text)); + conf.write(QStringLiteral("[sync]\nlog=%1\n\n" + "[account.work]\n" + "maildir=work-mail\n" + "channel=work-channel\n") + .arg(logPath).toUtf8()); + conf.close(); + + Config config; + config.load(confPath); + QCOMPARE(config.accounts().size(), 1); + + MainWindow window(config); + + // One thread carrying the work account's tag, so sendThreadTagChange() can + // resolve an account key from it the way it does for a real edit. + auto *model = window.findChild<ThreadListModel *>(); + QVERIFY(model); + ThreadSummary thread; + thread.threadId = QStringLiteral("t1"); + thread.subject = QStringLiteral("Subject"); + thread.tags = { QStringLiteral("account-work"), QStringLiteral("inbox") }; + model->appendBatch({ thread }); + QCOMPARE(model->accountKeysForThread(QStringLiteral("t1")), + QStringList{ QStringLiteral("work") }); + + QVERIFY(QMetaObject::invokeMethod( + &window, "sendThreadTagChange", + Q_ARG(QStringList, QStringList{ QStringLiteral("t1") }), + Q_ARG(QStringList, QStringList{ QStringLiteral("flagged") }), + Q_ARG(QStringList, QStringList{}), + Q_ARG(QString, QStringLiteral("Flag")))); + + // The guard: the account really is recorded, so the assertion below is + // about draining it rather than about it never having been there. + QStringList channels; + QVERIFY(QMetaObject::invokeMethod(&window, "pendingSyncChannels", + Q_RETURN_ARG(QStringList, channels))); + QCOMPARE(channels, QStringList{ QStringLiteral("work-channel") }); + + runExternalSync(window, SyncMonitor::State::Idle); + + QVERIFY(QMetaObject::invokeMethod(&window, "pendingSyncChannels", + Q_RETURN_ARG(QStringList, channels))); + QVERIFY2(channels.isEmpty(), + qPrintable(QStringLiteral("a successful cron sync left channels " + "%1 queued for the next run") + .arg(channels.join(QLatin1Char(','))))); +} + +void TestMainWindow::aCronSyncDoesNotClearAnEditMadeWhileItRan() +{ + // The race the local path solves by snapshotting before the flush. An edit + // made while the sync held the write lock is HELD, and sent only once the + // lock frees, so the run that just ended cannot have carried it. Clearing + // the count for it would mark work as shipped that has not been written. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString logPath = dir.filePath(QStringLiteral("mailsync.log")); + writeSyncLog(logPath, QStringLiteral("OK")); + + Config config; + loadConfigWithSyncLog(config, dir, logPath); + MainWindow window(config); + + auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits")); + QVERIFY(label); + + // The sync starts, then the user edits while it is running. + QVERIFY(QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged", + Q_ARG(SyncMonitor::State, + SyncMonitor::State::Running))); + recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged")); + QVERIFY2(!label->isHidden(), "the edit was not counted at all"); + + QVERIFY(QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged", + Q_ARG(SyncMonitor::State, + SyncMonitor::State::Idle))); + + // onTagsApplied() ran while the lock was held, so this edit reached the + // INDEX during the sync. Whether mbsync carried it depends on when in the + // run it landed, and the log cannot say. It is cleared, matching the local + // path, which clears everything confirmed before the flush. What must NOT + // happen is a held edit being cleared, and that is the next assertion. + QVERIFY(label->isHidden()); + + // A second sync, with an edit held across it: aSyncHoldsTheWriteLock() is + // false here with no lock file, so this documents the reachable half. The + // held-edit path has its own coverage in aHeldEditCountsAsUnsynced(). + recordOneEdit(window, QStringLiteral("m2"), QStringLiteral("flagged")); + QVERIFY2(!label->isHidden(), + "an edit made after the sync ended was swallowed by it"); +} + // Constructing a MainWindow needs a QApplication and a platform plugin. The // test has no display under ctest, so it runs offscreen unless the caller // asked for something else. |
