/*
 * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
 * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2 as
 * published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */

#include <QFile>
#include <QProcess>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QtTest>

#include "mailsync.h"

class TestMailSync : public QObject
{
    Q_OBJECT
private slots:
    void initTestCase();

    void unavailableWhenCommandEmpty();
    void unavailableWhenCommandIsOnlyWhitespace();
    void successfulRunEmitsFinished();
    void failedRunReportsExitCode();
    void capturesOutput();
    void capturesStderrToo();
    void emitsStartedSignal();
    void logIsClearedBetweenRuns();
    void refusesConcurrentRuns();
    void canRunAgainAfterFinishing();
    void missingBinaryReportsFailureNotSilence();
    void startDoesNotBlock();
    void argumentsAreNotShellInterpreted();
    void channelsAreAppendedToTheCommand();
    void noChannelsMeansNoExtraArguments();
    void channelNamesAreNotShellInterpreted();

    void phaseStartsAsMbsync();
    void notmuchLineSwitchesPhase();
    void mbsyncSummaryIsReported();
    void noiseLeavesThePhaseAlone();
    void aHostileLineCannotGrowTheStatus();
    void runMarkersAreNotAPhase();
    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);

    QTemporaryDir m_dir;
};

void TestMailSync::initTestCase()
{
    QVERIFY(m_dir.isValid());
}

QString TestMailSync::makeScript(const QString &name, const QString &body)
{
    const QString path = m_dir.filePath(name);
    QFile file(path);
    if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
        return QString();
    file.write("#!/bin/sh\n");
    file.write(body.toUtf8());
    file.write("\n");
    file.close();
    file.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner);
    return path;
}

void TestMailSync::unavailableWhenCommandEmpty()
{
    // Braces, not parens: MailSync sync(QString()) is a function declaration.
    MailSync sync{ QString() };
    QVERIFY(!sync.isAvailable());
    QVERIFY(!sync.start());
}

void TestMailSync::unavailableWhenCommandIsOnlyWhitespace()
{
    // A config line like `command =   ` reaches here as spaces, not as empty.
    // splitCommand yields nothing for it, so start() must refuse rather than
    // try to launch an empty program name.
    MailSync sync(QStringLiteral("   "));
    QVERIFY(!sync.start());
    QVERIFY(!sync.isRunning());
}

void TestMailSync::successfulRunEmitsFinished()
{
    MailSync sync(makeScript(QStringLiteral("ok.sh"), QStringLiteral("exit 0")));
    QVERIFY(sync.isAvailable());

    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));

    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.first().at(0).toBool(), true);
    QCOMPARE(spy.first().at(1).toInt(), 0);
}

void TestMailSync::failedRunReportsExitCode()
{
    MailSync sync(makeScript(QStringLiteral("fail.sh"), QStringLiteral("exit 3")));

    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));

    QCOMPARE(spy.first().at(0).toBool(), false);
    // The exit code reaches the UI: the status bar shows why sync failed.
    QCOMPARE(spy.first().at(1).toInt(), 3);
}

void TestMailSync::capturesOutput()
{
    MailSync sync(makeScript(QStringLiteral("talk.sh"),
                             QStringLiteral("echo syncing")));

    QSignalSpy spy(&sync, &MailSync::finished);
    QSignalSpy output(&sync, &MailSync::outputReceived);
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));

    QVERIFY(sync.log().contains(QStringLiteral("syncing")));
    QVERIFY(!output.isEmpty());
}

void TestMailSync::capturesStderrToo()
{
    // mbsync reports failures on stderr. If only stdout were captured, the log
    // pane would be empty for exactly the runs the user needs to read.
    MailSync sync(makeScript(QStringLiteral("noisy.sh"),
                             QStringLiteral("echo boom >&2\nexit 1")));

    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));

    QVERIFY(sync.log().contains(QStringLiteral("boom")));
}

