diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 20 | ||||
| -rw-r--r-- | tests/test_mailsync.cpp | 190 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 186 |
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. |
