aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp95
1 files changed, 95 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 08b2f84..253d3e6 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -202,6 +202,7 @@ private slots:
void autoSyncIsNotArmedWhenDisabledOrWithNothingPending();
void autoSyncSkipsWhileABackgroundSyncIsRunning();
void aSkippedAutoSyncRearmsRatherThanGivingUp();
+ void aHeldEditIsSentBeforeTheSyncEndRefreshReadsTheDatabase();
void aSuccessfulSyncRefreshesRatherThanRerunningTheQuery();
void markReadCanBeDisabled();
void pendingEditCountSurvivesAQuery();
@@ -4106,6 +4107,100 @@ void TestMainWindow::aSkippedAutoSyncRearmsRatherThanGivingUp()
"the re-armed sync cleared the pending indicator");
}
+void TestMainWindow::aHeldEditIsSentBeforeTheSyncEndRefreshReadsTheDatabase()
+{
+ // Reported by hand, 2026-08-15: read a message while a cron sync is
+ // running, watch the unread tag go and the status bar say the change will
+ // be applied when the sync finishes, and when it does the message is unread
+ // again.
+ //
+ // The edit is held during a sync because the worker's read-write open
+ // BLOCKS on notmuch's exclusive lock. At sync end the handler refreshed the
+ // list FIRST and flushed the held edits afterwards, so the refresh read a
+ // database that still carried `unread`, reconciled it into the model, and
+ // overwrote the optimistic update. The flush then wrote the tag correctly.
+ // The database ended up right and the list ended up wrong, with nothing
+ // scheduled to re-read it, which is why it looked like the edit was lost.
+ //
+ // Asserted on the ORDER, not on the tag: this window has no worker to
+ // answer either the refresh or the write, so the rows cannot show the
+ // outcome. What decides the bug is whether the edit had been sent by the
+ // time the refresh was issued.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString locks = dir.filePath(QStringLiteral("locks"));
+ {
+ QFile f(locks);
+ QVERIFY(f.open(QIODevice::WriteOnly));
+ // A held lock, so the edit below is held rather than sent.
+ f.write("1: FLOCK ADVISORY WRITE 1 00:00:0 0\n");
+ }
+ MainWindow::setLocksPathForTesting(locks);
+
+ Config config;
+ config.load(writeSyncConfig(dir));
+ MainWindow window(config);
+
+ auto *queryEdit = window.findChild<QLineEdit *>();
+ QVERIFY(queryEdit);
+ queryEdit->setText(QStringLiteral("tag:unread"));
+ queryEdit->returnPressed();
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ model->appendBatch({ makeThread(QStringLiteral("T1"),
+ { QStringLiteral("unread") }) });
+ selectThreadRow(view, 0);
+
+ // The sync is observed as running, which is what makes the edit held.
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Running));
+
+ auto *toggleUnread =
+ window.findChild<QAction *>(QStringLiteral("toggle_unread"));
+ QVERIFY(toggleUnread);
+ toggleUnread->trigger();
+
+ QVERIFY2(window.hasEditAwaitingSend(),
+ "the edit was not held, so this test proves nothing about the "
+ "order the sync-end handler does things in");
+
+ const quint64 before = window.currentGenerationForTesting();
+
+ // The sync ends. Both the refresh and the flush happen in this one call.
+ {
+ QFile f(locks);
+ QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Truncate));
+ }
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Idle));
+
+ QVERIFY2(!window.hasEditAwaitingSend(),
+ "the sync ended without ever flushing the held edit");
+
+ // The write went out. Nothing else in this handler sends one, so its
+ // presence is what proves the flush ran, and the refresh below is what it
+ // has to have run BEFORE.
+ QVERIFY2(!window.pendingThreadIdsForTesting().isEmpty(),
+ "the held edit was dropped rather than sent");
+
+ const quint64 after = window.currentGenerationForTesting();
+ QVERIFY2(after > before, "the sync end did not refresh the list at all");
+
+ // THE ASSERTION THIS TEST EXISTS FOR. Both orders leave identical end
+ // state, so everything above passes against the defect; only the
+ // generation stamped at flush time separates them.
+ //
+ // Flushing first means the stamp is the generation from BEFORE the refresh
+ // bumped it. Refreshing first means the flush sees the bumped one, and the
+ // refresh has already read a database that still carries the old tag.
+ QCOMPARE(window.flushGenerationForTesting(), before);
+}
+
void TestMainWindow::aSuccessfulSyncRefreshesRatherThanRerunningTheQuery()
{
// Reported by hand against item 71: reading a message in the Unread view,