void TestMailSync::emitsStartedSignal()
{
    MailSync sync(makeScript(QStringLiteral("started.sh"), QStringLiteral("exit 0")));

    QSignalSpy started(&sync, &MailSync::started);
    QSignalSpy finished(&sync, &MailSync::finished);
    QVERIFY(sync.start());
    QVERIFY(finished.wait(5000));

    QCOMPARE(started.count(), 1);
}

void TestMailSync::logIsClearedBetweenRuns()
{
    MailSync sync(makeScript(QStringLiteral("once.sh"), QStringLiteral("echo first")));

    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));
    QVERIFY(sync.log().contains(QStringLiteral("first")));

    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));
    // Stale output from the previous run must not accumulate: the log pane is
    // meant to show what this sync did.
    QCOMPARE(sync.log().count(QStringLiteral("first")), 1);
}

void TestMailSync::refusesConcurrentRuns()
{
    MailSync sync(makeScript(QStringLiteral("slow.sh"), QStringLiteral("sleep 2")));
    QVERIFY(sync.start());
    // The cron sync and this one share a flock; starting twice from the GUI is
    // still refused locally so the button cannot queue runs.
    QVERIFY(!sync.start());
    QVERIFY(sync.isRunning());

    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(spy.wait(10000));
}

void TestMailSync::canRunAgainAfterFinishing()
{
    MailSync sync(makeScript(QStringLiteral("again.sh"), QStringLiteral("exit 0")));

    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));
    QVERIFY(!sync.isRunning());

    // A refusal must be about "currently running", not a one-shot latch.
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));
    QCOMPARE(spy.count(), 2);
}

void TestMailSync::missingBinaryReportsFailureNotSilence()
{
    // Config checks the path at load time, but the script can be deleted or
    // unmounted afterwards. Failing silently would leave the spinner up
    // forever with no explanation.
    MailSync sync(m_dir.filePath(QStringLiteral("definitely-not-here.sh")));
    QVERIFY(sync.isAvailable());

    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));

    QCOMPARE(spy.first().at(0).toBool(), false);
    QVERIFY(!sync.log().isEmpty());
    QVERIFY(!sync.isRunning());
}

void TestMailSync::startDoesNotBlock()
{
    // Spec: "The UI stays usable during sync." start() must hand off to the
    // event loop rather than waiting for the process.
    MailSync sync(makeScript(QStringLiteral("blocker.sh"), QStringLiteral("sleep 2")));

    QElapsedTimer timer;
    timer.start();
    QVERIFY(sync.start());
    const qint64 elapsed = timer.elapsed();

    QVERIFY2(elapsed < 500,
             qPrintable(QStringLiteral("start() blocked for %1ms").arg(elapsed)));

    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(spy.wait(10000));
}

void TestMailSync::argumentsAreNotShellInterpreted()
{
    // The command comes from a config file. Running it through a shell would
    // turn that value into an injection point, so the metacharacters below must
    // arrive at the script as literal arguments.
    const QString script = makeScript(QStringLiteral("args.sh"),
                                      QStringLiteral("echo \"$1\""));

    MailSync sync(QStringLiteral("%1 \"; touch %2/pwned\"")
                      .arg(script, m_dir.path()));

    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));

    QVERIFY(!QFile::exists(m_dir.filePath(QStringLiteral("pwned"))));
    QVERIFY(sync.log().contains(QStringLiteral("; touch")));
}

void TestMailSync::channelsAreAppendedToTheCommand()
{
    // Item 49: a sync that knows which accounts were touched passes their
    // channel names, and they must reach the script as separate arguments
    // after whatever the config line already carries.
    const QString script = makeScript(QStringLiteral("chan.sh"),
                                      QStringLiteral("echo \"[$@]\""));

    MailSync sync(script);
    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start({ QStringLiteral("work"), QStringLiteral("personal") }));
    QVERIFY(spy.wait(5000));

    QVERIFY(sync.log().contains(QStringLiteral("[work personal]")));
}

