MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 92.9% 419 / 0 / 451
Functions: 92.7% 38 / 0 / 41
Branches: 79.0% 527 / 0 / 667

app/core/CalendarOutbox.cpp
Line Branch Exec Source
1 #include "data/ICalTodoCodec.h"
2 #include "CalendarOutbox.h"
3
4 #include "data/CalendarStore.h"
5 #include "data/ICalEventCodec.h"
6 #include "data/ICalMerge.h"
7 #include "service/CalDavClient.h"
8
9 #include <QDateTime>
10 #include <QLoggingCategory>
11 #include <utility>
12
13
4/7
✓ Branch 2 → 3 taken 15 times.
✓ Branch 2 → 8 taken 464 times.
✓ Branch 4 → 5 taken 15 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 15 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
479 Q_LOGGING_CATEGORY(lcCalendarOutbox, "mailjd.calendar.outbox")
14
15 namespace {
16
17 // Handles exactly one response of `request` on `client`, then disconnects.
18 template <typename Handler>
19 197 void onResponse(CalDavClient *client, quint64 request, QObject *context, Handler handler) {
20
3/3
void (anonymous namespace)::onResponse<CalendarOutbox::fetchServerVersion(CalendarOutboxEntry const&, QString const&, QByteArray const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#1}>(CalDavClient*, unsigned long long, QObject*, CalendarOutbox::fetchServerVersion(CalendarOutboxEntry const&, QString const&, QByteArray const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#1}):
✓ Branch 2 → 3 taken 55 times.
void (anonymous namespace)::onResponse<CalendarOutbox::push(CalendarOutboxEntry const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#2}>(CalDavClient*, unsigned long long, QObject*, CalendarOutbox::push(CalendarOutboxEntry const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#2}):
✓ Branch 2 → 3 taken 125 times.
void (anonymous namespace)::onResponse<CalendarOutbox::push(CalendarOutboxEntry const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#1}>(CalDavClient*, unsigned long long, QObject*, CalendarOutbox::push(CalendarOutboxEntry const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#1}):
✓ Branch 2 → 3 taken 17 times.
197 auto connection = std::make_shared<QMetaObject::Connection>();
21 197 *connection = QObject::connect(client, &CalDavClient::resourceResponse, context,
22
3/9
void (anonymous namespace)::onResponse<CalendarOutbox::fetchServerVersion(CalendarOutboxEntry const&, QString const&, QByteArray const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#1}>(CalDavClient*, unsigned long long, QObject*, CalendarOutbox::fetchServerVersion(CalendarOutboxEntry const&, QString const&, QByteArray const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#1}):
✓ Branch 5 → 6 taken 55 times.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
void (anonymous namespace)::onResponse<CalendarOutbox::push(CalendarOutboxEntry const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#2}>(CalDavClient*, unsigned long long, QObject*, CalendarOutbox::push(CalendarOutboxEntry const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#2}):
✓ Branch 5 → 6 taken 125 times.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
void (anonymous namespace)::onResponse<CalendarOutbox::push(CalendarOutboxEntry const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#1}>(CalDavClient*, unsigned long long, QObject*, CalendarOutbox::push(CalendarOutboxEntry const&, CalendarOutbox::Endpoint const&)::{lambda(DavResponse const&)#1}):
✓ Branch 5 → 6 taken 17 times.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
587 [connection, request, handler](const DavResponse &response) {
23
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 193 times.
193 if (response.request != request)
24 ✗ return;
25 193 QObject::disconnect(*connection);
26 193 handler(response);
27 });
28 197 }
29
30 // S106-160 (INV-12): 403 (a read-only collection) and 507 (no storage)
31 // are permanent — F-19's bounded retries end in the failed state; they
32 // used to back off forever as transient errors.
33 13 bool retryableStatus(int status) {
34
6/10
✓ Branch 2 → 3 taken 13 times.
✗ Branch 2 → 8 not taken.
✓ Branch 3 → 4 taken 13 times.
✗ Branch 3 → 8 not taken.
✓ Branch 4 → 5 taken 13 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 13 times.
✗ Branch 5 → 8 not taken.
✓ Branch 6 → 7 taken 10 times.
✓ Branch 6 → 9 taken 3 times.
23 return status == 0 || status == 401 || status == 408 || status == 429 ||
35
2/2
✓ Branch 7 → 8 taken 7 times.
✓ Branch 7 → 9 taken 3 times.
23 (status >= 500 && status != 507);
36 }
37
38 13 QString responseError(const DavResponse &response) {
39
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 13 times.
13 if (!response.error.isEmpty())
40 ✗ return response.error;
41 26 return QStringLiteral("%1 %2 answered HTTP %3")
42
2/2
✓ Branch 8 → 9 taken 13 times.
✓ Branch 9 → 10 taken 13 times.
39 .arg(QString::fromLatin1(response.method), response.url)
43
1/1
✓ Branch 11 → 12 taken 13 times.
13 .arg(response.status);
44 }
45
46 321 CalendarEvent masterOf(const QList<CalendarEvent> &rows) {
47
1/2
✓ Branch 11 → 4 taken 321 times.
✗ Branch 11 → 12 not taken.
321 for (const CalendarEvent &row : rows)
48
2/3
✓ Branch 5 → 6 taken 321 times.
✓ Branch 6 → 7 taken 321 times.
✗ Branch 6 → 9 not taken.
321 if (!row.recurrenceId.isValid())
49 321 return row;
50 ✗ return rows.value(0);
51 }
52
53 214 QList<CalendarEvent> exceptionsOf(const QList<CalendarEvent> &rows, const CalendarEvent &master) {
54 214 QList<CalendarEvent> exceptions;
55
2/2
✓ Branch 17 → 4 taken 216 times.
✓ Branch 17 → 18 taken 214 times.
430 for (const CalendarEvent &row : rows)
56
5/5
✓ Branch 5 → 6 taken 216 times.
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 12 taken 214 times.
✓ Branch 13 → 14 taken 2 times.
✓ Branch 13 → 15 taken 214 times.
218 if (row.recurrenceId.isValid() &&
57
2/6
✓ Branch 7 → 8 taken 2 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 11 taken 2 times.
✗ Branch 9 → 10 not taken.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
2 !(row.recurrenceId == master.recurrenceId && !master.recurrenceId.isValid()))
58
1/1
✓ Branch 14 → 15 taken 2 times.
2 exceptions.append(row);
59
2/3
✓ Branch 18 → 19 taken 214 times.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 214 times.
214 if (master.recurrenceId.isValid())
60 ✗ exceptions.removeFirst(); // the "master" is the first exception itself
61 214 return exceptions;
62 ✗ }
63
64 } // namespace
65
66 946 CalendarOutbox::CalendarOutbox(Deps deps, QObject *parent)
67
1/1
✓ Branch 7 → 8 taken 946 times.
946 : QObject(parent), m_deps(std::move(deps)) {
68
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 12 taken 946 times.
946 if (!m_deps.now)
69 ✗ m_deps.now = [] { return QDateTime::currentSecsSinceEpoch(); };
70
1/1
✓ Branch 12 → 13 taken 946 times.
946 m_retryTimer.setSingleShot(true);
71
1/1
✓ Branch 13 → 14 taken 946 times.
946 connect(&m_retryTimer, &QTimer::timeout, this, [this] { flush(false); });
72 946 }
73
74 644 QString CalendarOutbox::key(const QString &accountId, const QString &calendarPath, const QString &uid) {
75
3/3
✓ Branch 4 → 5 taken 644 times.
✓ Branch 5 → 6 taken 644 times.
✓ Branch 6 → 7 taken 644 times.
644 return calendarSelectionKey(accountId, calendarPath) + QLatin1Char('\n') + uid;
76 }
77
78 // F-19 (SPRINT-104): permanent rejections stop after this many attempts
79 // (about 1.5 hours with the 1800 s permanent interval) with a visible
80 // "failed" state the user can resolve.
81 constexpr int kMaxPermanentAttempts = 3;
82
83 10 qint64 CalendarOutbox::backoffSeconds(int attempts) {
84 static const qint64 steps[] = {30, 60, 120, 300, 600, 1800};
85
1/1
✓ Branch 4 → 5 taken 10 times.
10 const int index = qBound(0, attempts - 1, int(std::size(steps)) - 1);
86 10 return steps[index];
87 }
88
89 ✗ bool CalendarOutbox::hasEntry(const QString &uid, const QString &accountId,
90 const QString &calendarPath) const {
91 ✗ return m_deps.store->outboxEntry(accountId, calendarPath, uid).has_value();
92 }
93
94 // ─── Local changes ─────────────────────────────────────────
95
96 116 bool CalendarOutbox::saveEvent(const CalendarEvent &event) {
97 116 CalendarStore *store = m_deps.store;
98 116 const auto previous = store->occurrenceEvent(event.uid, event.accountId, event.calendarPath,
99
1/1
✓ Branch 2 → 3 taken 116 times.
116 event.recurrenceId);
100
1/1
✓ Branch 3 → 4 taken 116 times.
116 const auto master = store->eventFor(event.uid, event.accountId, event.calendarPath);
101 // A local edit never changes what the server told us about the resource.
102 116 CalendarEvent stored = event;
103 92 const auto inherit = [&stored](const CalendarEvent &source) {
104
2/2
✓ Branch 3 → 4 taken 74 times.
✓ Branch 3 → 5 taken 18 times.
92 if (stored.etag.isEmpty()) stored.etag = source.etag;
105
2/2
✓ Branch 6 → 7 taken 74 times.
✓ Branch 6 → 8 taken 18 times.
92 if (stored.resourceHref.isEmpty()) stored.resourceHref = source.resourceHref;
106
2/2
✓ Branch 9 → 10 taken 73 times.
✓ Branch 9 → 11 taken 19 times.
92 if (stored.resourceIcal.isEmpty()) stored.resourceIcal = source.resourceIcal;
107 208 };
108
2/2
✓ Branch 6 → 7 taken 89 times.
✓ Branch 6 → 13 taken 27 times.
116 if (previous) {
109 89 inherit(*previous);
110
2/2
✓ Branch 10 → 11 taken 58 times.
✓ Branch 10 → 17 taken 31 times.
89 if (stored.icalRaw.isEmpty()) stored.icalRaw = previous->icalRaw;
111
2/2
✓ Branch 14 → 15 taken 3 times.
✓ Branch 14 → 17 taken 24 times.
27 } else if (master) {
112 3 inherit(*master);
113 }
114
1/1
✓ Branch 17 → 18 taken 116 times.
116 stored.lastModified = QDateTime::currentDateTimeUtc();
115
3/3
✓ Branch 20 → 21 taken 116 times.
✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 23 taken 113 times.
116 if (!store->upsertEvent(stored))
116 3 return false;
117
2/2
✓ Branch 24 → 25 taken 91 times.
✓ Branch 24 → 26 taken 22 times.
113 const CalendarEvent resource = master ? *master : stored;
118
1/1
✓ Branch 31 → 32 taken 113 times.
226 store->enqueueOutbox(stored.accountId, stored.calendarPath, stored.uid, QStringLiteral("put"),
119 resource.resourceHref, resource.etag);
120
8/8
✓ Branch 34 → 35 taken 113 times.
✓ Branch 38 → 39 taken 113 times.
✓ Branch 39 → 40 taken 113 times.
✓ Branch 40 → 41 taken 113 times.
✓ Branch 41 → 42 taken 113 times.
✓ Branch 42 → 43 taken 113 times.
✓ Branch 46 → 36 taken 113 times.
✓ Branch 46 → 47 taken 113 times.
226 qCInfo(lcCalendarOutbox) << "Queued put" << stored.uid << "in" << stored.calendarPath;
121
1/1
✓ Branch 47 → 48 taken 113 times.
113 publishChange();
122
2/3
✓ Branch 49 → 50 taken 113 times.
✗ Branch 49 → 52 not taken.
✓ Branch 50 → 51 taken 113 times.
113 const bool syncs = m_deps.hasEndpoint ? m_deps.hasEndpoint(stored.accountId)
123
1/5
✗ Branch 52 → 53 not taken.
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 57 taken 113 times.
✗ Branch 81 → 82 not taken.
✗ Branch 81 → 83 not taken.
113 : m_deps.endpoint(stored.accountId).has_value();
124
5/5
✓ Branch 57 → 58 taken 51 times.
✓ Branch 57 → 59 taken 62 times.
✓ Branch 58 → 60 taken 51 times.
✓ Branch 59 → 60 taken 62 times.
✓ Branch 60 → 61 taken 113 times.
113 emit statusMessage(syncs ? tr("Saved – syncing…") : tr("Saved on this device"));
125
1/1
✓ Branch 62 → 63 taken 113 times.
113 requestFlush();
126 113 return true;
127 116 }
128
129 20 bool CalendarOutbox::removeSeries(const QString &uid, const QString &accountId,
130 const QString &calendarPath) {
131 20 CalendarStore *store = m_deps.store;
132
1/1
✓ Branch 2 → 3 taken 20 times.
20 const QList<CalendarEvent> rows = store->eventSeries(uid, accountId, calendarPath);
133
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 20 times.
20 if (rows.isEmpty())
134 ✗ return false;
135
1/1
✓ Branch 6 → 7 taken 20 times.
20 const CalendarEvent master = masterOf(rows);
136
1/1
✓ Branch 7 → 8 taken 20 times.
20 const auto existing = store->outboxEntry(accountId, calendarPath, uid);
137
2/3
✓ Branch 9 → 10 taken 20 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 20 times.
20 if (!store->deleteEvent(uid, accountId, calendarPath))
138 ✗ return false;
139
2/2
✓ Branch 14 → 15 taken 5 times.
✓ Branch 14 → 17 taken 15 times.
20 const QString baseEtag = existing ? existing->baseEtag : master.etag;
140 // F-55 (SPRINT-104): the empty ETag alone does not mean "never reached
141 // the server" — a successful PUT whose answer carries no ETag header
142 // leaves it empty while the resource exists (the contact outbox fetches
143 // it in that case). The stored resource record is the authority; an
144 // empty-ETag delete of a resource that exists queues the delete without
145 // an If-Match (the server's 412/404 answers resolve it).
146 const bool onServer =
147
1/1
✓ Branch 19 → 20 taken 20 times.
20 !store->resourceFor(accountId, calendarPath, uid).isEmpty();
148
6/6
✓ Branch 23 → 24 taken 17 times.
✓ Branch 23 → 26 taken 3 times.
✓ Branch 24 → 25 taken 13 times.
✓ Branch 24 → 26 taken 4 times.
✓ Branch 27 → 28 taken 13 times.
✓ Branch 27 → 30 taken 7 times.
20 if (baseEtag.isEmpty() && !onServer) {
149 // Never reached the server: nothing to delete there.
150
1/1
✓ Branch 28 → 29 taken 13 times.
13 store->removeOutbox(accountId, calendarPath, uid);
151
1/1
✓ Branch 29 → 55 taken 13 times.
13 store->removeResource(accountId, calendarPath, uid);
152 } else {
153
3/3
✓ Branch 31 → 32 taken 2 times.
✓ Branch 31 → 34 taken 5 times.
✓ Branch 38 → 39 taken 7 times.
16 store->enqueueOutbox(accountId, calendarPath, uid, QStringLiteral("delete"),
154 9 existing ? existing->href : master.resourceHref, baseEtag);
155
8/8
✓ Branch 41 → 42 taken 7 times.
✓ Branch 45 → 46 taken 7 times.
✓ Branch 46 → 47 taken 7 times.
✓ Branch 47 → 48 taken 7 times.
✓ Branch 48 → 49 taken 7 times.
✓ Branch 49 → 50 taken 7 times.
✓ Branch 53 → 43 taken 7 times.
✓ Branch 53 → 54 taken 7 times.
14 qCInfo(lcCalendarOutbox) << "Queued delete" << uid << "in" << calendarPath;
156 }
157
1/1
✓ Branch 55 → 56 taken 20 times.
20 publishChange();
158
2/2
✓ Branch 56 → 57 taken 20 times.
✓ Branch 57 → 58 taken 20 times.
20 emit statusMessage(tr("Appointment deleted"));
159
1/1
✓ Branch 59 → 60 taken 20 times.
20 requestFlush();
160 20 return true;
161 20 }
162
163 45 bool CalendarOutbox::restoreSeries(const QString &uid, const QString &accountId,
164 const QString &calendarPath, const QList<CalendarEvent> &rows) {
165 45 CalendarStore *store = m_deps.store;
166
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 6 taken 44 times.
45 if (rows.isEmpty())
167
1/1
✓ Branch 4 → 5 taken 1 time.
1 return removeSeries(uid, accountId, calendarPath);
168
1/1
✓ Branch 6 → 7 taken 44 times.
44 const auto existing = store->outboxEntry(accountId, calendarPath, uid);
169 // Rows from outside the store (an answered invitation, an imported file)
170 // replace the stored series: they keep its server identity (S93.5.2).
171
1/1
✓ Branch 8 → 9 taken 44 times.
44 const auto previous = store->occurrenceEvent(uid, accountId, calendarPath, {});
172 44 QList<CalendarEvent> stored = rows;
173
4/4
✓ Branch 11 → 12 taken 44 times.
✓ Branch 12 → 13 taken 44 times.
✓ Branch 28 → 14 taken 46 times.
✓ Branch 28 → 29 taken 18 times.
64 for (CalendarEvent &row : stored) {
174
2/2
✓ Branch 16 → 17 taken 26 times.
✓ Branch 16 → 18 taken 20 times.
46 if (!previous) break;
175
1/2
✓ Branch 19 → 20 taken 20 times.
✗ Branch 19 → 22 not taken.
20 if (row.etag.isEmpty()) row.etag = previous->etag;
176
1/2
✓ Branch 23 → 24 taken 20 times.
✗ Branch 23 → 26 not taken.
20 if (row.resourceHref.isEmpty()) row.resourceHref = previous->resourceHref;
177 }
178
1/1
✓ Branch 29 → 30 taken 44 times.
44 store->beginTransaction();
179
1/1
✓ Branch 31 → 32 taken 44 times.
44 store->deleteEvent(uid, accountId, calendarPath);
180 44 bool ok = true;
181
4/4
✓ Branch 33 → 34 taken 44 times.
✓ Branch 34 → 35 taken 44 times.
✓ Branch 44 → 36 taken 48 times.
✓ Branch 44 → 45 taken 44 times.
92 for (const CalendarEvent &row : stored)
182
3/5
✓ Branch 37 → 38 taken 48 times.
✓ Branch 38 → 39 taken 48 times.
✗ Branch 38 → 41 not taken.
✓ Branch 39 → 40 taken 48 times.
✗ Branch 39 → 41 not taken.
48 ok = store->upsertEvent(row) && ok;
183
1/1
✓ Branch 45 → 46 taken 44 times.
44 store->commitTransaction();
184
1/2
✗ Branch 46 → 47 not taken.
✓ Branch 46 → 48 taken 44 times.
44 if (!ok)
185 ✗ return false;
186 // The resource still exists on the server unless a confirmed delete
187 // removed its stored copy: then the restore is a create.
188
1/1
✓ Branch 48 → 49 taken 44 times.
44 const bool onServer = !store->resourceFor(accountId, calendarPath, uid).isEmpty();
189
1/1
✓ Branch 51 → 52 taken 44 times.
44 const CalendarEvent master = masterOf(stored);
190
3/3
✓ Branch 60 → 61 taken 14 times.
✓ Branch 60 → 63 taken 30 times.
✓ Branch 67 → 68 taken 44 times.
102 store->enqueueOutbox(accountId, calendarPath, uid, QStringLiteral("put"),
191 58 existing ? existing->href : master.resourceHref,
192
4/4
✓ Branch 53 → 54 taken 14 times.
✓ Branch 53 → 56 taken 30 times.
✓ Branch 56 → 57 taken 20 times.
✓ Branch 56 → 58 taken 10 times.
88 existing ? existing->baseEtag : (onServer ? master.etag : QString()));
193
6/6
✓ Branch 72 → 73 taken 30 times.
✓ Branch 72 → 75 taken 14 times.
✓ Branch 73 → 74 taken 10 times.
✓ Branch 73 → 75 taken 20 times.
✓ Branch 76 → 77 taken 10 times.
✓ Branch 76 → 92 taken 34 times.
44 if (!existing && !onServer) {
194
1/1
✓ Branch 77 → 78 taken 10 times.
10 auto entry = store->outboxEntry(accountId, calendarPath, uid);
195
3/6
✓ Branch 79 → 80 taken 10 times.
✗ Branch 79 → 84 not taken.
✗ Branch 82 → 83 not taken.
✓ Branch 82 → 84 taken 10 times.
✗ Branch 85 → 86 not taken.
✓ Branch 85 → 90 taken 10 times.
10 if (entry && !entry->baseEtag.isEmpty()) {
196 ✗ entry->baseEtag.clear();
197 ✗ store->updateOutbox(*entry);
198 }
199 10 }
200
1/1
✓ Branch 92 → 93 taken 44 times.
44 publishChange();
201
1/1
✓ Branch 93 → 94 taken 44 times.
44 requestFlush();
202 44 return true;
203 44 }
204
205 // ─── Pushing ───────────────────────────────────────────────
206
207 407 void CalendarOutbox::requestFlush() {
208
2/2
✓ Branch 2 → 3 taken 158 times.
✓ Branch 2 → 4 taken 249 times.
407 if (m_flushRequested) return;
209 249 m_flushRequested = true;
210
1/1
✓ Branch 4 → 5 taken 249 times.
249 QTimer::singleShot(0, this, [this] {
211 198 m_flushRequested = false;
212 198 flush(false);
213 198 });
214 }
215
216 365 void CalendarOutbox::flush(bool ignoreBackoff) {
217
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 365 times.
365 if (m_batchDepth > 0) return;
218
5/6
✓ Branch 5 → 6 taken 365 times.
✗ Branch 5 → 9 not taken.
✓ Branch 7 → 8 taken 80 times.
✓ Branch 7 → 9 taken 285 times.
✓ Branch 10 → 11 taken 80 times.
✓ Branch 10 → 12 taken 285 times.
365 if (m_deps.networkAllowed && !m_deps.networkAllowed())
219 80 return;
220 // S106-159: the credential lookup below may run a nested event loop; a
221 // flush started from inside it waits for this one (it used to push the
222 // same entry a second time — a duplicate PUT and a spurious 412).
223
2/2
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 284 times.
285 if (m_flushing) {
224 1 m_flushAgain = true;
225 1 m_flushAgainIgnoringBackoff |= ignoreBackoff;
226 1 return;
227 }
228 284 m_flushing = true;
229 284 flushOnce(ignoreBackoff);
230
2/2
✓ Branch 18 → 16 taken 1 time.
✓ Branch 18 → 19 taken 284 times.
285 while (m_flushAgain) {
231 1 const bool again = std::exchange(m_flushAgainIgnoringBackoff, false);
232 1 m_flushAgain = false;
233 1 flushOnce(again);
234 }
235 284 m_flushing = false;
236 284 scheduleRetry();
237 }
238
239 285 void CalendarOutbox::flushOnce(bool ignoreBackoff) {
240 285 const qint64 now = m_deps.now();
241
5/5
✓ Branch 3 → 4 taken 285 times.
✓ Branch 4 → 5 taken 285 times.
✓ Branch 5 → 6 taken 285 times.
✓ Branch 74 → 7 taken 165 times.
✓ Branch 74 → 75 taken 285 times.
450 for (const CalendarOutboxEntry &listed : m_deps.store->outboxEntries()) {
242
1/1
✓ Branch 8 → 9 taken 165 times.
165 const QString entryKey = key(listed.accountId, listed.calendarPath, listed.uid);
243
6/6
✓ Branch 11 → 12 taken 163 times.
✓ Branch 11 → 14 taken 2 times.
✓ Branch 13 → 14 taken 10 times.
✓ Branch 13 → 15 taken 153 times.
✓ Branch 16 → 17 taken 12 times.
✓ Branch 16 → 18 taken 153 times.
165 if (listed.state != QLatin1String("pending") || m_inFlight.contains(entryKey))
244 12 continue;
245 153 const CalendarOutboxEntry &entry = listed;
246
4/4
✓ Branch 18 → 19 taken 130 times.
✓ Branch 18 → 21 taken 23 times.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 129 times.
153 if (!ignoreBackoff && entry.nextAttempt > now)
247 1 continue;
248
2/2
✓ Branch 22 → 23 taken 15 times.
✓ Branch 22 → 39 taken 137 times.
152 if (!entry.dependencyKey.isEmpty()) {
249 15 bool waiting = false;
250
5/5
✓ Branch 23 → 24 taken 15 times.
✓ Branch 24 → 25 taken 15 times.
✓ Branch 25 → 26 taken 15 times.
✓ Branch 35 → 27 taken 17 times.
✓ Branch 35 → 36 taken 7 times.
24 for (const auto &dependency : m_deps.store->outboxEntries())
251
3/3
✓ Branch 28 → 29 taken 17 times.
✓ Branch 31 → 32 taken 8 times.
✓ Branch 31 → 33 taken 9 times.
24 if (key(dependency.accountId, dependency.calendarPath, dependency.uid) == entry.dependencyKey) { waiting = true; break; }
252
2/2
✓ Branch 37 → 38 taken 8 times.
✓ Branch 37 → 39 taken 7 times.
15 if (waiting) continue;
253 }
254 // In flight before the (possibly blocking) credential read (S106-159).
255
1/1
✓ Branch 39 → 40 taken 144 times.
144 m_inFlight.insert(entryKey);
256
1/1
✓ Branch 40 → 41 taken 144 times.
144 const auto endpoint = m_deps.endpoint(entry.accountId);
257
1/1
✓ Branch 41 → 42 taken 144 times.
144 const auto fresh = m_deps.store->outboxEntry(entry.accountId, entry.calendarPath, entry.uid);
258
6/8
✓ Branch 43 → 44 taken 142 times.
✓ Branch 43 → 50 taken 2 times.
✓ Branch 45 → 46 taken 142 times.
✗ Branch 45 → 50 not taken.
✗ Branch 49 → 50 not taken.
✓ Branch 49 → 51 taken 142 times.
✓ Branch 52 → 53 taken 2 times.
✓ Branch 52 → 55 taken 142 times.
144 if (!endpoint || !fresh || fresh->state != QLatin1String("pending")) {
259
1/1
✓ Branch 53 → 54 taken 2 times.
2 m_inFlight.remove(entryKey); // stays queued until the account is reachable
260 2 continue;
261 }
262
1/1
✓ Branch 57 → 58 taken 142 times.
142 push(*fresh, *endpoint);
263 454 }
264 285 }
265
266 297 void CalendarOutbox::scheduleRetry() {
267 297 const qint64 now = m_deps.now();
268 297 qint64 next = 0;
269
5/5
✓ Branch 3 → 4 taken 297 times.
✓ Branch 4 → 5 taken 297 times.
✓ Branch 5 → 6 taken 297 times.
✓ Branch 20 → 7 taken 178 times.
✓ Branch 20 → 21 taken 297 times.
475 for (const CalendarOutboxEntry &entry : m_deps.store->outboxEntries())
270
7/8
✓ Branch 10 → 11 taken 176 times.
✓ Branch 10 → 15 taken 2 times.
✓ Branch 11 → 12 taken 22 times.
✓ Branch 11 → 15 taken 154 times.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 22 times.
✓ Branch 16 → 17 taken 22 times.
✓ Branch 16 → 18 taken 156 times.
178 if (entry.state == QLatin1String("pending") && entry.nextAttempt > now &&
271 ✗ (next == 0 || entry.nextAttempt < next))
272 319 next = entry.nextAttempt;
273
2/2
✓ Branch 22 → 23 taken 275 times.
✓ Branch 22 → 25 taken 22 times.
297 if (next == 0) {
274 275 m_retryTimer.stop();
275 275 return;
276 }
277
1/1
✓ Branch 26 → 27 taken 22 times.
22 m_retryTimer.start(int(qBound(qint64(1), next - now, qint64(1800)) * 1000));
278 }
279
280 231 CalDavClient *CalendarOutbox::clientFor(const Endpoint &endpoint) {
281
1/1
✓ Branch 3 → 4 taken 231 times.
231 CalDavClient *client = m_deps.makeClient(endpoint.serverUrl, endpoint.username,
282
1/1
✓ Branch 2 → 3 taken 231 times.
462 QString::fromUtf8(endpoint.password));
283
1/1
✓ Branch 5 → 6 taken 231 times.
231 connect(client, &CalDavClient::allRequestsFinished, client, &QObject::deleteLater);
284 231 return client;
285 }
286
287 184 void CalendarOutbox::finishInFlight(const CalendarOutboxEntry &entry) {
288
2/2
✓ Branch 2 → 3 taken 184 times.
✓ Branch 3 → 4 taken 184 times.
184 m_inFlight.remove(key(entry.accountId, entry.calendarPath, entry.uid));
289 184 }
290
291 142 void CalendarOutbox::push(const CalendarOutboxEntry &entry, const Endpoint &endpoint) {
292 142 CalendarStore *store = m_deps.store;
293
4/6
✓ Branch 4 → 5 taken 17 times.
✓ Branch 4 → 8 taken 125 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 17 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 13 taken 142 times.
142 if (entry.operation == QLatin1String("delete") && entry.baseEtag.isEmpty()) {
294 ✗ store->removeOutbox(entry.accountId, entry.calendarPath, entry.uid, entry.revision);
295 ✗ finishInFlight(entry);
296 17 return;
297 }
298 142 QList<CalendarEvent> rows;
299
2/2
✓ Branch 15 → 16 taken 125 times.
✓ Branch 15 → 39 taken 17 times.
142 if (entry.operation == QLatin1String("put")) {
300
2/2
✓ Branch 18 → 19 taken 43 times.
✓ Branch 18 → 30 taken 82 times.
125 if (entry.componentType == QLatin1String("VTODO")) {
301
2/3
✓ Branch 19 → 20 taken 43 times.
✓ Branch 21 → 22 taken 43 times.
✗ Branch 21 → 28 not taken.
43 if (const auto task = store->taskFor(entry.uid, entry.accountId, entry.calendarPath)) {
302
1/1
✓ Branch 25 → 26 taken 43 times.
43 CalendarEvent marker; marker.uid = task->uid; rows << marker;
303 86 }
304
1/1
✓ Branch 30 → 31 taken 82 times.
82 } else rows = store->eventSeries(entry.uid, entry.accountId, entry.calendarPath);
305
1/2
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 39 taken 125 times.
125 if (rows.isEmpty()) {
306 ✗ store->removeOutbox(entry.accountId, entry.calendarPath, entry.uid, entry.revision);
307 ✗ finishInFlight(entry);
308 ✗ return;
309 }
310 }
311
1/1
✓ Branch 39 → 40 taken 142 times.
142 CalDavClient *client = clientFor(endpoint);
312
2/3
✓ Branch 41 → 42 taken 142 times.
✗ Branch 41 → 46 not taken.
✓ Branch 42 → 43 taken 142 times.
142 if (m_deps.pushDontNotify) client->setPushDontNotify(m_deps.pushDontNotify(entry.accountId, entry.calendarPath));
313
2/2
✓ Branch 46 → 47 taken 142 times.
✓ Branch 47 → 48 taken 142 times.
142 m_inFlight.insert(key(entry.accountId, entry.calendarPath, entry.uid));
314
2/2
✓ Branch 51 → 52 taken 17 times.
✓ Branch 51 → 60 taken 125 times.
142 if (entry.operation == QLatin1String("delete")) {
315
1/1
✓ Branch 52 → 53 taken 17 times.
17 const QString url = client->eventResourceUrl(entry.href, entry.calendarPath, entry.uid);
316
1/1
✓ Branch 53 → 54 taken 17 times.
17 const quint64 request = client->deleteResource(url, entry.baseEtag);
317
1/3
✓ Branch 56 → 57 taken 17 times.
✗ Branch 143 → 144 not taken.
✗ Branch 143 → 145 not taken.
17 onResponse(client, request, this, [this, entry, endpoint](const DavResponse &response) {
318 17 onDeleteFinished(entry, response, endpoint);
319 17 });
320 17 return;
321 17 }
322
1/1
✓ Branch 60 → 61 taken 125 times.
125 const CalendarEvent master = masterOf(rows);
323 125 const QByteArray body = entry.componentType == QLatin1String("VTODO")
324
1/3
✓ Branch 64 → 65 taken 43 times.
✗ Branch 154 → 155 not taken.
✗ Branch 154 → 156 not taken.
86 ? ICalTodoCodec::buildResource(store->taskFor(entry.uid, entry.accountId, entry.calendarPath).value())
325
10/12
✓ Branch 63 → 64 taken 43 times.
✓ Branch 63 → 67 taken 82 times.
✓ Branch 65 → 66 taken 43 times.
✓ Branch 66 → 69 taken 43 times.
✓ Branch 67 → 68 taken 82 times.
✓ Branch 68 → 69 taken 82 times.
✓ Branch 69 → 70 taken 82 times.
✓ Branch 69 → 71 taken 43 times.
✓ Branch 71 → 72 taken 43 times.
✓ Branch 71 → 73 taken 82 times.
✗ Branch 150 → 151 not taken.
✗ Branch 150 → 152 not taken.
168 : CalDavClient::seriesToICalendar(master, exceptionsOf(rows, master));
326
1/4
✗ Branch 74 → 75 not taken.
✓ Branch 74 → 83 taken 125 times.
✗ Branch 75 → 76 not taken.
✗ Branch 79 → 80 not taken.
125 if (body.isEmpty()) { finishInFlight(entry); retryLater(entry, QStringLiteral("Unreadable stored resource"), true); return; }
327 125 const QString url = client->eventResourceUrl(entry.href.isEmpty() ? master.resourceHref : entry.href,
328
3/3
✓ Branch 84 → 85 taken 41 times.
✓ Branch 84 → 86 taken 84 times.
✓ Branch 87 → 88 taken 125 times.
125 entry.calendarPath, entry.uid);
329
1/1
✓ Branch 89 → 90 taken 125 times.
125 const quint64 request = client->putResource(url, body, entry.baseEtag, entry.baseEtag.isEmpty());
330
8/8
✓ Branch 90 → 91 taken 125 times.
✓ Branch 94 → 95 taken 125 times.
✓ Branch 95 → 96 taken 125 times.
✓ Branch 96 → 97 taken 125 times.
✓ Branch 97 → 98 taken 125 times.
✓ Branch 98 → 99 taken 125 times.
✓ Branch 107 → 92 taken 125 times.
✓ Branch 107 → 108 taken 125 times.
250 qCInfo(lcCalendarOutbox) << "PUT" << entry.uid << "revision" << entry.revision
331
3/3
✓ Branch 100 → 101 taken 23 times.
✓ Branch 100 → 102 taken 102 times.
✓ Branch 103 → 104 taken 125 times.
125 << (entry.baseEtag.isEmpty() ? "create" : "update");
332
1/7
✓ Branch 111 → 112 taken 125 times.
✗ Branch 172 → 173 not taken.
✗ Branch 172 → 174 not taken.
✗ Branch 175 → 176 not taken.
✗ Branch 175 → 177 not taken.
✗ Branch 178 → 179 not taken.
✗ Branch 178 → 180 not taken.
125 onResponse(client, request, this, [this, entry, body, endpoint, client](const DavResponse &response) {
333
2/2
✓ Branch 2 → 3 taken 49 times.
✓ Branch 2 → 5 taken 72 times.
121 if (response.status == 412) {
334 49 fetchServerVersion(entry, response.url, body, endpoint);
335 49 return;
336 }
337 Q_UNUSED(client);
338 72 onPutFinished(entry, body, response, endpoint);
339 });
340 142 }
341
342 72 void CalendarOutbox::onPutFinished(const CalendarOutboxEntry &entry, const QByteArray &body,
343 const DavResponse &response, const Endpoint &endpoint) {
344 72 CalendarStore *store = m_deps.store;
345
5/6
✓ Branch 2 → 3 taken 72 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 53 times.
✓ Branch 3 → 5 taken 19 times.
✓ Branch 4 → 5 taken 33 times.
✓ Branch 4 → 75 taken 20 times.
72 if (response.status == 200 || response.status == 201 || response.status == 204) {
346
1/2
✓ Branch 6 → 7 taken 52 times.
✗ Branch 6 → 8 not taken.
52 const QString href = response.location.isEmpty() ? response.url : response.location;
347 // The pushed body becomes the base of every row of the resource.
348
5/5
✓ Branch 10 → 11 taken 52 times.
✓ Branch 11 → 12 taken 52 times.
✓ Branch 12 → 13 taken 52 times.
✓ Branch 23 → 14 taken 29 times.
✓ Branch 23 → 24 taken 52 times.
81 for (CalendarEvent row : store->eventSeries(entry.uid, entry.accountId, entry.calendarPath)) {
349 29 row.etag = response.etag;
350 29 row.resourceHref = href;
351
1/1
✓ Branch 18 → 19 taken 29 times.
29 ICalEventCodec::adoptPushedResource(&row, body);
352
1/1
✓ Branch 19 → 20 taken 29 times.
29 store->upsertEvent(row);
353 81 }
354
2/2
✓ Branch 27 → 28 taken 23 times.
✓ Branch 27 → 41 taken 29 times.
52 if (entry.componentType == QLatin1String("VTODO")) {
355
3/3
✓ Branch 28 → 29 taken 23 times.
✓ Branch 30 → 31 taken 22 times.
✓ Branch 30 → 39 taken 1 time.
23 if (auto task = store->taskFor(entry.uid, entry.accountId, entry.calendarPath)) {
356 22 task->etag = response.etag; task->resourceHref = href;
357
2/2
✓ Branch 36 → 37 taken 22 times.
✓ Branch 38 → 39 taken 22 times.
22 ICalTodoCodec::adoptPushedResource(&*task, body); store->upsertTask(*task);
358 23 }
359 }
360
1/1
✓ Branch 42 → 43 taken 52 times.
52 store->upsertResource(entry.accountId, entry.calendarPath, entry.uid, href, response.etag,
361
1/1
✓ Branch 41 → 42 taken 52 times.
104 QString::fromUtf8(body));
362
3/3
✓ Branch 44 → 45 taken 52 times.
✓ Branch 45 → 46 taken 2 times.
✓ Branch 45 → 57 taken 50 times.
52 if (!store->removeOutbox(entry.accountId, entry.calendarPath, entry.uid, entry.revision)) {
363 // A newer local change arrived during the push: rebase it.
364
2/3
✓ Branch 46 → 47 taken 2 times.
✓ Branch 48 → 49 taken 2 times.
✗ Branch 48 → 55 not taken.
2 if (auto current = store->outboxEntry(entry.accountId, entry.calendarPath, entry.uid)) {
365 2 current->baseEtag = response.etag;
366 2 current->href = href;
367
1/1
✓ Branch 54 → 55 taken 2 times.
2 store->updateOutbox(*current);
368 2 }
369 }
370
8/8
✓ Branch 57 → 58 taken 52 times.
✓ Branch 61 → 62 taken 52 times.
✓ Branch 62 → 63 taken 52 times.
✓ Branch 63 → 64 taken 52 times.
✓ Branch 64 → 65 taken 52 times.
✓ Branch 65 → 66 taken 52 times.
✓ Branch 69 → 59 taken 52 times.
✓ Branch 69 → 70 taken 52 times.
104 qCInfo(lcCalendarOutbox) << "Pushed" << entry.uid << "etag" << response.etag;
371
1/1
✓ Branch 70 → 71 taken 52 times.
52 finishInFlight(entry);
372
1/1
✓ Branch 71 → 72 taken 52 times.
52 publishChange();
373
1/1
✓ Branch 72 → 73 taken 52 times.
52 flush(false);
374 52 return;
375 52 }
376
5/6
✓ Branch 75 → 76 taken 8 times.
✓ Branch 75 → 79 taken 12 times.
✓ Branch 77 → 78 taken 8 times.
✗ Branch 77 → 79 not taken.
✓ Branch 80 → 81 taken 8 times.
✓ Branch 80 → 83 taken 12 times.
20 if (response.status == 404 && !entry.baseEtag.isEmpty()) {
377 8 confirmDeletedOnServer(entry, response.url, endpoint);
378 8 return;
379 }
380
2/2
✓ Branch 84 → 85 taken 12 times.
✓ Branch 85 → 86 taken 12 times.
12 retryLater(entry, responseError(response), !retryableStatus(response.status));
381 }
382
383 17 void CalendarOutbox::onDeleteFinished(const CalendarOutboxEntry &entry, const DavResponse &response,
384 const Endpoint &endpoint) {
385 17 CalendarStore *store = m_deps.store;
386
5/6
✓ Branch 2 → 3 taken 15 times.
✓ Branch 2 → 5 taken 2 times.
✓ Branch 3 → 4 taken 7 times.
✓ Branch 3 → 5 taken 8 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 33 taken 7 times.
17 if (response.status == 200 || response.status == 204 || response.status == 404) {
387
2/2
✓ Branch 6 → 7 taken 9 times.
✓ Branch 6 → 8 taken 1 time.
10 if (store->removeOutbox(entry.accountId, entry.calendarPath, entry.uid, entry.revision))
388 9 store->removeResource(entry.accountId, entry.calendarPath, entry.uid);
389
2/3
✓ Branch 8 → 9 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✗ Branch 10 → 15 not taken.
1 else if (auto current = store->outboxEntry(entry.accountId, entry.calendarPath, entry.uid)) {
390
1/1
✓ Branch 14 → 15 taken 1 time.
1 current->baseEtag.clear(); store->updateOutbox(*current);
391 1 }
392
7/7
✓ Branch 17 → 18 taken 10 times.
✓ Branch 21 → 22 taken 10 times.
✓ Branch 22 → 23 taken 10 times.
✓ Branch 23 → 24 taken 10 times.
✓ Branch 24 → 25 taken 10 times.
✓ Branch 28 → 19 taken 10 times.
✓ Branch 28 → 29 taken 10 times.
20 qCInfo(lcCalendarOutbox) << "Deleted" << entry.uid << "on the server";
393 10 finishInFlight(entry);
394 10 publishChange();
395 10 flush(false);
396 10 return;
397 }
398
2/2
✓ Branch 33 → 34 taken 6 times.
✓ Branch 33 → 38 taken 1 time.
7 if (response.status == 412) {
399
1/1
✓ Branch 35 → 36 taken 6 times.
6 fetchServerVersion(entry, response.url, QByteArray(), endpoint);
400 6 return;
401 }
402
2/2
✓ Branch 39 → 40 taken 1 time.
✓ Branch 40 → 41 taken 1 time.
1 retryLater(entry, responseError(response), !retryableStatus(response.status));
403 }
404
405 55 void CalendarOutbox::fetchServerVersion(const CalendarOutboxEntry &entry, const QString &url,
406 const QByteArray &ours, const Endpoint &endpoint) {
407
7/7
✓ Branch 2 → 3 taken 55 times.
✓ Branch 6 → 7 taken 55 times.
✓ Branch 7 → 8 taken 55 times.
✓ Branch 8 → 9 taken 55 times.
✓ Branch 9 → 10 taken 55 times.
✓ Branch 13 → 4 taken 55 times.
✓ Branch 13 → 14 taken 55 times.
110 qCInfo(lcCalendarOutbox) << "Precondition failed for" << entry.uid << "- fetching the server version";
408 55 CalDavClient *client = clientFor(endpoint);
409 55 const quint64 request = client->getResource(url);
410
1/3
✓ Branch 18 → 19 taken 55 times.
✗ Branch 28 → 29 not taken.
✗ Branch 28 → 30 not taken.
55 onResponse(client, request, this, [this, entry, ours](const DavResponse &response) {
411 55 onServerVersion(entry, ours, response);
412 55 });
413 55 }
414
415 57 void CalendarOutbox::onServerVersion(const CalendarOutboxEntry &entry, const QByteArray &ours,
416 const DavResponse &response) {
417
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 18 taken 57 times.
57 if (response.status == 404) {
418 ✗ markConflict(entry, QString(), QString(), {QStringLiteral("deleted")});
419 6 return;
420 }
421
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 24 taken 57 times.
57 if (response.status != 200) {
422 ✗ retryLater(entry, responseError(response), !retryableStatus(response.status));
423 ✗ return;
424 }
425
1/1
✓ Branch 24 → 25 taken 57 times.
57 const QString theirs = QString::fromUtf8(response.body);
426
2/2
✓ Branch 27 → 28 taken 6 times.
✓ Branch 27 → 43 taken 51 times.
57 if (entry.operation == QLatin1String("delete")) {
427 // D-22 (Sprint 107): the source of a task move changed on the server —
428 // its changes travel into the moved task instead of a question.
429
3/3
✓ Branch 28 → 29 taken 6 times.
✓ Branch 29 → 30 taken 4 times.
✓ Branch 29 → 32 taken 2 times.
6 if (isMoveSource(entry)) {
430
1/1
✓ Branch 30 → 31 taken 4 times.
4 mergeMovedSource(entry, theirs, response.etag);
431 4 return;
432 }
433
3/5
✓ Branch 36 → 37 taken 2 times.
✓ Branch 39 → 40 taken 2 times.
✓ Branch 39 → 41 taken 2 times.
✗ Branch 79 → 80 not taken.
✗ Branch 79 → 81 not taken.
4 markConflict(entry, theirs, response.etag, {QStringLiteral("changed")});
434 2 return;
435 }
436
1/1
✓ Branch 43 → 44 taken 51 times.
51 mergeServerVersion(entry, ours, theirs, response.etag);
437 61 }
438
439 51 void CalendarOutbox::mergeServerVersion(const CalendarOutboxEntry &entry, const QByteArray &ours,
440 const QString &theirs, const QString &etag) {
441 51 CalendarStore *store = m_deps.store;
442
1/1
✓ Branch 2 → 3 taken 51 times.
51 const QString base = store->resourceFor(entry.accountId, entry.calendarPath, entry.uid);
443
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 16 taken 51 times.
51 if (base.isEmpty()) {
444 ✗ markConflict(entry, theirs, etag, {QStringLiteral("created")});
445 ✗ return;
446 }
447 // "Ours" is what the store holds now — a local edit made while the GET
448 // was in flight is part of it (S106-158: the VEVENT path merged the body
449 // captured at push time and the newer edit was lost on adoption).
450
1/1
✓ Branch 16 → 17 taken 51 times.
51 QByteArray currentOurs = localBody(entry);
451
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 51 times.
51 if (currentOurs.isEmpty())
452 ✗ currentOurs = ours;
453
2/2
✓ Branch 20 → 21 taken 51 times.
✓ Branch 21 → 22 taken 51 times.
51 const ICalMerge::Result merged = ICalMerge::merge(base, QString::fromUtf8(currentOurs), theirs);
454
2/2
✓ Branch 23 → 24 taken 44 times.
✓ Branch 23 → 40 taken 7 times.
51 if (!merged.ok) {
455
1/1
✓ Branch 31 → 32 taken 44 times.
44 markConflict(entry, theirs, etag,
456
3/14
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 30 taken 44 times.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 37 taken 44 times.
✗ Branch 35 → 36 not taken.
✗ Branch 35 → 37 not taken.
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 39 taken 44 times.
✗ Branch 115 → 116 not taken.
✗ Branch 115 → 119 not taken.
✗ Branch 117 → 118 not taken.
✗ Branch 117 → 119 not taken.
✗ Branch 121 → 122 not taken.
✗ Branch 121 → 123 not taken.
88 merged.conflicts.isEmpty() ? QStringList{QStringLiteral("unreadable")} : merged.conflicts);
457 44 return;
458 }
459 // The merge becomes the local state; the next push sends it unchanged.
460 7 CalendarOutboxEntry rebased = entry;
461
2/3
✓ Branch 41 → 42 taken 7 times.
✗ Branch 42 → 43 not taken.
✓ Branch 42 → 44 taken 7 times.
7 if (!adoptServerVersion(rebased, merged.merged, etag))
462 ✗ return;
463
1/1
✓ Branch 47 → 48 taken 7 times.
7 store->enqueueOutbox(entry.accountId, entry.calendarPath, entry.uid, QStringLiteral("put"),
464 7 entry.href, etag);
465
2/3
✓ Branch 50 → 51 taken 7 times.
✓ Branch 52 → 53 taken 7 times.
✗ Branch 52 → 59 not taken.
7 if (auto current = store->outboxEntry(entry.accountId, entry.calendarPath, entry.uid)) {
466 7 current->baseEtag = etag;
467 7 current->componentType = entry.componentType;
468
1/1
✓ Branch 58 → 59 taken 7 times.
7 store->updateOutbox(*current);
469 7 }
470
6/6
✓ Branch 60 → 61 taken 7 times.
✓ Branch 64 → 65 taken 7 times.
✓ Branch 65 → 66 taken 7 times.
✓ Branch 66 → 67 taken 7 times.
✓ Branch 70 → 62 taken 7 times.
✓ Branch 70 → 71 taken 7 times.
14 qCInfo(lcCalendarOutbox) << "Merged server change into" << entry.uid;
471
2/2
✓ Branch 71 → 72 taken 7 times.
✓ Branch 72 → 73 taken 7 times.
7 emit statusMessage(tr("Your change was combined with a change made on the server."));
472
1/1
✓ Branch 74 → 75 taken 7 times.
7 flush(true);
473 139 }
474
475 // The resource this computer would push now ("" when no row is stored).
476 201 QByteArray CalendarOutbox::localBody(const CalendarOutboxEntry &entry) const {
477 201 CalendarStore *store = m_deps.store;
478
2/2
✓ Branch 4 → 5 taken 69 times.
✓ Branch 4 → 14 taken 132 times.
201 if (entry.componentType == QLatin1String("VTODO")) {
479
1/1
✓ Branch 5 → 6 taken 69 times.
69 const auto task = store->taskFor(entry.uid, entry.accountId, entry.calendarPath);
480
3/3
✓ Branch 7 → 8 taken 67 times.
✓ Branch 7 → 10 taken 2 times.
✓ Branch 9 → 11 taken 67 times.
69 return task ? ICalTodoCodec::buildResource(*task) : QByteArray();
481 69 }
482
1/1
✓ Branch 14 → 15 taken 132 times.
132 const QList<CalendarEvent> rows = store->eventSeries(entry.uid, entry.accountId, entry.calendarPath);
483
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 132 times.
132 if (rows.isEmpty())
484 ✗ return {};
485
1/1
✓ Branch 18 → 19 taken 132 times.
132 const CalendarEvent master = masterOf(rows);
486
2/2
✓ Branch 19 → 20 taken 132 times.
✓ Branch 20 → 21 taken 132 times.
132 return CalDavClient::seriesToICalendar(master, exceptionsOf(rows, master));
487 132 }
488
489 // ─── Outcomes ──────────────────────────────────────────────
490
491 15 void CalendarOutbox::retryLater(const CalendarOutboxEntry &entry, const QString &error, bool permanent) {
492 15 CalendarStore *store = m_deps.store;
493
1/1
✓ Branch 2 → 3 taken 15 times.
15 finishInFlight(entry);
494
1/1
✓ Branch 3 → 4 taken 15 times.
15 auto current = store->outboxEntry(entry.accountId, entry.calendarPath, entry.uid);
495
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 15 times.
15 if (!current)
496 ✗ return;
497 15 current->attempts += 1;
498 // F-19 (SPRINT-104): a change the server rejects for good (403 on a
499 // read-only calendar, 413, 507) gets BOUNDED retries with a visible
500 // terminal state — not one every 30 minutes forever and no way to
501 // discard it. Transient failures keep the ordinary backoff.
502
6/6
✓ Branch 8 → 9 taken 8 times.
✓ Branch 8 → 12 taken 7 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 12 taken 6 times.
✓ Branch 13 → 14 taken 2 times.
✓ Branch 13 → 47 taken 13 times.
15 if (permanent && current->attempts >= kMaxPermanentAttempts) {
503 2 current->state = QStringLiteral("failed");
504 2 current->nextAttempt = 0;
505 2 current->lastError = error;
506
1/1
✓ Branch 25 → 26 taken 2 times.
2 store->updateOutbox(*current);
507
6/6
✓ Branch 26 → 27 taken 2 times.
✓ Branch 30 → 31 taken 2 times.
✓ Branch 31 → 32 taken 2 times.
✓ Branch 32 → 33 taken 2 times.
✓ Branch 41 → 28 taken 2 times.
✓ Branch 41 → 42 taken 2 times.
4 qCWarning(lcCalendarOutbox) << "Push of" << entry.uid
508
4/4
✓ Branch 33 → 34 taken 2 times.
✓ Branch 35 → 36 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
✓ Branch 37 → 38 taken 2 times.
2 << "gave up after" << current->attempts << "attempts:" << error;
509
1/1
✓ Branch 42 → 43 taken 2 times.
2 publishChange();
510
2/2
✓ Branch 43 → 44 taken 2 times.
✓ Branch 44 → 45 taken 2 times.
2 emit statusMessage(tr("The server keeps refusing this change. It stays in this calendar as not yet on the server."));
511 2 return;
512 }
513
4/4
✓ Branch 47 → 48 taken 13 times.
✓ Branch 48 → 49 taken 6 times.
✓ Branch 48 → 50 taken 7 times.
✓ Branch 51 → 52 taken 7 times.
13 current->nextAttempt = m_deps.now() + (permanent ? 1800 : backoffSeconds(current->attempts));
514 13 current->lastError = error;
515
1/1
✓ Branch 57 → 58 taken 13 times.
13 store->updateOutbox(*current);
516
8/8
✓ Branch 58 → 59 taken 13 times.
✓ Branch 62 → 63 taken 13 times.
✓ Branch 63 → 64 taken 13 times.
✓ Branch 64 → 65 taken 13 times.
✓ Branch 65 → 66 taken 13 times.
✓ Branch 67 → 68 taken 13 times.
✓ Branch 73 → 60 taken 13 times.
✓ Branch 73 → 74 taken 13 times.
26 qCWarning(lcCalendarOutbox) << "Push of" << entry.uid << "failed (attempt" << current->attempts
517
2/2
✓ Branch 68 → 69 taken 13 times.
✓ Branch 69 → 70 taken 13 times.
13 << "):" << error;
518
1/1
✓ Branch 74 → 75 taken 13 times.
13 publishChange();
519
5/5
✓ Branch 75 → 76 taken 6 times.
✓ Branch 75 → 77 taken 7 times.
✓ Branch 76 → 78 taken 6 times.
✓ Branch 77 → 78 taken 7 times.
✓ Branch 78 → 79 taken 13 times.
13 emit statusMessage(permanent ? tr("The server did not accept the change. MailJD will try again later.")
520 : tr("Not yet on the server. MailJD retries automatically."));
521
1/1
✓ Branch 80 → 81 taken 13 times.
13 scheduleRetry();
522 15 }
523
524 52 void CalendarOutbox::markConflict(const CalendarOutboxEntry &entry, const QString &serverIcal,
525 const QString &serverEtag, const QStringList &fields) {
526 52 CalendarStore *store = m_deps.store;
527
1/1
✓ Branch 2 → 3 taken 52 times.
52 finishInFlight(entry);
528
1/1
✓ Branch 3 → 4 taken 52 times.
52 auto current = store->outboxEntry(entry.accountId, entry.calendarPath, entry.uid);
529
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 52 times.
52 if (!current)
530 ✗ return;
531 52 current->state = QStringLiteral("conflict");
532 52 current->serverIcal = serverIcal;
533 52 current->serverEtag = serverEtag;
534 52 current->conflictFields = fields;
535
1/1
✓ Branch 21 → 22 taken 52 times.
52 store->updateOutbox(*current);
536
7/7
✓ Branch 22 → 23 taken 52 times.
✓ Branch 26 → 27 taken 52 times.
✓ Branch 27 → 28 taken 52 times.
✓ Branch 28 → 29 taken 52 times.
✓ Branch 30 → 31 taken 52 times.
✓ Branch 36 → 24 taken 52 times.
✓ Branch 36 → 37 taken 52 times.
104 qCWarning(lcCalendarOutbox) << "Conflict on" << entry.uid << fields;
537
1/1
✓ Branch 37 → 38 taken 52 times.
52 publishChange();
538
2/2
✓ Branch 38 → 39 taken 52 times.
✓ Branch 39 → 40 taken 52 times.
52 emit statusMessage(tr("Someone changed this appointment on the server. Open it to choose which version to keep."));
539 52 }
540
541 29 bool CalendarOutbox::adoptServerVersion(const CalendarOutboxEntry &entry, const QString &ical,
542 const QString &etag) {
543 29 CalendarStore *store = m_deps.store;
544
2/2
✓ Branch 4 → 5 taken 13 times.
✓ Branch 4 → 64 taken 16 times.
29 if (entry.componentType == QLatin1String("VTODO")) {
545 13 ICalTodoCodec::Resource resource;
546
5/7
✓ Branch 6 → 7 taken 11 times.
✓ Branch 6 → 10 taken 2 times.
✓ Branch 7 → 8 taken 11 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 11 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 13 times.
13 if (!ical.isEmpty() && !ICalTodoCodec::parseResource(ical, &resource)) return false;
547
2/2
✓ Branch 13 → 14 taken 13 times.
✓ Branch 14 → 15 taken 13 times.
13 store->beginTransaction(); store->deleteTask(entry.uid, entry.accountId, entry.calendarPath);
548
4/4
✓ Branch 15 → 16 taken 13 times.
✓ Branch 16 → 17 taken 13 times.
✓ Branch 52 → 18 taken 11 times.
✓ Branch 52 → 53 taken 13 times.
24 for (auto task : resource.tasks) {
549
10/25
✓ Branch 21 → 22 taken 11 times.
✗ Branch 21 → 29 not taken.
✓ Branch 25 → 26 taken 11 times.
✓ Branch 26 → 27 taken 11 times.
✓ Branch 27 → 28 taken 11 times.
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 30 taken 11 times.
✓ Branch 31 → 32 taken 11 times.
✗ Branch 31 → 33 not taken.
✓ Branch 33 → 34 taken 11 times.
✗ Branch 33 → 35 not taken.
✓ Branch 35 → 36 taken 11 times.
✗ Branch 35 → 37 not taken.
✓ Branch 37 → 38 taken 11 times.
✗ Branch 37 → 39 not taken.
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 11 times.
✗ Branch 121 → 122 not taken.
✗ Branch 121 → 123 not taken.
✗ Branch 125 → 126 not taken.
✗ Branch 125 → 127 not taken.
✗ Branch 129 → 130 not taken.
✗ Branch 129 → 131 not taken.
✗ Branch 133 → 134 not taken.
✗ Branch 133 → 135 not taken.
22 if (task.uid != entry.uid || ICal::parseFirst(task.icalRaw, QStringLiteral("VTODO")).hasProperty("RECURRENCE-ID")) continue;
550 11 task.accountId = entry.accountId; task.calendarPath = entry.calendarPath;
551
1/1
✓ Branch 45 → 46 taken 11 times.
11 task.etag = etag; task.resourceHref = entry.href; store->upsertTask(task);
552 11 }
553
3/3
✓ Branch 54 → 55 taken 2 times.
✓ Branch 54 → 56 taken 11 times.
✓ Branch 55 → 57 taken 2 times.
13 if (ical.isEmpty()) store->removeResource(entry.accountId, entry.calendarPath, entry.uid);
554
1/1
✓ Branch 56 → 57 taken 11 times.
11 else store->upsertResource(entry.accountId, entry.calendarPath, entry.uid, entry.href, etag, ical);
555
2/2
✓ Branch 57 → 58 taken 13 times.
✓ Branch 58 → 59 taken 13 times.
13 store->removeOutbox(entry.accountId, entry.calendarPath, entry.uid); store->commitTransaction();
556
2/2
✓ Branch 59 → 60 taken 13 times.
✓ Branch 60 → 61 taken 13 times.
13 finishInFlight(entry); publishChange(); return true;
557 13 }
558 16 ICalEventCodec::Resource resource;
559
5/7
✓ Branch 65 → 66 taken 15 times.
✓ Branch 65 → 69 taken 1 time.
✓ Branch 66 → 67 taken 15 times.
✗ Branch 67 → 68 not taken.
✓ Branch 67 → 69 taken 15 times.
✗ Branch 70 → 71 not taken.
✓ Branch 70 → 84 taken 16 times.
16 if (!ical.isEmpty() && !ICalEventCodec::parseResource(ical, &resource)) {
560 ✗ qCWarning(lcCalendarOutbox) << "Server version of" << entry.uid << "cannot be read";
561 ✗ return false;
562 }
563 16 const QString href = entry.href;
564
1/1
✓ Branch 85 → 86 taken 16 times.
16 store->beginTransaction();
565
1/1
✓ Branch 87 → 88 taken 16 times.
16 store->deleteEvent(entry.uid, entry.accountId, entry.calendarPath);
566
4/4
✓ Branch 89 → 90 taken 16 times.
✓ Branch 90 → 91 taken 16 times.
✓ Branch 108 → 92 taken 15 times.
✓ Branch 108 → 109 taken 16 times.
31 for (CalendarEvent event : resource.events) {
567
1/2
✗ Branch 95 → 96 not taken.
✓ Branch 95 → 97 taken 15 times.
15 if (event.uid != entry.uid)
568 ✗ continue;
569 15 event.accountId = entry.accountId;
570 15 event.calendarPath = entry.calendarPath;
571 15 event.etag = etag;
572 15 event.resourceHref = href;
573
1/1
✓ Branch 101 → 102 taken 15 times.
15 store->upsertEvent(event);
574 15 }
575
1/1
✓ Branch 109 → 110 taken 16 times.
16 store->commitTransaction();
576
2/2
✓ Branch 111 → 112 taken 1 time.
✓ Branch 111 → 113 taken 15 times.
16 if (ical.isEmpty())
577
1/1
✓ Branch 112 → 114 taken 1 time.
1 store->removeResource(entry.accountId, entry.calendarPath, entry.uid);
578 else
579
1/1
✓ Branch 113 → 114 taken 15 times.
15 store->upsertResource(entry.accountId, entry.calendarPath, entry.uid, href, etag, ical);
580
1/1
✓ Branch 114 → 115 taken 16 times.
16 store->removeOutbox(entry.accountId, entry.calendarPath, entry.uid);
581
1/1
✓ Branch 115 → 116 taken 16 times.
16 finishInFlight(entry);
582
1/1
✓ Branch 116 → 117 taken 16 times.
16 publishChange();
583 16 return true;
584 16 }
585