aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt20
-rw-r--r--tests/test_mailsync.cpp190
-rw-r--r--tests/test_mainwindow.cpp186
3 files changed, 395 insertions, 1 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index e67d7e0..24da10b 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -96,6 +96,13 @@ target_compile_definitions(test_translations PRIVATE
target_compile_definitions(test_composewindow PRIVATE
SOURCE_DIR="${CMAKE_SOURCE_DIR}")
+# One test RUNS assets/mailsync.sh with stubbed binaries and reads the status
+# file it wrote, which is the guard against the bash writer and the C++ reader
+# drifting apart. Every other test in that file writes what it believes the
+# script emits, and would go on passing after the script changed.
+target_compile_definitions(test_mailsync PRIVATE
+ SOURCE_DIR="${CMAKE_SOURCE_DIR}")
+
# The notmuch hooks (assets/hooks/), which are Python rather than C++ and are
# therefore registered directly rather than through add_qtmaildir_test().
#
@@ -112,6 +119,19 @@ if(Python3_Interpreter_FOUND)
COMMAND ${Python3_EXECUTABLE}
${CMAKE_SOURCE_DIR}/assets/hooks/test_${hook_test}.py)
endforeach()
+
+ # The sync script, which lives in assets/ rather than assets/hooks/ and so
+ # is registered on its own rather than through the loop above.
+ #
+ # It belongs in the suite for a stronger reason than the hooks do: the
+ # user's ~/bin/mailsync.sh is a SYMLINK to assets/mailsync.sh, so an edit
+ # here is live on their next cron tick with no deploy step in between. The
+ # test stubs mbsync and notmuch, points HOME at a temp directory and
+ # redirects the lock file, so it can neither reach the network nor take the
+ # real sync lock, which is the mutex their cron run uses.
+ add_test(NAME mailsync_script
+ COMMAND ${Python3_EXECUTABLE}
+ ${CMAKE_SOURCE_DIR}/assets/test_mailsync.py)
else()
message(STATUS "Python3 not found: the notmuch hook tests will not run")
endif()
diff --git a/tests/test_mailsync.cpp b/tests/test_mailsync.cpp
index 45c7767..eb4990e 100644
--- a/tests/test_mailsync.cpp
+++ b/tests/test_mailsync.cpp
@@ -65,6 +65,16 @@ private slots:
void lastRunOutcomeReadsATailOfAHugeLog();
void lastRunOutcomeReadsABannerTheScriptActuallyWrote();
+ void readStatusReadsAnOkRun();
+ void readStatusReadsTheChannelsARunCarried();
+ void readStatusReadsAFullRunAsEveryAccount();
+ void readStatusReadsASkippedRun();
+ void readStatusOnAMissingFileIsUnknown();
+ void readStatusOnRubbishIsUnknown();
+ void readStatusOnATruncatedFileIsUnknown();
+ void readStatusOnAnUnknownVersionIsUnknown();
+ void readStatusReadsAFileTheScriptActuallyWrote();
+
private:
/// Writes an executable shell script into the temp dir, returns its path.
QString makeScript(const QString &name, const QString &body);
@@ -466,6 +476,186 @@ void TestMailSync::aChannelNameIsNotLetInVerbatim()
// script writes into its log. These tests pin the parser against the exact
// shape assets/mailsync.sh emits.
+// Item 174. The status file is what the application READS, as against the log,
+// which is for a human. These pin the reader against the exact shape
+// assets/mailsync.sh writes; assets/test_mailsync.py pins the writer against
+// the same shape from the other side, and the two agree by test rather than by
+// shared code, exactly as the two rules.json readers do.
+
+static QString writeStatus(const QDir &dir, const QString &name,
+ const QByteArray &contents)
+{
+ const QString path = dir.filePath(name);
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return QString();
+ file.write(contents);
+ file.close();
+ return path;
+}
+
+void TestMailSync::readStatusReadsAnOkRun()
+{
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("ok.json"),
+ R"({"version": 1, "run_id": "2026-08-29T10:00:00+02:00",
+ "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:12+02:00",
+ "state": "ok", "channels": ["-a"],
+ "mbsync_status": 0, "notmuch_status": 0})");
+ QVERIFY(!path.isEmpty());
+
+ const SyncStatus status = MailSync::readStatus(path);
+ QCOMPARE(status.state, SyncState::Ok);
+ QVERIFY(status.ended.isValid());
+}
+
+void TestMailSync::readStatusReadsTheChannelsARunCarried()
+{
+ // The whole reason this file exists rather than the log's banner: the
+ // application clears its pending count for the accounts a run carried, and
+ // the log could never say which those were.
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("channels.json"),
+ R"({"version": 1, "run_id": "r", "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:12+02:00", "state": "ok",
+ "channels": ["work", "personal"],
+ "mbsync_status": 0, "notmuch_status": 0})");
+ QVERIFY(!path.isEmpty());
+
+ const SyncStatus status = MailSync::readStatus(path);
+ QCOMPARE(status.state, SyncState::Ok);
+ QCOMPARE(status.channels,
+ (QStringList{ QStringLiteral("work"), QStringLiteral("personal") }));
+ QVERIFY(!status.everyChannel);
+}
+
+void TestMailSync::readStatusReadsAFullRunAsEveryAccount()
+{
+ // "-a" is not a channel name and must not be matched against one: a full
+ // run carries every account, so a reader treating it as an unknown channel
+ // would clear nothing on exactly the run that carried everything.
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("full.json"),
+ R"({"version": 1, "run_id": "r", "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:12+02:00", "state": "ok",
+ "channels": ["-a"], "mbsync_status": 0, "notmuch_status": 0})");
+ QVERIFY(!path.isEmpty());
+
+ const SyncStatus status = MailSync::readStatus(path);
+ QVERIFY2(status.everyChannel, "a -a run was not read as every account");
+}
+
+void TestMailSync::readStatusReadsASkippedRun()
+{
+ // Item 125. A skipped run releases a lock it never took, so the spinner had
+ // nothing to clear on. It is a terminal state, and distinct from a failure:
+ // the other run is doing the work.
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("skip.json"),
+ R"({"version": 1, "run_id": "r", "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:00+02:00", "state": "skipped",
+ "channels": ["-a"], "mbsync_status": -1, "notmuch_status": -1})");
+ QVERIFY(!path.isEmpty());
+
+ const SyncStatus status = MailSync::readStatus(path);
+ QCOMPARE(status.state, SyncState::Skipped);
+}
+
+void TestMailSync::readStatusOnAMissingFileIsUnknown()
+{
+ QCOMPARE(MailSync::readStatus(m_dir.filePath(QStringLiteral("nope.json"))).state,
+ SyncState::Unknown);
+ QCOMPARE(MailSync::readStatus(QString()).state, SyncState::Unknown);
+}
+
+void TestMailSync::readStatusOnRubbishIsUnknown()
+{
+ const QString path = writeStatus(QDir(m_dir.path()),
+ QStringLiteral("rubbish.json"),
+ "this is not json at all\n");
+ QVERIFY(!path.isEmpty());
+ QCOMPARE(MailSync::readStatus(path).state, SyncState::Unknown);
+}
+
+void TestMailSync::readStatusOnATruncatedFileIsUnknown()
+{
+ // The script writes atomically through a temp file and mv precisely so this
+ // cannot happen, but a reader that trusts that is one filesystem away from
+ // being wrong. Unknown changes no state, so a torn read is harmless.
+ const QString path = writeStatus(QDir(m_dir.path()),
+ QStringLiteral("torn.json"),
+ R"({"version": 1, "state": "o)");
+ QVERIFY(!path.isEmpty());
+ QCOMPARE(MailSync::readStatus(path).state, SyncState::Unknown);
+}
+
+void TestMailSync::readStatusOnAnUnknownVersionIsUnknown()
+{
+ // Refused rather than guessed at, the rule the rules file already follows:
+ // a future version may mean something different by the same field names,
+ // and acting on it would be worse than observing nothing.
+ const QString path = writeStatus(
+ QDir(m_dir.path()), QStringLiteral("future.json"),
+ R"({"version": 99, "run_id": "r", "started": "2026-08-29T10:00:00+02:00",
+ "ended": "2026-08-29T10:00:12+02:00", "state": "ok",
+ "channels": ["-a"], "mbsync_status": 0, "notmuch_status": 0})");
+ QVERIFY(!path.isEmpty());
+ QCOMPARE(MailSync::readStatus(path).state, SyncState::Unknown);
+}
+
+void TestMailSync::readStatusReadsAFileTheScriptActuallyWrote()
+{
+ // The guard against the two sides drifting apart. Every test above writes
+ // what this file BELIEVES the script emits; this one runs the real script
+ // with stubbed binaries and reads what it actually wrote.
+ //
+ // Skipped rather than failed where bash or the script is unavailable: a
+ // packaging build has no reason to carry either, and a test that cannot run
+ // has observed nothing.
+ const QString script = QStringLiteral(SOURCE_DIR "/assets/mailsync.sh");
+ if (!QFile::exists(script))
+ QSKIP("assets/mailsync.sh not found");
+
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+
+ // Stubs, so nothing reaches the network and the real lock is never taken.
+ const QString bin = home.filePath(QStringLiteral("bin"));
+ QVERIFY(QDir().mkpath(bin));
+ for (const QString &name : { QStringLiteral("mbsync"),
+ QStringLiteral("notmuch") }) {
+ QFile stub(bin + QLatin1Char('/') + name);
+ QVERIFY(stub.open(QIODevice::WriteOnly | QIODevice::Text));
+ stub.write("#!/bin/bash\nexit 0\n");
+ stub.close();
+ QVERIFY(stub.setPermissions(QFile::ReadOwner | QFile::WriteOwner
+ | QFile::ExeOwner));
+ }
+
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert(QStringLiteral("HOME"), home.path());
+ env.insert(QStringLiteral("PATH"),
+ bin + QLatin1Char(':') + env.value(QStringLiteral("PATH")));
+ // Never /tmp/mbsync.lock: that is the mutex the user's cron sync uses, and
+ // a test that took it would block their mail.
+ env.insert(QStringLiteral("MAILSYNC_LOCKFILE"),
+ home.filePath(QStringLiteral("lock")));
+
+ QProcess proc;
+ proc.setProcessEnvironment(env);
+ proc.start(QStringLiteral("bash"), { script, QStringLiteral("work") });
+ if (!proc.waitForStarted(5000))
+ QSKIP("bash not available");
+ QVERIFY(proc.waitForFinished(30000));
+
+ const SyncStatus status = MailSync::readStatus(
+ home.filePath(QStringLiteral(".local/state/qtmaildir/syncstatus.json")));
+ QCOMPARE(status.state, SyncState::Ok);
+ QCOMPARE(status.channels, QStringList{ QStringLiteral("work") });
+ QVERIFY(!status.everyChannel);
+}
+
void TestMailSync::lastRunOutcomeReadsAnOkRun()
{
const QString path = m_dir.filePath(QStringLiteral("ok.log"));
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index bbdad19..90cdbc2 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -247,6 +247,7 @@ private slots:
void init();
void cleanup();
void noTestCanSeeTheRealLockTable();
+ void noSyncTestReadsTheRealSyncState();
void everyKnownActionIsRegistered();
void everyRegisteredActionIsKnown();
void configuredBindingReachesTheAction();
@@ -429,6 +430,8 @@ private slots:
void aRejectedWriteKeepsEarlierUndoHistory();
void aSuccessfulCronSyncClearsThePendingCount();
+ void anExternalSyncClearsOnlyTheAccountsItCarried();
+ void aSkippedExternalSyncClearsNothing();
void aFailedCronSyncLeavesThePendingCount();
void anUnreadableSyncLogLeavesThePendingCount();
void anUnknownExternalStateClearsNothing();
@@ -624,6 +627,53 @@ void TestMainWindow::noTestCanSeeTheRealLockTable()
QVERIFY(MainWindow::locksPath().startsWith(QDir::tempPath()));
}
+void TestMainWindow::noSyncTestReadsTheRealSyncState()
+{
+ // The same guard as noTestCanSeeTheRealLockTable(), for the two paths a
+ // Config falls back to when a test does not name them, and it exists
+ // because that fallback bit twice in one sitting (item 174).
+ //
+ // A test writing "[sync]\nlog=..." and nothing else leaves syncStatus()
+ // pointing at the developer's real ~/.local/state/qtmaildir/syncstatus.json.
+ // Two tests asserting that a FAILED run leaves the pending count alone
+ // therefore read the last real cron run, found "ok", and passed against a
+ // broken clear. Pinning only the status key has the mirror problem: the log
+ // then defaults to the real mailsync.log.
+ //
+ // Asserted on Config rather than on any one test, so a new sync test that
+ // forgets one key fails here with a message naming the reason rather than
+ // failing mysteriously whenever the developer's last sync happened to
+ // succeed.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("[sync]\ncommand=/bin/true\n");
+ file.close();
+
+ Config config;
+ config.load(path);
+
+ // Both DO default to the real paths, which is correct for the application
+ // and is exactly the trap for a test. This documents the behaviour so the
+ // requirement below is obviously about the tests rather than the defaults.
+ QCOMPARE(config.syncLog(), MailSync::defaultLogPath());
+ QCOMPARE(config.syncStatus(), MailSync::defaultStatusPath());
+
+ QVERIFY2(MailSync::defaultStatusPath().contains(
+ QStringLiteral(".local/state/qtmaildir/syncstatus.json")),
+ "the default status path moved: assets/mailsync.sh writes the old "
+ "one, and the two must agree or every external sync reads as "
+ "Unknown");
+
+ // Any test asserting on what a sync did must name BOTH keys in its own
+ // config, pointing them inside its own QTemporaryDir. There is no fixture
+ // that can enforce it, since Config is loaded per test, so this is the
+ // reminder that fails loudly if the defaults ever stop being real paths.
+}
+
void TestMainWindow::everyKnownActionIsRegistered()
{
// KeyMap::knownActions() is what loadOverrides() validates config bindings
@@ -7015,7 +7065,17 @@ void loadConfigWithSyncLog(Config &config, const QTemporaryDir &dir,
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());
+ // The status file is pointed at this test's own directory even though these
+ // tests are about the LOG, and the omission cost two false greens: without
+ // it Config falls back to the real ~/.local/state/qtmaildir/syncstatus.json,
+ // so a test asserting that a FAILED log leaves the count alone read the
+ // developer's own last cron run, found "ok" and cleared. Same rule as the
+ // lock table: no test may observe the machine's real sync state. Pointing
+ // it at a file that does not exist makes readStatus() return Unknown, which
+ // is exactly the fallback-to-log case these tests mean to exercise.
+ file.write(QStringLiteral("[sync]\nlog=%1\nstatus=%2\n")
+ .arg(logPath, dir.filePath(QStringLiteral("no-status.json")))
+ .toUtf8());
file.close();
config.load(path);
@@ -7056,6 +7116,130 @@ void runExternalSync(MainWindow &window, SyncMonitor::State ending)
} // namespace
+/// Item 174. A run this process did not start now reports what it DID, in the
+/// status file assets/mailsync.sh writes, instead of being inferred from the
+/// log's RUN END banner.
+///
+/// The property that banner could never express: WHICH channels the run
+/// carried. The local sync path has always narrowed its clear to the accounts
+/// it carried (onSyncFinished's snapshot-and-subtract); the external path had
+/// no way to and cleared everything, so an edit to an account the run did not
+/// touch was reported as shipped when it had not been.
+void TestMainWindow::anExternalSyncClearsOnlyTheAccountsItCarried()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+
+ const QString statusPath = dir.filePath(QStringLiteral("syncstatus.json"));
+ QFile status(statusPath);
+ QVERIFY(status.open(QIODevice::WriteOnly));
+ // Timestamped NOW rather than with a fixed date: the status file is only
+ // read as this run's result when it is at least as new as the sync that
+ // just ended, so a fixture dated in the past is correctly ignored as stale
+ // and the test would exercise the log fallback instead.
+ const QString now =
+ QDateTime::currentDateTime().toString(Qt::ISODate);
+ // A run that carried ONE of the two accounts.
+ status.write(QStringLiteral(R"({"version": 1, "run_id": "r",
+ "started": "%1", "ended": "%1",
+ "state": "ok", "channels": ["work"],
+ "mbsync_status": 0, "notmuch_status": 0})")
+ .arg(now).toUtf8());
+ status.close();
+
+ const QString confPath = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile conf(confPath);
+ QVERIFY(conf.open(QIODevice::WriteOnly | QIODevice::Text));
+ // BOTH keys, always. Pinning only one leaves the other defaulting to the
+ // developer's real ~/.local/state file, and a test then reads their last
+ // cron run instead of its own fixture: that is how two tests in this group
+ // went green against a broken clear before this was noticed.
+ conf.write(QStringLiteral("[sync]\nstatus=%1\nlog=%2\n"
+ "[account.work]\nmaildir=work\ntrash=trash\n"
+ "[account.personal]\nmaildir=personal\ntrash=trash\n")
+ .arg(statusPath,
+ dir.filePath(QStringLiteral("no-log.log")))
+ .toUtf8());
+ conf.close();
+
+ Config config;
+ config.load(confPath);
+ QCOMPARE(config.syncStatus(), statusPath);
+
+ MainWindow window(config);
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ // An edit on each account. Only the first is carried by the run above.
+ QVERIFY(QMetaObject::invokeMethod(&window, "noteEditedAccountForTesting",
+ Q_ARG(QString, QStringLiteral("work"))));
+ QVERIFY(QMetaObject::invokeMethod(&window, "noteEditedAccountForTesting",
+ Q_ARG(QString,
+ QStringLiteral("personal"))));
+ recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged"));
+ QVERIFY2(!label->isHidden(), "the edit was not counted at all");
+
+ runExternalSync(window, SyncMonitor::State::Idle);
+
+ // The account the run carried is gone; the one it did not is still waiting.
+ // Asserting only that something cleared would pass against the old blanket
+ // clear, which is the behaviour this replaces.
+ QVERIFY2(!window.editedAccountsForTesting().contains(
+ QStringLiteral("work")),
+ "the account the sync carried is still marked as edited");
+ QVERIFY2(window.editedAccountsForTesting().contains(
+ QStringLiteral("personal")),
+ "an account the sync never carried was cleared anyway, which is "
+ "the blanket clear this replaces");
+}
+
+/// Item 125, the half this closes. A run that SKIPPED because another held the
+/// lock did the work of neither: it must clear no edits, and before the status
+/// file there was nothing to tell the application it had happened at all.
+void TestMainWindow::aSkippedExternalSyncClearsNothing()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+
+ const QString statusPath = dir.filePath(QStringLiteral("syncstatus.json"));
+ QFile status(statusPath);
+ QVERIFY(status.open(QIODevice::WriteOnly));
+ // NOW, for the staleness reason the other test records.
+ const QString now =
+ QDateTime::currentDateTime().toString(Qt::ISODate);
+ status.write(QStringLiteral(R"({"version": 1, "run_id": "r",
+ "started": "%1", "ended": "%1",
+ "state": "skipped", "channels": ["-a"],
+ "mbsync_status": -1, "notmuch_status": -1})")
+ .arg(now).toUtf8());
+ status.close();
+
+ const QString confPath = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile conf(confPath);
+ QVERIFY(conf.open(QIODevice::WriteOnly | QIODevice::Text));
+ conf.write(QStringLiteral("[sync]\nstatus=%1\nlog=%2\n")
+ .arg(statusPath,
+ dir.filePath(QStringLiteral("no-log.log")))
+ .toUtf8());
+ conf.close();
+
+ Config config;
+ config.load(confPath);
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ recordOneEdit(window, QStringLiteral("m1"), QStringLiteral("flagged"));
+ QVERIFY(!label->isHidden());
+
+ runExternalSync(window, SyncMonitor::State::Idle);
+
+ QVERIFY2(!label->isHidden(),
+ "a SKIPPED run cleared the pending count: it synced nothing, so "
+ "the edits are still only local");
+}
+
void TestMainWindow::aSuccessfulCronSyncClearsThePendingCount()
{
// The reported defect: edits applied, cron syncs, indicator still says N.