void TestMailSync::noChannelsMeansNoExtraArguments()
{
    // Empty means "sync everything", which is the script's own default. It must
    // not become an empty string argument: mbsync would read that as a channel
    // named "" and fail the run.
    const QString script = makeScript(QStringLiteral("nochan.sh"),
                                      QStringLiteral("echo \"count=$#\""));

    MailSync sync(script);
    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start());
    QVERIFY(spy.wait(5000));

    QVERIFY(sync.log().contains(QStringLiteral("count=0")));
}

void TestMailSync::channelNamesAreNotShellInterpreted()
{
    // Channel names are derived from config, same trust boundary as the command
    // itself, and reach the same QProcess argument list. The injection test
    // above covers the command; this covers the half added for item 49.
    const QString script = makeScript(QStringLiteral("chanargs.sh"),
                                      QStringLiteral("echo \"$1\""));

    MailSync sync(script);
    QSignalSpy spy(&sync, &MailSync::finished);
    QVERIFY(sync.start({ QStringLiteral("; touch %1/chanpwned")
                             .arg(m_dir.path()) }));
    QVERIFY(spy.wait(5000));

    QVERIFY(!QFile::exists(m_dir.filePath(QStringLiteral("chanpwned"))));
}

void TestMailSync::phaseStartsAsMbsync()
{
    // A fresh tracker has nothing to report until it is fed, and a run is
    // mbsync's until notmuch announces itself.
    SyncPhaseTracker tracker;
    QCOMPARE(tracker.phase(), SyncPhase::Starting);

    // A timestamped mbsync line, as the script emits it.
    QVERIFY(tracker.feed(QStringLiteral("10:44:11 Socket error on imap.example.org (192.0.2.1:993): timeout.")));
    QCOMPARE(tracker.phase(), SyncPhase::Mbsync);
    QVERIFY(!tracker.statusText().isEmpty());
}

void TestMailSync::notmuchLineSwitchesPhase()
{
    // "notmuch new" announces itself with its own progress wording. Matching is
    // loose on purpose: the exact phrasing varies by version, and a status that
    // goes blank because a string moved is worse than a fixed one.
    SyncPhaseTracker tracker;
    tracker.feed(QStringLiteral("10:44:32 Channels: 5    Boxes: 39    Far: +0 *15 #0 -0    Near: +1 *0 #0 -0"));
    QCOMPARE(tracker.phase(), SyncPhase::Mbsync);

    QVERIFY(tracker.feed(QStringLiteral("10:44:33 Processed 77 total files in almost no time.")));
    QCOMPARE(tracker.phase(), SyncPhase::Notmuch);

    // Both spellings notmuch uses when it finishes.
    SyncPhaseTracker other;
    other.feed(QStringLiteral("11:00:33 Added 1 new message to the database."));
    QCOMPARE(other.phase(), SyncPhase::Notmuch);

    SyncPhaseTracker third;
    third.feed(QStringLiteral("11:11:33 No new mail."));
    QCOMPARE(third.phase(), SyncPhase::Notmuch);
}

void TestMailSync::mbsyncSummaryIsReported()
{
    // mbsync prints one summary at the end of its run and nothing per channel,
    // so this line is the only concrete thing there is to show. The counts are
    // worth surfacing; the raw "Far: +0 *15 #0 -0" tail is not.
    SyncPhaseTracker tracker;
    QVERIFY(tracker.feed(QStringLiteral("10:44:32 Channels: 5    Boxes: 39    Far: +0 *15 #0 -0    Near: +1 *0 #0 -0")));

    const QString text = tracker.statusText();
    QVERIFY2(text.contains(QStringLiteral("5")), qPrintable(text));
    QVERIFY2(text.contains(QStringLiteral("39")), qPrintable(text));
}

void TestMailSync::noiseLeavesThePhaseAlone()
{
    // The overwhelming majority of a real run is this one line repeated, and it
    // must not be shown or counted as a phase change.
    SyncPhaseTracker tracker;
    tracker.feed(QStringLiteral("10:44:32 Channels: 5    Boxes: 39    Far: +0 *0 #0 -0    Near: +0 *0 #0 -0"));
    const QString before = tracker.statusText();

    QVERIFY(!tracker.feed(QStringLiteral(
        "11:11:33 Note: Ignoring non-mail file: /home/you/Mail/example/Inbox/.uidvalidity")));
    QCOMPARE(tracker.statusText(), before);
    QCOMPARE(tracker.phase(), SyncPhase::Mbsync);
}

