diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-25 11:44:05 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-25 11:44:05 +0200 |
| commit | 82588d51c4ad0c9e46bbd41658785eb0ba77f48b (patch) | |
| tree | a4c92bf1a050db260f01897a945b94de22bd6f58 /tests | |
| parent | cd56144ba0304247812cf61c8b6e435779df29d0 (diff) | |
| download | qtmaildir-82588d51c4ad0c9e46bbd41658785eb0ba77f48b.tar.gz qtmaildir-82588d51c4ad0c9e46bbd41658785eb0ba77f48b.zip | |
fix(compose): resolve a path a sync renamed, at all three read sites
Item 163. mbsync renames an uploaded file to add its `,U=<uid>` infix,
and the model's `MessageRef::filePath` was captured when the query ran,
so a row loaded before that sync names a file that no longer exists.
MimeParser then honestly reports a message it cannot open.
MaildirName::resolveRenamed() answers the filesystem question: returns
the path unchanged when it still exists, otherwise looks in that one
directory for the file whose unique stem matches. mbsync preserves the
stem (`<stem>:2,D` becomes `<stem>,U=5:2,D`), which is what makes this
safe to do by filename at all. It never recurses, never crosses a folder
boundary, and refuses an ambiguous match rather than guessing, since
opening or moving the wrong message is worse than reporting none.
It lives in MaildirName because that namespace already owns the `,U=`
infix and is a pure-value unit testable without a widget. A file that
changed FOLDERS is a different question that only the message id can
answer, and NotmuchWorker::moveMessages() re-resolves that way already.
Three call sites, all of which held a stale path:
- The message pane, which reported "(unreadable message)" over a file
that was on disk and readable. Cosmetic and self-repairing.
- Reply and Forward, refused outright, so the user could not answer a
message that was sitting there.
- The draft reopen, and this is the half that costs data. The refusal
happens BEFORE any composer exists, so the user composes again into a
fresh window whose autosave has no previous path to unlink. The old
revision survives, each save mints a new Message-ID, and both files
reach the server. The unlink machinery was correct throughout and
never ran.
forDraft() seeds draftPath from the RESOLVED path, never the caller's:
seeding the stale one would let the reopen succeed and the unlink still
miss, which is the same fork arriving one step later.
Covered by five unit tests on the resolver, including the two that keep
it honest (a genuinely missing file yields nothing, and a neighbouring
message is never matched), and by an integration test that renames the
draft the way mbsync does and asserts the file COUNT, which is the shape
the fork actually takes. Both mutation-checked; the integration test
fails with the reported symptom when the resolution is removed.
The stable-Message-ID question is deliberately untouched: it is what
turns a stale path into two server-side messages rather than one
replaced file, and it wants its own item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_maildirname.cpp | 96 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 78 |
2 files changed, 174 insertions, 0 deletions
diff --git a/tests/test_maildirname.cpp b/tests/test_maildirname.cpp index dcc8fab..a8e4c01 100644 --- a/tests/test_maildirname.cpp +++ b/tests/test_maildirname.cpp @@ -18,7 +18,10 @@ #include "maildirname.h" +#include <QDir> +#include <QFile> #include <QSet> +#include <QTemporaryDir> #include <QTest> class TestMaildirName : public QObject @@ -31,6 +34,11 @@ private slots: void anEmptyFlagSuffixIsPreserved(); void aNameWithNoSuffixGetsNone(); void theUidInfixIsNotCarriedAcross(); + void resolveRenamedReturnsAPathThatStillExists(); + void resolveRenamedFindsTheFileMbsyncRenamed(); + void resolveRenamedIsEmptyWhenTheFileIsReallyGone(); + void resolveRenamedDoesNotMatchADifferentMessage(); + void resolveRenamedRefusesAnAmbiguousMatch(); }; // Two messages written in the same second must not collide, which a @@ -89,5 +97,93 @@ void TestMaildirName::theUidInfixIsNotCarriedAcross() .arg(name))); } +namespace { + +/// One empty file, so a test can assert on which PATH is chosen rather than on +/// content. resolveRenamed() answers a filesystem question and never opens the +/// file. +bool touch(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) + return false; + file.close(); + return true; +} + +} // namespace + +void TestMaildirName::resolveRenamedReturnsAPathThatStillExists() +{ + // The ordinary case, and the one that must stay cheap: nothing was + // renamed, so the answer is the question. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString path = dir.filePath(QStringLiteral("1787647354.M369Q2.host:2,D")); + QVERIFY(touch(path)); + + QCOMPARE(MaildirName::resolveRenamed(path), path); +} + +void TestMaildirName::resolveRenamedFindsTheFileMbsyncRenamed() +{ + // Item 163. mbsync uploads the file and inserts its `,U=<uid>` infix + // before the flag suffix, leaving the unique stem alone. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString stale = dir.filePath(QStringLiteral("1787647354.M369Q2.host:2,D")); + const QString renamed = + dir.filePath(QStringLiteral("1787647354.M369Q2.host,U=5:2,D")); + QVERIFY(touch(renamed)); + QVERIFY2(!QFile::exists(stale), "the stale path must not exist"); + + QCOMPARE(MaildirName::resolveRenamed(stale), renamed); +} + +void TestMaildirName::resolveRenamedIsEmptyWhenTheFileIsReallyGone() +{ + // The bounded half. A deleted file must NOT be recovered from, or a + // reportable defect becomes a wrong answer. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString gone = dir.filePath(QStringLiteral("1787647354.M369Q2.host:2,D")); + QVERIFY(!QFile::exists(gone)); + + QVERIFY(MaildirName::resolveRenamed(gone).isEmpty()); +} + +void TestMaildirName::resolveRenamedDoesNotMatchADifferentMessage() +{ + // A neighbouring file in the same folder is not this message. Matching on + // anything looser than the whole stem would return it, and the caller + // would then open, display or MOVE the wrong mail. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString stale = dir.filePath(QStringLiteral("1787647354.M369Q2.host:2,D")); + QVERIFY(touch(dir.filePath(QStringLiteral("1787647354.M369Q3.host,U=5:2,D")))); + QVERIFY(touch(dir.filePath(QStringLiteral("9999999999.M111Q1.host,U=6:2,D")))); + + QVERIFY(MaildirName::resolveRenamed(stale).isEmpty()); +} + +void TestMaildirName::resolveRenamedRefusesAnAmbiguousMatch() +{ + // Two files sharing one stem cannot happen in a correct Maildir, so this + // is a "the world is not what I assumed" case. Guessing between them could + // move or delete the wrong file, and the caller reports honestly instead. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString stale = dir.filePath(QStringLiteral("1787647354.M369Q2.host:2,D")); + QVERIFY(touch(dir.filePath(QStringLiteral("1787647354.M369Q2.host,U=5:2,D")))); + QVERIFY(touch(dir.filePath(QStringLiteral("1787647354.M369Q2.host,U=6:2,S")))); + + QVERIFY(MaildirName::resolveRenamed(stale).isEmpty()); +} + QTEST_MAIN(TestMaildirName) #include "test_maildirname.moc" diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index f935cac..21c3661 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -490,6 +490,7 @@ private slots: void doubleClickingADraftOpensTheComposer(); void aResumedDraftReplacesItsFileRatherThanAddingOne(); void aResumedDraftKeepsItsBlindRecipients(); + void aDraftRenamedByASyncStillReopensAndReplacesItsFile(); void theComposerSplitsItsToolbarByScope(); void ccAndBccHideBehindADisclosure(); void ccAndBccAreRevealedWhenTheyCarryAValue(); @@ -12861,6 +12862,83 @@ void TestMainWindow::aResumedDraftReplacesItsFileRatherThanAddingOne() "now exists twice"); } +void TestMainWindow::aDraftRenamedByASyncStillReopensAndReplacesItsFile() +{ + // Item 163, the composer site, and the one that costs data rather than + // display. mbsync uploads a draft and renames it to add its `,U=<uid>` + // infix; the model's path was captured when the query ran, so the reopen + // is handed a name that no longer exists. + // + // The refusal happens BEFORE any composer exists, so the user composes + // again into a FRESH window whose autosave has no previous path to unlink. + // The old revision survives, each save mints a new Message-ID, and both + // files reach the server. Asserted as the file COUNT, which is the shape + // the fork actually takes. + ComposeFixture fixture; + QVERIFY(fixture.build()); + + OutgoingMessage message; + message.accountKey = QStringLiteral("acct"); + message.to = { QStringLiteral("someone@example.org") }; + message.subject = QStringLiteral("Written before a sync"); + message.markdownBody = QStringLiteral("The first half."); + + const QString folder = fixture.mailRoot() + QStringLiteral("/acct/Drafts"); + const QString path = writeDraftFile(folder, message, + fixture.config().account( + QStringLiteral("acct"))); + QVERIFY(!path.isEmpty()); + + // mbsync's rename: same directory, same unique stem, `,U=<uid>` inserted + // before the flag suffix. Nothing reindexes, so the caller below still + // holds the pre-rename name, which is the whole precondition. + const QFileInfo before(path); + const QString base = before.fileName(); + const int suffix = base.indexOf(QStringLiteral(":2,")); + QVERIFY2(suffix > 0, "the draft fixture has no maildir flag suffix"); + const QString renamed = before.absolutePath() + QLatin1Char('/') + + base.left(suffix) + QStringLiteral(",U=7") + + base.mid(suffix); + QVERIFY2(QFile::rename(path, renamed), "could not stage the sync rename"); + + // The guard that proves this test can fail: without it, a fixture that + // quietly left the original in place would pass against the bug. + QVERIFY2(!QFile::exists(path), "the stale path should no longer exist"); + + const auto draftCount = [&folder]() { + return QDir(folder + QStringLiteral("/cur")) + .entryList(QDir::Files).size(); + }; + QCOMPARE(draftCount(), 1); + + // The STALE path, exactly as openComposerFor() passes MessageRef::filePath. + const ComposeContext context = + ComposeContextBuilder::forDraft(fixture.config(), path); + QVERIFY2(context.kind == ComposeContext::Kind::Draft, + "the reopen was refused, so the user would compose a second draft"); + // Resolved, not the caller's: seeding the stale path would let the reopen + // succeed and the unlink still miss, forking the draft one step later. + QCOMPARE(context.draftPath, renamed); + + ComposeWindow window(context, fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("The second half.")); + + auto *timer = window.findChild<QTimer *>(QStringLiteral("autosave")); + QVERIFY2(timer, "the composer has no autosave timer"); + QVERIFY2(timer->isActive(), "editing the body did not arm the autosave"); + timer->setInterval(0); + QTRY_VERIFY_WITH_TIMEOUT(!timer->isActive(), 5000); + + // Still ONE draft: the autosave replaced the renamed file rather than + // leaving it behind beside a new one. + QCOMPARE(draftCount(), 1); + QVERIFY2(!QFile::exists(renamed), + "the renamed draft survived the autosave, so the draft was forked " + "into two files and both would reach the server"); +} + void TestMainWindow::aResumedDraftKeepsItsBlindRecipients() { // MessageBuilder writes Bcc into the draft file deliberately, and says |