void TestMailSync::aHostileLineCannotGrowTheStatus()
{
    // Sync output is local but unstructured, and it lands in a status label.
    // A long line must be truncated rather than resizing the status bar, and
    // control characters must not survive into it.
    SyncPhaseTracker tracker;
    tracker.feed(QStringLiteral("10:00:00 Channels: %1    Boxes: 2")
                     .arg(QString(500, QLatin1Char('9'))));

    const QString text = tracker.statusText();
    QVERIFY2(text.size() <= 120, qPrintable(QString::number(text.size())));
    QVERIFY(!text.contains(QLatin1Char('\n')));
    QVERIFY(!text.contains(QLatin1Char('\r')));
}

void TestMailSync::runMarkersAreNotAPhase()
{
    // The script's own banners bracket the run. RUN START must not read as
    // mbsync output, and RUN END must not leave a phase claiming work is still
    // going: the exit status decides the outcome, deliberately, so nothing here
    // may be parsed into success or failure.
    SyncPhaseTracker tracker;
    QVERIFY(!tracker.feed(QStringLiteral("===== RUN START: 2026-08-07T11:10:47+02:00 =====")));
    QCOMPARE(tracker.phase(), SyncPhase::Starting);

    tracker.feed(QStringLiteral("11:11:33 No new mail."));
    QCOMPARE(tracker.phase(), SyncPhase::Notmuch);

    QVERIFY(!tracker.feed(QStringLiteral(
        "===== RUN END: 2026-08-07T11:11:33+02:00  status=FAILED  mbsync=1 notmuch=0 =====")));
    // Unchanged: the banner says nothing the status bar should repeat, and the
    // caller reports the outcome from the exit code.
    QCOMPARE(tracker.phase(), SyncPhase::Notmuch);
}

void TestMailSync::theChannelNameIsShown()
{
    // What the user actually asked for: which account is being synced right
    // now. mbsync -V announces each channel as it reaches it, and the channel
    // name is the account name.
    SyncPhaseTracker tracker;

    QVERIFY(tracker.feed(QStringLiteral("11:31:16 Channel provider-work")));
    QCOMPARE(tracker.phase(), SyncPhase::Mbsync);
    QVERIFY2(tracker.statusText().contains(QStringLiteral("provider-work")),
             qPrintable(tracker.statusText()));

    // The per-box chatter between channels must not displace it: the account
    // is the useful thing, and a box name changing several times a second
    // would make the status bar unreadable.
    const QString onChannel = tracker.statusText();
    QVERIFY(!tracker.feed(QStringLiteral("11:31:16 Opening far side box INBOX...")));
    QCOMPARE(tracker.statusText(), onChannel);
    QVERIFY(!tracker.feed(QStringLiteral("11:32:20 near side: 14758 messages, 0 recent")));
    QCOMPARE(tracker.statusText(), onChannel);

    // The next channel does replace it.
    QVERIFY(tracker.feed(QStringLiteral("11:33:04 Channel provider-personal")));
    QVERIFY(tracker.statusText().contains(QStringLiteral("provider-personal")));
    QVERIFY(!tracker.statusText().contains(QStringLiteral("provider-work")));
}

void TestMailSync::aChannelNameIsNotLetInVerbatim()
{
    // The channel name comes from a config file this app does not own, and it
    // lands in a status label. A long one must not be able to stretch the
    // status bar, whatever mbsync was told to call it.
    SyncPhaseTracker tracker;
    tracker.feed(QStringLiteral("11:31:16 Channel %1")
                     .arg(QString(400, QLatin1Char('x'))));

    const QString text = tracker.statusText();
    QVERIFY2(text.size() <= 120, qPrintable(QString::number(text.size())));
    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"
