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: 86.9% 1028 / 0 / 1183
Functions: 93.3% 70 / 0 / 75
Branches: 79.0% 1589 / 5 / 2017

service/CalDavClient.cpp
Line Branch Exec Source
1 #include "data/ICalTodoCodec.h"
2 #include "CalDavClient.h"
3 #include "CalDavXml.h"
4
5 #include "performance/PimPerformanceTracker.h"
6
7 #include <QDomDocument>
8 #include <utility>
9 #include <QLoggingCategory>
10 #include <QNetworkAccessManager>
11 #include <QNetworkReply>
12 #include <QNetworkRequest>
13 #include <QRegularExpression>
14 #include <QTimeZone>
15 #include <QTimer>
16
17 #include "DavNetworkLimits.h"
18 #include "data/ICalEventCodec.h"
19 #include "DavXmlHelper.h"
20 #include "DavPushXml.h"
21 #include "util/MessageId.h"
22
23 Q_DECLARE_LOGGING_CATEGORY(lcCalDav) // defined in CalDavXml.cpp
24
25 using namespace DavXmlHelper;
26
27 using CalDavXml::MultistatusSummary;
28 using CalDavXml::summarizeMultistatus;
29
30 453 CalDavClient::CalDavClient(const QString &serverUrl, const QString &username,
31 453 const QString &password, QObject *parent)
32 453 : QObject(parent), m_serverUrl(DavNetworkLimits::requestServerUrl(serverUrl)), m_username(username),
33
1/1
✓ Branch 3 → 4 taken 453 times.
906 m_password(password) {
34
3/3
✓ Branch 15 → 16 taken 453 times.
✓ Branch 16 → 17 taken 38 times.
✓ Branch 16 → 18 taken 415 times.
453 if (m_serverUrl.endsWith('/'))
35
1/1
✓ Branch 17 → 18 taken 38 times.
38 m_serverUrl.chop(1);
36
3/6
✓ Branch 18 → 19 taken 453 times.
✓ Branch 19 → 20 taken 453 times.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 453 times.
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 36 not taken.
453 m_nam = new QNetworkAccessManager(this);
37
1/1
✓ Branch 22 → 23 taken 453 times.
453 m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
38
39 // T-79.F1/H8: inactivity watchdog. Started at construction so an idle
40 // client (caller never issued a request) still releases itself; rearmed
41 // on every request and every finished reply.
42
3/6
✓ Branch 23 → 24 taken 453 times.
✓ Branch 24 → 25 taken 453 times.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 453 times.
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 39 not taken.
453 m_inactivityTimer = new QTimer(this);
43
1/1
✓ Branch 27 → 28 taken 453 times.
453 m_inactivityTimer->setSingleShot(true);
44
1/1
✓ Branch 28 → 29 taken 453 times.
453 m_inactivityTimer->setInterval(kInactivityTimeoutMs);
45 453 connect(m_inactivityTimer, &QTimer::timeout, this,
46
1/1
✓ Branch 29 → 30 taken 453 times.
453 &CalDavClient::onInactivityTimeout);
47
1/1
✓ Branch 31 → 32 taken 453 times.
453 m_inactivityTimer->start();
48 453 }
49
50 // T-79.F1/H8: register a dispatched reply for lifetime tracking. The
51 // decrement runs as a QUEUED handler so it always executes after the
52 // reply's direct business handler — result signals (e.g.
53 // calendarsDiscovered) synchronously start follow-up requests, which must
54 // be counted before the zero check runs.
55 676 void CalDavClient::trackReply(QNetworkReply *reply) {
56 676 ++m_pendingReplies;
57
1/1
✓ Branch 2 → 3 taken 676 times.
676 m_trackedReplies.insert(reply);
58 676 m_inactivityTimer->start();
59 676 connect(
60 reply, &QNetworkReply::finished, this,
61
1/1
✓ Branch 4 → 5 taken 676 times.
676 [this, reply]() {
62 619 m_trackedReplies.remove(reply);
63 619 m_inactivityTimer->start();
64 619 emit requestAnswered(); // F-54: account runs restart their budget
65
2/2
✓ Branch 5 → 6 taken 383 times.
✓ Branch 5 → 8 taken 236 times.
619 if (--m_pendingReplies == 0) {
66 383 m_inactivityTimer->stop();
67 383 emit allRequestsFinished();
68 }
69 619 },
70 Qt::QueuedConnection);
71 676 }
72
73 ✗ void CalDavClient::onInactivityTimeout() {
74 ✗ if (m_pendingReplies <= 0) {
75 // Idle client that never got (or has finished) its work — release it.
76 ✗ emit allRequestsFinished();
77 ✗ return;
78 }
79 ✗ qCWarning(lcCalDav) << "CalDAV inactivity timeout with" << m_pendingReplies
80 ✗ << "outstanding replies — aborting";
81 ✗ m_pendingReplies = 0;
82 ✗ const auto replies = m_trackedReplies;
83 ✗ m_trackedReplies.clear();
84 ✗ for (QNetworkReply *reply : replies) {
85 ✗ reply->disconnect(this); // no late business/tracking callbacks
86 ✗ reply->abort();
87 ✗ reply->deleteLater();
88 }
89 // Both signals so sync callers surface the error and write callers can
90 // roll back their optimistic local change.
91 ✗ m_lastNetworkError = QNetworkReply::TimeoutError;
92 ✗ emit syncFailed(QStringLiteral("CalDAV request timed out"));
93 ✗ emit writeFailed(QStringLiteral("CalDAV request timed out"));
94 ✗ emit allRequestsFinished();
95 ✗ }
96
97 431 void CalDavClient::setNetworkAccessManager(QNetworkAccessManager *nam) {
98
5/6
✓ Branch 2 → 3 taken 430 times.
✓ Branch 2 → 6 taken 1 time.
✓ Branch 4 → 5 taken 430 times.
✗ Branch 4 → 6 not taken.
✓ Branch 7 → 8 taken 430 times.
✓ Branch 7 → 10 taken 1 time.
431 if (m_nam && m_nam->parent() == this)
99
1/2
✓ Branch 8 → 9 taken 430 times.
✗ Branch 8 → 10 not taken.
430 delete m_nam;
100 431 m_nam = nam;
101
2/2
✓ Branch 10 → 11 taken 430 times.
✓ Branch 10 → 13 taken 1 time.
431 if (m_nam) {
102 430 m_nam->setParent(this);
103 430 m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
104 }
105 431 }
106
107 678 QByteArray CalDavClient::authHeader() const {
108 // No credentials → no auth header (e.g. testing with auth-free server)
109
2/2
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 5 taken 670 times.
678 if (m_username.isEmpty())
110 8 return {};
111 // T-504: Refuse to send credentials over unencrypted connections
112
1/1
✓ Branch 5 → 6 taken 670 times.
670 QUrl serverUrl(m_serverUrl);
113
4/4
✓ Branch 7 → 8 taken 670 times.
✓ Branch 8 → 9 taken 670 times.
✓ Branch 12 → 13 taken 52 times.
✓ Branch 12 → 24 taken 618 times.
670 if (serverUrl.scheme().toLower() == QLatin1String("http")) {
114
5/5
✓ Branch 13 → 14 taken 52 times.
✓ Branch 17 → 18 taken 52 times.
✓ Branch 18 → 19 taken 52 times.
✓ Branch 22 → 15 taken 52 times.
✓ Branch 22 → 23 taken 52 times.
104 qCWarning(lcCalDav) << "Refusing Basic Auth over HTTP — use HTTPS";
115 52 return {};
116 }
117 return "Basic " +
118 ✗ QByteArray(
119
3/3
✓ Branch 27 → 28 taken 618 times.
✓ Branch 28 → 29 taken 618 times.
✓ Branch 29 → 30 taken 618 times.
1854 (m_username + QStringLiteral(":") + m_password).toUtf8())
120
2/2
✓ Branch 31 → 32 taken 618 times.
✓ Branch 32 → 33 taken 618 times.
1236 .toBase64();
121 670 }
122
123 #ifdef MAILJD_E2E_TESTING
124 480 void CalDavClient::reportE2eTransportPhase(const QString &operation) {
125
2/2
✓ Branch 6 → 7 taken 480 times.
✓ Branch 7 → 8 taken 480 times.
960 emit e2eTransportPhase(QStringLiteral("request-dispatch:%1").arg(operation),
126 480 ++m_e2eRequestToken);
127 480 }
128 #endif
129
130 152 bool CalDavClient::isValidEtag(const QString &etag) {
131 152 return DavEntityTag::isValid(etag);
132 }
133
134 2474 QString CalDavClient::resolveDavUrl(const QString &href) const {
135 2474 const QString resolved = DavNetworkLimits::resolveSameOrigin(m_serverUrl, href);
136
8/8
✓ Branch 4 → 5 taken 24 times.
✓ Branch 4 → 17 taken 2450 times.
✓ Branch 5 → 6 taken 24 times.
✓ Branch 9 → 10 taken 24 times.
✓ Branch 10 → 11 taken 24 times.
✓ Branch 11 → 12 taken 24 times.
✓ Branch 15 → 7 taken 24 times.
✓ Branch 15 → 16 taken 24 times.
2498 if (resolved.isEmpty()) qCWarning(lcCalDav) << "Rejected cross-origin DAV URL:" << href;
137 2474 return resolved;
138 ✗ }
139
140 // ═══════════════════════════════════════════════════════
141 // Calendar Discovery (PROPFIND)
142 // ═══════════════════════════════════════════════════════
143
144 122 void CalDavClient::discoverCalendars() {
145 122 m_pushPropertiesRejected = false;
146 122 m_discoveryError.clear();
147 122 m_lastNetworkError = QNetworkReply::NoError;
148 122 m_discoveryRedirects = 0;
149 122 requestDiscovery(m_serverUrl, DiscoveryStep::ConfiguredCollection);
150 122 }
151
152 155 void CalDavClient::requestDiscovery(const QString &url, DiscoveryStep step) {
153
1/1
✓ Branch 2 → 3 taken 155 times.
155 const QString resolved = resolveDavUrl(url);
154
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 12 taken 155 times.
155 if (resolved.isEmpty()) {
155 ✗ m_discoveryStep = step;
156 ✗ advanceDiscovery(QStringLiteral("Cross-origin discovery URL rejected"));
157 ✗ return;
158 }
159 155 m_discoveryStep = step;
160
161
2/2
✓ Branch 12 → 13 taken 155 times.
✓ Branch 13 → 14 taken 155 times.
155 QNetworkRequest request{QUrl(resolved)};
162
3/3
✓ Branch 15 → 16 taken 155 times.
✓ Branch 16 → 17 taken 155 times.
✓ Branch 17 → 18 taken 155 times.
155 request.setRawHeader("Authorization", authHeader());
163
5/5
✓ Branch 20 → 21 taken 32 times.
✓ Branch 20 → 23 taken 123 times.
✓ Branch 25 → 26 taken 155 times.
✓ Branch 26 → 27 taken 155 times.
✓ Branch 27 → 28 taken 155 times.
187 request.setRawHeader("Depth",
164
2/2
✓ Branch 21 → 22 taken 29 times.
✓ Branch 21 → 23 taken 3 times.
32 step == DiscoveryStep::ConfiguredCollection ||
165
2/2
✓ Branch 22 → 23 taken 11 times.
✓ Branch 22 → 24 taken 18 times.
29 step == DiscoveryStep::CalendarHome ||
166 step == DiscoveryStep::NextcloudFallback
167 ? "1"
168 : "0");
169
3/3
✓ Branch 30 → 31 taken 155 times.
✓ Branch 31 → 32 taken 155 times.
✓ Branch 32 → 33 taken 155 times.
155 request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
170 // Discovery follows redirects itself: Qt turns a followed 301/302 into a
171 // GET, and /.well-known/caldav (RFC 6764) answers with a redirect.
172
1/1
✓ Branch 36 → 37 taken 155 times.
155 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy);
173
174 155 QByteArray body;
175
2/2
✓ Branch 38 → 39 taken 15 times.
✓ Branch 38 → 40 taken 140 times.
155 if (step == DiscoveryStep::WellKnown) {
176 body = "<d:propfind xmlns:d=\"DAV:\"><d:prop>"
177
1/1
✓ Branch 39 → 69 taken 15 times.
15 "<d:current-user-principal/></d:prop></d:propfind>";
178
2/2
✓ Branch 40 → 41 taken 3 times.
✓ Branch 40 → 42 taken 137 times.
140 } else if (step == DiscoveryStep::Principal) {
179 body = "<d:propfind xmlns:d=\"DAV:\" "
180 "xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:prop>"
181
1/1
✓ Branch 41 → 69 taken 3 times.
3 "<c:calendar-home-set/><c:schedule-outbox-URL/><c:calendar-user-address-set/></d:prop></d:propfind>";
182 } else {
183 // Sprint 96 S96.1.2: the WebDAV-Push properties travel with the normal
184 // collection discovery (no extra request), unless the server rejected
185 // them once in this discovery.
186 137 const bool push = !m_pushPropertiesRejected;
187 body = "<d:propfind xmlns:d=\"DAV:\" "
188 "xmlns:c=\"urn:ietf:params:xml:ns:caldav\" xmlns:cs=\"http://calendarserver.org/ns/\" "
189 137 "xmlns:apple=\"http://apple.com/ns/ical/\"" +
190
13/25
✓ Branch 45 → 46 taken 136 times.
✓ Branch 45 → 50 taken 1 time.
✓ Branch 46 → 47 taken 136 times.
✓ Branch 47 → 48 taken 136 times.
✓ Branch 48 → 49 taken 136 times.
✓ Branch 49 → 51 taken 136 times.
✓ Branch 51 → 52 taken 137 times.
✓ Branch 61 → 62 taken 136 times.
✓ Branch 61 → 63 taken 1 time.
✓ Branch 63 → 64 taken 136 times.
✓ Branch 63 → 65 taken 1 time.
✓ Branch 65 → 66 taken 136 times.
✓ Branch 65 → 67 taken 1 time.
✗ Branch 125 → 126 not taken.
✗ Branch 125 → 127 not taken.
✗ Branch 129 → 130 not taken.
✗ Branch 129 → 131 not taken.
✗ Branch 131 → 132 not taken.
✗ Branch 131 → 133 not taken.
✗ Branch 133 → 134 not taken.
✗ Branch 133 → 135 not taken.
✗ Branch 135 → 136 not taken.
✗ Branch 135 → 137 not taken.
✗ Branch 139 → 140 not taken.
✗ Branch 139 → 141 not taken.
411 (push ? QByteArray(" xmlns:P=\"") + DavPushXml::kNamespace.toLatin1() + "\"" : QByteArray()) +
191 "><d:prop><d:displayname/><d:resourcetype/><cs:getctag/>"
192
1/1
✓ Branch 52 → 53 taken 137 times.
274 "<apple:calendar-color/><c:supported-calendar-component-set/><d:current-user-privilege-set/>" +
193
5/5
✓ Branch 42 → 43 taken 136 times.
✓ Branch 42 → 44 taken 1 time.
✓ Branch 43 → 45 taken 136 times.
✓ Branch 53 → 54 taken 137 times.
✓ Branch 54 → 55 taken 137 times.
411 (push ? DavPushXml::discoveryProperties() : QByteArray()) + "</d:prop></d:propfind>";
194 }
195
196 #ifdef MAILJD_E2E_TESTING
197
1/1
✓ Branch 72 → 73 taken 99 times.
99 reportE2eTransportPhase(QStringLiteral("PROPFIND_DISCOVERY"));
198 #endif
199
4/4
✓ Branch 69 → 70 taken 56 times.
✓ Branch 70 → 71 taken 56 times.
✓ Branch 75 → 76 taken 99 times.
✓ Branch 76 → 77 taken 99 times.
155 auto *reply = m_nam->sendCustomRequest(request, "PROPFIND", body);
200
4/4
✓ Branch 72 → 73 taken 56 times.
✓ Branch 73 → 74 taken 56 times.
✓ Branch 78 → 79 taken 99 times.
✓ Branch 79 → 80 taken 99 times.
155 MailJD::Performance::PimPerformanceTracker::instance().davWaitStarted();
201
2/2
✓ Branch 74 → 75 taken 56 times.
✓ Branch 80 → 81 taken 99 times.
155 DavNetworkLimits::apply(reply);
202
2/2
✓ Branch 75 → 76 taken 56 times.
✓ Branch 81 → 82 taken 99 times.
155 trackReply(reply); // T-79.F1: lifetime follows request completion
203
2/2
✓ Branch 76 → 77 taken 56 times.
✓ Branch 82 → 83 taken 99 times.
155 connect(reply, &QNetworkReply::finished, this, [this, reply]() {
204 152 onDiscoverReply(reply);
205 152 });
206 155 }
207
208 35 void CalDavClient::advanceDiscovery(const QString &reason) {
209
1/2
✓ Branch 3 → 4 taken 35 times.
✗ Branch 3 → 5 not taken.
35 if (!reason.isEmpty())
210 35 m_discoveryError = reason;
211
212
1/1
✓ Branch 5 → 6 taken 35 times.
35 QUrl origin(m_serverUrl);
213
1/1
✓ Branch 9 → 10 taken 35 times.
35 origin.setPath(QStringLiteral("/"));
214
1/1
✓ Branch 13 → 14 taken 35 times.
35 origin.setQuery(QString());
215
1/1
✓ Branch 16 → 17 taken 35 times.
35 origin.setFragment(QString());
216
217
2/2
✓ Branch 18 → 19 taken 14 times.
✓ Branch 18 → 33 taken 21 times.
35 if (m_discoveryStep == DiscoveryStep::ConfiguredCollection) {
218
3/3
✓ Branch 22 → 23 taken 14 times.
✓ Branch 23 → 24 taken 14 times.
✓ Branch 26 → 27 taken 14 times.
42 requestDiscovery(origin.resolved(QUrl(QStringLiteral("/.well-known/caldav")))
219
1/1
✓ Branch 25 → 26 taken 14 times.
28 .toString(),
220 DiscoveryStep::WellKnown);
221 14 return;
222 }
223
224
2/2
✓ Branch 33 → 34 taken 11 times.
✓ Branch 33 → 59 taken 10 times.
21 if (m_discoveryStep != DiscoveryStep::NextcloudFallback) {
225 const QString fallback =
226
1/1
✓ Branch 44 → 45 taken 11 times.
44 origin.resolved(QUrl(QStringLiteral("/remote.php/dav/calendars/%1/")
227
2/2
✓ Branch 41 → 42 taken 11 times.
✓ Branch 42 → 43 taken 11 times.
22 .arg(QString::fromUtf8(
228
2/2
✓ Branch 40 → 41 taken 11 times.
✓ Branch 43 → 44 taken 11 times.
33 QUrl::toPercentEncoding(m_username)))))
229
1/1
✓ Branch 46 → 47 taken 11 times.
11 .toString();
230
1/1
✓ Branch 56 → 57 taken 11 times.
11 requestDiscovery(fallback, DiscoveryStep::NextcloudFallback);
231 11 return;
232 11 }
233
234
1/1
✓ Branch 65 → 66 taken 10 times.
20 emit syncFailed(m_discoveryError.isEmpty()
235
2/6
✗ Branch 60 → 61 not taken.
✓ Branch 60 → 64 taken 10 times.
✗ Branch 67 → 68 not taken.
✓ Branch 67 → 69 taken 10 times.
✗ Branch 138 → 139 not taken.
✗ Branch 138 → 140 not taken.
20 ? QStringLiteral("CalDAV discovery failed")
236 10 : m_discoveryError);
237 35 }
238
239 152 void CalDavClient::onDiscoverReply(QNetworkReply *reply) {
240
1/1
✓ Branch 2 → 3 taken 152 times.
152 reply->deleteLater();
241 auto &performanceTracker =
242
1/1
✓ Branch 3 → 4 taken 152 times.
152 MailJD::Performance::PimPerformanceTracker::instance();
243
2/2
✓ Branch 4 → 5 taken 152 times.
✓ Branch 5 → 6 taken 152 times.
152 performanceTracker.davWaitFinished(reply->bytesAvailable());
244
245
1/1
✓ Branch 6 → 7 taken 152 times.
152 const QString limitError = DavNetworkLimits::failureReason(reply);
246
2/2
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 22 taken 149 times.
152 if (!limitError.isEmpty()) {
247
6/6
✓ Branch 9 → 10 taken 3 times.
✓ Branch 13 → 14 taken 3 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 15 → 16 taken 3 times.
✓ Branch 19 → 11 taken 3 times.
✓ Branch 19 → 20 taken 3 times.
6 qCWarning(lcCalDav) << "PROPFIND failed:" << limitError;
248
1/1
✓ Branch 20 → 21 taken 3 times.
3 advanceDiscovery(limitError);
249 3 return;
250 }
251
252 // RFC 6764: the well-known URI (or a moved principal) redirects. The
253 // PROPFIND is repeated at the target, same origin only, at most 3 times.
254
2/2
✓ Branch 22 → 23 taken 149 times.
✓ Branch 23 → 24 taken 149 times.
149 const int redirectStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
255
5/8
✓ Branch 25 → 26 taken 147 times.
✓ Branch 25 → 29 taken 2 times.
✓ Branch 26 → 27 taken 147 times.
✗ Branch 26 → 29 not taken.
✓ Branch 27 → 28 taken 147 times.
✗ Branch 27 → 29 not taken.
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 69 taken 147 times.
149 if (redirectStatus == 301 || redirectStatus == 302 || redirectStatus == 307 || redirectStatus == 308) {
256
4/4
✓ Branch 33 → 34 taken 2 times.
✓ Branch 34 → 35 taken 2 times.
✓ Branch 35 → 36 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
4 const QString target = resolveDavUrl(QString::fromUtf8(reply->rawHeader("Location")).trimmed());
257
5/6
✓ Branch 41 → 42 taken 1 time.
✓ Branch 41 → 44 taken 1 time.
✓ Branch 42 → 43 taken 1 time.
✗ Branch 42 → 44 not taken.
✓ Branch 45 → 46 taken 1 time.
✓ Branch 45 → 60 taken 1 time.
2 if (!target.isEmpty() && m_discoveryRedirects < 3) {
258 1 ++m_discoveryRedirects;
259
7/7
✓ Branch 46 → 47 taken 1 time.
✓ Branch 50 → 51 taken 1 time.
✓ Branch 51 → 52 taken 1 time.
✓ Branch 52 → 53 taken 1 time.
✓ Branch 53 → 54 taken 1 time.
✓ Branch 57 → 48 taken 1 time.
✓ Branch 57 → 58 taken 1 time.
2 qCInfo(lcCalDav) << "Discovery redirected (" << redirectStatus << ")";
260
1/1
✓ Branch 58 → 59 taken 1 time.
1 requestDiscovery(target, m_discoveryStep);
261 1 return;
262 }
263
1/1
✓ Branch 63 → 64 taken 1 time.
1 advanceDiscovery(QStringLiteral("CalDAV discovery redirect refused"));
264 1 return;
265 2 }
266
267 // S96.1.2: a server that refuses the extended property list as a whole
268 // gets the same PROPFIND once more without the push properties.
269 322 const bool collectionStep = m_discoveryStep == DiscoveryStep::ConfiguredCollection ||
270
4/4
✓ Branch 69 → 70 taken 28 times.
✓ Branch 69 → 72 taken 119 times.
✓ Branch 70 → 71 taken 25 times.
✓ Branch 70 → 72 taken 3 times.
172 m_discoveryStep == DiscoveryStep::CalendarHome ||
271
2/2
✓ Branch 71 → 72 taken 10 times.
✓ Branch 71 → 73 taken 15 times.
25 m_discoveryStep == DiscoveryStep::NextcloudFallback;
272
5/8
✓ Branch 74 → 75 taken 146 times.
✓ Branch 74 → 76 taken 1 time.
✗ Branch 75 → 76 not taken.
✓ Branch 75 → 99 taken 146 times.
✓ Branch 76 → 77 taken 1 time.
✗ Branch 76 → 99 not taken.
✓ Branch 77 → 78 taken 1 time.
✗ Branch 77 → 99 not taken.
147 if ((redirectStatus == 400 || redirectStatus == 422) && collectionStep && !m_pushPropertiesRejected) {
273
7/7
✓ Branch 78 → 79 taken 1 time.
✓ Branch 82 → 83 taken 1 time.
✓ Branch 83 → 84 taken 1 time.
✓ Branch 84 → 85 taken 1 time.
✓ Branch 85 → 86 taken 1 time.
✓ Branch 89 → 80 taken 1 time.
✓ Branch 89 → 90 taken 1 time.
2 qCInfo(lcCalDav) << "PROPFIND with push properties rejected (" << redirectStatus << "), retrying without";
274 1 m_pushPropertiesRejected = true;
275
4/4
✓ Branch 90 → 91 taken 1 time.
✓ Branch 91 → 92 taken 1 time.
✓ Branch 93 → 94 taken 1 time.
✓ Branch 94 → 95 taken 1 time.
1 requestDiscovery(reply->request().url().toString(), m_discoveryStep);
276 1 return;
277 }
278
279
3/3
✓ Branch 99 → 100 taken 146 times.
✓ Branch 100 → 101 taken 16 times.
✓ Branch 100 → 138 taken 130 times.
146 if (reply->error() != QNetworkReply::NoError) {
280 // A step with a fallback after it failing is the usual way through
281 // discovery (a server root answers 405 before /.well-known/caldav, on
282 // every sync of a Nextcloud account given by its address); only the
283 // last step's failure is a warning, and syncFailed reports it.
284
2/2
✓ Branch 101 → 102 taken 4 times.
✓ Branch 101 → 116 taken 12 times.
16 if (m_discoveryStep == DiscoveryStep::NextcloudFallback)
285
7/7
✓ Branch 102 → 103 taken 4 times.
✓ Branch 106 → 107 taken 4 times.
✓ Branch 107 → 108 taken 4 times.
✓ Branch 108 → 109 taken 4 times.
✓ Branch 109 → 110 taken 4 times.
✓ Branch 114 → 104 taken 4 times.
✓ Branch 114 → 115 taken 4 times.
8 qCWarning(lcCalDav) << "PROPFIND failed:" << reply->errorString();
286 else
287
8/8
✓ Branch 116 → 117 taken 12 times.
✓ Branch 120 → 121 taken 12 times.
✓ Branch 121 → 122 taken 12 times.
✓ Branch 122 → 123 taken 12 times.
✓ Branch 123 → 124 taken 12 times.
✓ Branch 124 → 125 taken 12 times.
✓ Branch 129 → 118 taken 12 times.
✓ Branch 129 → 130 taken 12 times.
24 qCInfo(lcCalDav) << "PROPFIND answered" << redirectStatus << "in discovery step" << int(m_discoveryStep)
288
1/1
✓ Branch 125 → 126 taken 12 times.
12 << "- trying the next one";
289 // A rejected password stays the reported cause even when the
290 // discovery fallbacks fail differently afterwards.
291
2/2
✓ Branch 131 → 132 taken 10 times.
✓ Branch 131 → 134 taken 6 times.
16 if (m_lastNetworkError != QNetworkReply::AuthenticationRequiredError)
292
1/1
✓ Branch 132 → 133 taken 10 times.
10 m_lastNetworkError = reply->error();
293
2/2
✓ Branch 134 → 135 taken 16 times.
✓ Branch 135 → 136 taken 16 times.
16 advanceDiscovery(reply->errorString());
294 16 return;
295 }
296
297
1/1
✓ Branch 138 → 139 taken 130 times.
130 QByteArray data = reply->readAll();
298
7/7
✓ Branch 139 → 140 taken 130 times.
✓ Branch 143 → 144 taken 130 times.
✓ Branch 144 → 145 taken 130 times.
✓ Branch 145 → 146 taken 130 times.
✓ Branch 146 → 147 taken 130 times.
✓ Branch 151 → 141 taken 130 times.
✓ Branch 151 → 152 taken 130 times.
260 qCDebug(lcCalDav) << "PROPFIND response:" << data.left(2000);
299
1/1
✓ Branch 152 → 153 taken 130 times.
130 performanceTracker.parseStarted();
300
1/1
✓ Branch 153 → 154 taken 130 times.
130 QDomDocument doc;
301 // T-511: Check XML parse result
302
3/3
✓ Branch 154 → 155 taken 130 times.
✓ Branch 155 → 156 taken 11 times.
✓ Branch 155 → 175 taken 119 times.
130 if (!DavXmlHelper::parseInto(doc, data)) {
303
1/1
✓ Branch 157 → 158 taken 11 times.
11 performanceTracker.parseFinished(0, data.size());
304
5/5
✓ Branch 158 → 159 taken 11 times.
✓ Branch 162 → 163 taken 11 times.
✓ Branch 163 → 164 taken 11 times.
✓ Branch 167 → 160 taken 11 times.
✓ Branch 167 → 168 taken 11 times.
22 qCWarning(lcCalDav) << "Failed to parse XML response";
305
1/1
✓ Branch 171 → 172 taken 11 times.
11 advanceDiscovery(QStringLiteral("Invalid XML response"));
306 11 return;
307 }
308
309
2/2
✓ Branch 175 → 176 taken 115 times.
✓ Branch 175 → 177 taken 4 times.
119 if (m_discoveryStep == DiscoveryStep::WellKnown ||
310
2/2
✓ Branch 176 → 177 taken 3 times.
✓ Branch 176 → 245 taken 112 times.
115 m_discoveryStep == DiscoveryStep::Principal) {
311 const QString property =
312 7 m_discoveryStep == DiscoveryStep::WellKnown
313 4 ? QStringLiteral("current-user-principal")
314
6/6
✓ Branch 177 → 178 taken 4 times.
✓ Branch 177 → 181 taken 3 times.
✓ Branch 184 → 185 taken 3 times.
✓ Branch 184 → 186 taken 4 times.
✓ Branch 186 → 187 taken 4 times.
✓ Branch 186 → 188 taken 3 times.
14 : QStringLiteral("calendar-home-set");
315
2/2
✓ Branch 188 → 189 taken 7 times.
✓ Branch 189 → 190 taken 7 times.
7 const QString href = hrefOfProperty(doc.documentElement(), property);
316
3/3
✓ Branch 192 → 193 taken 1 time.
✓ Branch 192 → 194 taken 6 times.
✓ Branch 194 → 195 taken 6 times.
7 const QString resolvedHref = href.isEmpty() ? QString() : resolveDavUrl(href);
317
2/2
✓ Branch 195 → 196 taken 3 times.
✓ Branch 195 → 223 taken 4 times.
7 if (m_discoveryStep == DiscoveryStep::Principal) {
318 // Sprint 108 S108.8.4: the outbox URL (free/busy) and the addresses
319 // the server schedules for.
320
1/1
✓ Branch 196 → 197 taken 3 times.
3 const CalDavXml::Scheduling scheduling = CalDavXml::parseScheduling(doc);
321 3 m_serverScheduling = !scheduling.outboxHref.isEmpty();
322
3/3
✓ Branch 200 → 201 taken 2 times.
✓ Branch 200 → 202 taken 1 time.
✓ Branch 202 → 203 taken 1 time.
3 m_scheduleOutboxUrl = scheduling.outboxHref.isEmpty() ? QString() : resolveDavUrl(scheduling.outboxHref);
323 3 m_calendarUserAddresses = scheduling.addresses;
324
7/7
✓ Branch 206 → 207 taken 3 times.
✓ Branch 210 → 211 taken 3 times.
✓ Branch 211 → 212 taken 3 times.
✓ Branch 213 → 214 taken 3 times.
✓ Branch 214 → 215 taken 3 times.
✓ Branch 220 → 208 taken 3 times.
✓ Branch 220 → 221 taken 3 times.
6 qCInfo(lcCalDav) << "Server scheduling (RFC 6638):" << *m_serverScheduling << "addresses"
325
1/1
✓ Branch 216 → 217 taken 3 times.
3 << m_calendarUserAddresses.size();
326 3 }
327
2/2
✓ Branch 224 → 225 taken 1 time.
✓ Branch 224 → 234 taken 6 times.
7 if (resolvedHref.isEmpty()) {
328
1/1
✓ Branch 226 → 227 taken 1 time.
1 performanceTracker.parseFinished(0, data.size());
329
1/1
✓ Branch 230 → 231 taken 1 time.
1 advanceDiscovery(QStringLiteral("CalDAV discovery property missing"));
330 1 return;
331 }
332
1/1
✓ Branch 237 → 238 taken 6 times.
6 requestDiscovery(resolvedHref,
333
2/2
✓ Branch 234 → 235 taken 3 times.
✓ Branch 234 → 236 taken 3 times.
6 m_discoveryStep == DiscoveryStep::WellKnown
334 ? DiscoveryStep::Principal
335 : DiscoveryStep::CalendarHome);
336
1/1
✓ Branch 239 → 240 taken 6 times.
6 performanceTracker.parseFinished(1, data.size());
337 6 return;
338 7 }
339
340
1/1
✓ Branch 245 → 246 taken 112 times.
112 QList<CalendarInfo> calendars = CalDavXml::parseCalendars(doc);
341 // S96.1.2: push offers per href, parsed namespace-exactly.
342 112 QString pushError;
343
1/1
✓ Branch 246 → 247 taken 112 times.
112 const auto offers = DavPushXml::parseDiscovery(data, &pushError);
344
4/4
✓ Branch 247 → 248 taken 112 times.
✓ Branch 248 → 249 taken 112 times.
✓ Branch 286 → 250 taken 214 times.
✓ Branch 286 → 287 taken 112 times.
326 for (CalendarInfo &cal : calendars) {
345
2/2
✓ Branch 251 → 252 taken 3 times.
✓ Branch 251 → 259 taken 211 times.
214 if (m_pushPropertiesRejected) {
346 3 cal.push.capability = DavPushCapability::Unsupported;
347 3 cal.push.reason = QStringLiteral("server rejects the push discovery properties");
348
1/2
✗ Branch 260 → 261 not taken.
✓ Branch 260 → 262 taken 211 times.
211 } else if (!offers) {
349 ✗ cal.push.capability = DavPushCapability::ProbeError;
350 ✗ cal.push.reason = pushError;
351 } else {
352
1/1
✓ Branch 263 → 264 taken 211 times.
211 cal.push = offers->value(cal.path.trimmed());
353 }
354
8/8
✓ Branch 269 → 270 taken 214 times.
✓ Branch 273 → 274 taken 214 times.
✓ Branch 274 → 275 taken 214 times.
✓ Branch 275 → 276 taken 214 times.
✓ Branch 276 → 277 taken 214 times.
✓ Branch 277 → 278 taken 214 times.
✓ Branch 283 → 271 taken 214 times.
✓ Branch 283 → 284 taken 214 times.
428 qCDebug(lcCalDav) << "Calendar:" << cal.displayName << "at" << cal.path
355
2/2
✓ Branch 278 → 279 taken 214 times.
✓ Branch 279 → 280 taken 214 times.
214 << "color:" << cal.color;
356 }
357
358
2/2
✓ Branch 288 → 289 taken 3 times.
✓ Branch 288 → 298 taken 109 times.
112 if (calendars.isEmpty()) {
359
1/1
✓ Branch 290 → 291 taken 3 times.
3 performanceTracker.parseFinished(0, data.size());
360
1/1
✓ Branch 294 → 295 taken 3 times.
3 advanceDiscovery(QStringLiteral("No calendars found at discovery URL"));
361 3 return;
362 }
363
1/1
✓ Branch 300 → 301 taken 109 times.
109 performanceTracker.parseFinished(calendars.size(), data.size());
364
7/7
✓ Branch 301 → 302 taken 109 times.
✓ Branch 305 → 306 taken 109 times.
✓ Branch 306 → 307 taken 109 times.
✓ Branch 308 → 309 taken 109 times.
✓ Branch 309 → 310 taken 109 times.
✓ Branch 313 → 303 taken 109 times.
✓ Branch 313 → 314 taken 109 times.
218 qCInfo(lcCalDav) << "Discovered" << calendars.size() << "calendars";
365
1/1
✓ Branch 314 → 315 taken 109 times.
109 emit calendarsDiscovered(calendars);
366 203 }
367
368 // ═══════════════════════════════════════════════════════
369 // Event Sync (REPORT calendar-query VEVENT)
370 // ═══════════════════════════════════════════════════════
371
372 30 void CalDavClient::syncCalendar(const QString &calendarPath) {
373
1/1
✓ Branch 2 → 3 taken 30 times.
30 QString url = resolveDavUrl(calendarPath);
374
2/2
✓ Branch 4 → 5 taken 6 times.
✓ Branch 4 → 12 taken 24 times.
30 if (url.isEmpty()) {
375
1/1
✓ Branch 8 → 9 taken 6 times.
6 emit syncFailed(QStringLiteral("Cross-origin URL rejected"));
376 6 return;
377 }
378
379
2/2
✓ Branch 12 → 13 taken 24 times.
✓ Branch 13 → 14 taken 24 times.
24 QNetworkRequest request{QUrl(url)};
380
3/3
✓ Branch 15 → 16 taken 24 times.
✓ Branch 16 → 17 taken 24 times.
✓ Branch 17 → 18 taken 24 times.
24 request.setRawHeader("Authorization", authHeader());
381
3/3
✓ Branch 20 → 21 taken 24 times.
✓ Branch 21 → 22 taken 24 times.
✓ Branch 22 → 23 taken 24 times.
24 request.setRawHeader("Depth", "1");
382
3/3
✓ Branch 25 → 26 taken 24 times.
✓ Branch 26 → 27 taken 24 times.
✓ Branch 27 → 28 taken 24 times.
24 request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
383
384 QByteArray body =
385 "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
386 "<c:calendar-query xmlns:d=\"DAV:\" "
387 " xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
388 " <d:prop>"
389 " <d:getetag/>"
390 " <c:calendar-data/>"
391 " </d:prop>"
392 " <c:filter>"
393 " <c:comp-filter name=\"VCALENDAR\">"
394 " <c:comp-filter name=\"VEVENT\"/>"
395 " </c:comp-filter>"
396 " </c:filter>"
397
1/1
✓ Branch 30 → 31 taken 24 times.
24 "</c:calendar-query>";
398
399 #ifdef MAILJD_E2E_TESTING
400
1/1
✓ Branch 34 → 35 taken 17 times.
17 reportE2eTransportPhase(QStringLiteral("REPORT_EVENTS"));
401 #endif
402
4/4
✓ Branch 31 → 32 taken 7 times.
✓ Branch 32 → 33 taken 7 times.
✓ Branch 37 → 38 taken 17 times.
✓ Branch 38 → 39 taken 17 times.
24 auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
403
4/4
✓ Branch 34 → 35 taken 7 times.
✓ Branch 35 → 36 taken 7 times.
✓ Branch 40 → 41 taken 17 times.
✓ Branch 41 → 42 taken 17 times.
24 MailJD::Performance::PimPerformanceTracker::instance().davWaitStarted();
404
2/2
✓ Branch 36 → 37 taken 7 times.
✓ Branch 42 → 43 taken 17 times.
24 DavNetworkLimits::apply(reply);
405
2/2
✓ Branch 37 → 38 taken 7 times.
✓ Branch 43 → 44 taken 17 times.
24 trackReply(reply); // T-79.F1: lifetime follows request completion
406
2/2
✓ Branch 39 → 40 taken 7 times.
✓ Branch 45 → 46 taken 17 times.
24 connect(reply, &QNetworkReply::finished, this,
407 48 [this, reply, calendarPath]() {
408 24 onSyncEventsReply(reply, calendarPath);
409 24 });
410 30 }
411
412 24 void CalDavClient::onSyncEventsReply(QNetworkReply *reply,
413 const QString &calendarPath) {
414
1/1
✓ Branch 2 → 3 taken 24 times.
24 reply->deleteLater();
415 auto &performanceTracker =
416
1/1
✓ Branch 3 → 4 taken 24 times.
24 MailJD::Performance::PimPerformanceTracker::instance();
417
2/2
✓ Branch 4 → 5 taken 24 times.
✓ Branch 5 → 6 taken 24 times.
24 performanceTracker.davWaitFinished(reply->bytesAvailable());
418
419
1/1
✓ Branch 6 → 7 taken 24 times.
24 const QString limitError = DavNetworkLimits::failureReason(reply);
420
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 22 taken 23 times.
24 if (!limitError.isEmpty()) {
421
6/6
✓ Branch 9 → 10 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 19 → 11 taken 1 time.
✓ Branch 19 → 20 taken 1 time.
2 qCWarning(lcCalDav) << "REPORT (events) failed:" << limitError;
422
1/1
✓ Branch 20 → 21 taken 1 time.
1 emit syncFailed(limitError);
423 1 return;
424 }
425
426
3/3
✓ Branch 22 → 23 taken 23 times.
✓ Branch 23 → 24 taken 1 time.
✓ Branch 23 → 42 taken 22 times.
23 if (reply->error() != QNetworkReply::NoError) {
427
5/5
✓ Branch 24 → 25 taken 1 time.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 29 → 30 taken 1 time.
✓ Branch 36 → 26 taken 1 time.
✓ Branch 36 → 37 taken 1 time.
2 qCWarning(lcCalDav) << "REPORT (events) failed:"
428
2/2
✓ Branch 30 → 31 taken 1 time.
✓ Branch 31 → 32 taken 1 time.
1 << reply->errorString();
429
1/1
✓ Branch 37 → 38 taken 1 time.
1 m_lastNetworkError = reply->error();
430
2/2
✓ Branch 38 → 39 taken 1 time.
✓ Branch 39 → 40 taken 1 time.
1 emit syncFailed(reply->errorString());
431 1 return;
432 }
433
434
1/1
✓ Branch 42 → 43 taken 22 times.
22 QByteArray data = reply->readAll();
435
6/6
✓ Branch 43 → 44 taken 22 times.
✓ Branch 47 → 48 taken 22 times.
✓ Branch 48 → 49 taken 22 times.
✓ Branch 50 → 51 taken 22 times.
✓ Branch 55 → 45 taken 22 times.
✓ Branch 55 → 56 taken 22 times.
44 qCDebug(lcCalDav) << "REPORT (events) response:" << data.size()
436
1/1
✓ Branch 51 → 52 taken 22 times.
22 << "bytes";
437
438 22 QList<CalendarEvent> events;
439 22 QString parseError;
440
1/1
✓ Branch 56 → 57 taken 22 times.
22 performanceTracker.parseStarted();
441
3/3
✓ Branch 57 → 58 taken 22 times.
✓ Branch 58 → 59 taken 3 times.
✓ Branch 58 → 74 taken 19 times.
22 if (!parseICalEvents(data, &events, &parseError)) {
442
1/1
✓ Branch 60 → 61 taken 3 times.
3 performanceTracker.parseFinished(0, data.size());
443
6/6
✓ Branch 61 → 62 taken 3 times.
✓ Branch 65 → 66 taken 3 times.
✓ Branch 66 → 67 taken 3 times.
✓ Branch 67 → 68 taken 3 times.
✓ Branch 71 → 63 taken 3 times.
✓ Branch 71 → 72 taken 3 times.
6 qCWarning(lcCalDav) << "REPORT (events) parse failed:" << parseError;
444
1/1
✓ Branch 72 → 73 taken 3 times.
3 emit syncFailed(parseError);
445 3 return;
446 }
447
1/1
✓ Branch 76 → 77 taken 19 times.
19 performanceTracker.parseFinished(events.size(), data.size());
448
449 // Assign calendarPath to each event
450
4/4
✓ Branch 77 → 78 taken 19 times.
✓ Branch 78 → 79 taken 19 times.
✓ Branch 103 → 80 taken 19 times.
✓ Branch 103 → 104 taken 18 times.
37 for (auto &ev : events) {
451 19 ev.calendarPath = calendarPath;
452
2/2
✓ Branch 83 → 84 taken 18 times.
✓ Branch 83 → 101 taken 1 time.
19 if (!ev.resourceHref.isEmpty()) {
453
1/1
✓ Branch 84 → 85 taken 18 times.
18 const QString resolvedHref = resolveDavUrl(ev.resourceHref);
454
2/2
✓ Branch 86 → 87 taken 1 time.
✓ Branch 86 → 94 taken 17 times.
18 if (resolvedHref.isEmpty()) {
455
1/1
✓ Branch 90 → 91 taken 1 time.
1 emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
456 1 return;
457 }
458 17 ev.resourceHref = resolvedHref;
459 18 }
460 }
461
462
1/1
✓ Branch 104 → 105 taken 18 times.
18 const bool complete = queryComplete(data, calendarPath);
463
7/7
✓ Branch 105 → 106 taken 18 times.
✓ Branch 109 → 110 taken 18 times.
✓ Branch 110 → 111 taken 18 times.
✓ Branch 112 → 113 taken 18 times.
✓ Branch 113 → 114 taken 18 times.
✓ Branch 122 → 107 taken 18 times.
✓ Branch 122 → 123 taken 18 times.
36 qCInfo(lcCalDav) << "Synced" << events.size() << "events from"
464
4/4
✓ Branch 114 → 115 taken 18 times.
✓ Branch 115 → 116 taken 14 times.
✓ Branch 115 → 117 taken 4 times.
✓ Branch 118 → 119 taken 18 times.
18 << calendarPath << (complete ? "complete" : "incomplete");
465
1/1
✓ Branch 123 → 124 taken 18 times.
18 emit eventsSynced(calendarPath, events, complete);
466 36 }
467
468 // ═══════════════════════════════════════════════════════
469 // Incremental sync (REPORT sync-collection, RFC 6578) — Sprint 93 S93.6.2
470 // ═══════════════════════════════════════════════════════
471
472
473 193 void CalDavClient::syncCollection(const QString &calendarPath, const QString &syncToken) {
474
1/1
✓ Branch 2 → 3 taken 193 times.
193 const QString url = resolveDavUrl(calendarPath);
475
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 12 taken 193 times.
193 if (url.isEmpty()) {
476 ✗ emit syncFailed(QStringLiteral("Cross-origin URL rejected"));
477 ✗ return;
478 }
479
2/2
✓ Branch 12 → 13 taken 193 times.
✓ Branch 13 → 14 taken 193 times.
193 QNetworkRequest request{QUrl(url)};
480
3/3
✓ Branch 15 → 16 taken 193 times.
✓ Branch 16 → 17 taken 193 times.
✓ Branch 17 → 18 taken 193 times.
193 request.setRawHeader("Authorization", authHeader());
481
3/3
✓ Branch 20 → 21 taken 193 times.
✓ Branch 21 → 22 taken 193 times.
✓ Branch 22 → 23 taken 193 times.
193 request.setRawHeader("Depth", "0");
482
3/3
✓ Branch 25 → 26 taken 193 times.
✓ Branch 26 → 27 taken 193 times.
✓ Branch 27 → 28 taken 193 times.
193 request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
483
1/1
✓ Branch 30 → 31 taken 193 times.
193 QString escapedToken = syncToken.toHtmlEscaped();
484 const QByteArray body =
485 "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
486 "<d:sync-collection xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
487
2/2
✓ Branch 31 → 32 taken 193 times.
✓ Branch 32 → 33 taken 193 times.
386 "<d:sync-token>" + escapedToken.toUtf8() + "</d:sync-token>"
488 "<d:sync-level>1</d:sync-level>"
489 "<d:prop><d:getetag/><c:calendar-data/></d:prop>"
490
1/1
✓ Branch 33 → 34 taken 193 times.
193 "</d:sync-collection>";
491 #ifdef MAILJD_E2E_TESTING
492
1/1
✓ Branch 39 → 40 taken 175 times.
175 reportE2eTransportPhase(QStringLiteral("REPORT_SYNC_COLLECTION"));
493 #endif
494
4/4
✓ Branch 36 → 37 taken 18 times.
✓ Branch 37 → 38 taken 18 times.
✓ Branch 42 → 43 taken 175 times.
✓ Branch 43 → 44 taken 175 times.
193 auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
495
4/4
✓ Branch 39 → 40 taken 18 times.
✓ Branch 40 → 41 taken 18 times.
✓ Branch 45 → 46 taken 175 times.
✓ Branch 46 → 47 taken 175 times.
193 MailJD::Performance::PimPerformanceTracker::instance().davWaitStarted();
496
2/2
✓ Branch 41 → 42 taken 18 times.
✓ Branch 47 → 48 taken 175 times.
193 DavNetworkLimits::apply(reply);
497
2/2
✓ Branch 42 → 43 taken 18 times.
✓ Branch 48 → 49 taken 175 times.
193 trackReply(reply);
498
2/6
✓ Branch 45 → 46 taken 18 times.
✓ Branch 51 → 52 taken 175 times.
✗ Branch 96 → 97 not taken.
✗ Branch 96 → 98 not taken.
✗ Branch 108 → 109 not taken.
✗ Branch 108 → 110 not taken.
193 connect(reply, &QNetworkReply::finished, this, [this, reply, calendarPath, syncToken]() {
499 192 onSyncCollectionReply(reply, calendarPath, syncToken);
500 192 });
501 193 }
502
503 // S96.3.0: a calendar-query result is complete when the server did not
504 // truncate it (507) and every member carried its calendar-data.
505 27 bool CalDavClient::queryComplete(const QByteArray &xml, const QString &calendarPath) const {
506 27 MultistatusSummary summary;
507
6/7
✓ Branch 2 → 3 taken 27 times.
✓ Branch 3 → 4 taken 27 times.
✗ Branch 3 → 9 not taken.
✓ Branch 4 → 5 taken 25 times.
✓ Branch 4 → 9 taken 2 times.
✓ Branch 7 → 8 taken 23 times.
✓ Branch 7 → 9 taken 2 times.
52 return summarizeMultistatus(xml, &summary, true, calendarPath) && !summary.truncated &&
508
3/5
✓ Branch 5 → 6 taken 25 times.
✓ Branch 10 → 11 taken 25 times.
✓ Branch 10 → 12 taken 2 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
79 failedMembers(summary.failed, calendarPath).isEmpty();
509 27 }
510
511 // Failed members of a multistatus, resolved; the collection itself (some
512 // servers list it beside its members) is no failed member.
513 267 QStringList CalDavClient::failedMembers(const QStringList &hrefs, const QString &calendarPath) const {
514 267 QStringList failed;
515
1/1
✓ Branch 2 → 3 taken 267 times.
267 const QString collection = resolveDavUrl(calendarPath);
516
2/2
✓ Branch 33 → 5 taken 577 times.
✓ Branch 33 → 34 taken 267 times.
844 for (const QString &href : hrefs) {
517
1/1
✓ Branch 6 → 7 taken 577 times.
577 const QString resolved = resolveDavUrl(href);
518
5/11
✓ Branch 8 → 9 taken 577 times.
✗ Branch 8 → 14 not taken.
✓ Branch 11 → 12 taken 577 times.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 577 times.
✓ Branch 16 → 17 taken 577 times.
✗ Branch 16 → 18 not taken.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 577 times.
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 39 not taken.
577 if (resolved == collection || (resolved + QLatin1Char('/')) == collection) continue;
519
2/3
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 577 times.
✓ Branch 24 → 25 taken 577 times.
577 failed << (resolved.isEmpty() ? href : resolved);
520 577 }
521 267 return failed;
522 267 }
523
524 // S106-704: dropping a member the reply left out let the enumeration prune
525 // delete the local copy while the sync token committed.
526 13 QStringList CalDavClient::unansweredMembers(const QStringList &requested, const QStringList &listed,
527 const QStringList &failed, const QStringList &removed,
528 const QString &calendarPath) const {
529 13 QSet<QString> answered;
530
2/2
✓ Branch 16 → 5 taken 39 times.
✓ Branch 16 → 17 taken 13 times.
52 for (const QStringList *hrefs : {&listed, &failed, &removed})
531
6/6
✓ Branch 5 → 6 taken 39 times.
✓ Branch 6 → 7 taken 39 times.
✓ Branch 7 → 8 taken 39 times.
✓ Branch 10 → 11 taken 131 times.
✓ Branch 13 → 9 taken 131 times.
✓ Branch 13 → 14 taken 39 times.
170 for (const QString &href : failedMembers(*hrefs, calendarPath)) answered.insert(href);
532 13 QStringList unanswered;
533
5/5
✓ Branch 17 → 18 taken 13 times.
✓ Branch 18 → 19 taken 13 times.
✓ Branch 19 → 20 taken 13 times.
✓ Branch 27 → 21 taken 282 times.
✓ Branch 27 → 28 taken 13 times.
295 for (const QString &href : failedMembers(requested, calendarPath))
534
3/3
✓ Branch 23 → 24 taken 151 times.
✓ Branch 23 → 25 taken 131 times.
✓ Branch 24 → 25 taken 151 times.
295 if (!answered.contains(href)) unanswered << href;
535
2/2
✓ Branch 30 → 31 taken 3 times.
✓ Branch 30 → 46 taken 10 times.
13 if (!unanswered.isEmpty())
536
6/6
✓ Branch 31 → 32 taken 3 times.
✓ Branch 35 → 36 taken 3 times.
✓ Branch 36 → 37 taken 3 times.
✓ Branch 38 → 39 taken 3 times.
✓ Branch 44 → 33 taken 3 times.
✓ Branch 44 → 45 taken 3 times.
6 qCWarning(lcCalDav) << "calendar-multiget left" << unanswered.size()
537
2/2
✓ Branch 39 → 40 taken 3 times.
✓ Branch 40 → 41 taken 3 times.
3 << "requested member(s) unanswered in" << calendarPath;
538 13 return unanswered;
539 13 }
540
541 192 void CalDavClient::onSyncCollectionReply(QNetworkReply *reply, const QString &calendarPath,
542 const QString &requestToken) {
543 192 const bool full = requestToken.isEmpty();
544
1/1
✓ Branch 3 → 4 taken 192 times.
192 reply->deleteLater();
545
1/1
✓ Branch 4 → 5 taken 192 times.
192 auto &performanceTracker = MailJD::Performance::PimPerformanceTracker::instance();
546
2/2
✓ Branch 5 → 6 taken 192 times.
✓ Branch 6 → 7 taken 192 times.
192 performanceTracker.davWaitFinished(reply->bytesAvailable());
547
1/1
✓ Branch 7 → 8 taken 192 times.
192 const QString limitError = DavNetworkLimits::failureReason(reply);
548
2/2
✓ Branch 9 → 10 taken 7 times.
✓ Branch 9 → 44 taken 185 times.
192 if (!limitError.isEmpty()) {
549 // F-54 (SPRINT-104): a token-less full download that breaks the
550 // response bounds (10 MiB / 30 s) is retried as an ETag-only listing
551 // with batched calendar-multiget — every response stays bounded. The
552 // listing itself failing the bounds (≈85k members) keeps the failure.
553
5/6
✓ Branch 10 → 11 taken 6 times.
✓ Branch 10 → 14 taken 1 time.
✓ Branch 12 → 13 taken 6 times.
✗ Branch 12 → 14 not taken.
✓ Branch 15 → 16 taken 6 times.
✓ Branch 15 → 31 taken 1 time.
7 if (full && !m_listingFallback.contains(calendarPath)) {
554
6/6
✓ Branch 16 → 17 taken 6 times.
✓ Branch 20 → 21 taken 6 times.
✓ Branch 21 → 22 taken 6 times.
✓ Branch 22 → 23 taken 6 times.
✓ Branch 27 → 18 taken 6 times.
✓ Branch 27 → 28 taken 6 times.
12 qCInfo(lcCalDav) << "full sync-collection exceeded the bounds for" << calendarPath
555
1/1
✓ Branch 23 → 24 taken 6 times.
6 << "— retrying as an ETag listing with batched multiget";
556
1/1
✓ Branch 28 → 29 taken 6 times.
6 m_listingFallback.insert(calendarPath);
557
1/1
✓ Branch 29 → 30 taken 6 times.
6 requestEtagListing(calendarPath);
558 6 return;
559 }
560
6/6
✓ Branch 31 → 32 taken 1 time.
✓ Branch 35 → 36 taken 1 time.
✓ Branch 36 → 37 taken 1 time.
✓ Branch 37 → 38 taken 1 time.
✓ Branch 41 → 33 taken 1 time.
✓ Branch 41 → 42 taken 1 time.
2 qCWarning(lcCalDav) << "REPORT (sync-collection) failed:" << limitError;
561
1/1
✓ Branch 42 → 43 taken 1 time.
1 emit syncFailed(limitError);
562 1 return;
563 }
564
2/2
✓ Branch 44 → 45 taken 185 times.
✓ Branch 45 → 46 taken 185 times.
185 const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
565
3/3
✓ Branch 47 → 48 taken 185 times.
✓ Branch 48 → 49 taken 13 times.
✓ Branch 48 → 123 taken 172 times.
185 if (reply->error() != QNetworkReply::NoError) {
566 // RFC 6578 §3.2: an expired token is a 403/409 valid-sync-token
567 // precondition; 400/404/405/415/501 mean the report is not supported.
568 // Sprint 96 S96.3.3: a 403 alone is no proof of a missing report — only
569 // the supported-report precondition is; a privilege error (or a 403
570 // without a known precondition) fails this collection instead of
571 // switching it to calendar-query.
572
1/1
✓ Branch 49 → 50 taken 13 times.
13 const QByteArray error = reply->readAll();
573 // S106-049: 410 Gone and 412 Precondition Failed are how deployed servers
574 // invalidate a token too; they used to fail every later run with the
575 // same token.
576
11/12
✓ Branch 50 → 51 taken 11 times.
✓ Branch 50 → 59 taken 2 times.
✓ Branch 51 → 52 taken 5 times.
✓ Branch 51 → 53 taken 6 times.
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 56 taken 5 times.
✓ Branch 55 → 56 taken 2 times.
✓ Branch 55 → 58 taken 4 times.
✓ Branch 56 → 57 taken 6 times.
✓ Branch 56 → 58 taken 1 time.
✓ Branch 60 → 61 taken 6 times.
✓ Branch 60 → 74 taken 7 times.
19 if (!full && (((status == 403 || status == 409) && error.contains("valid-sync-token")) ||
577
2/2
✓ Branch 57 → 58 taken 1 time.
✓ Branch 57 → 59 taken 5 times.
6 status == 410 || status == 412)) {
578
6/6
✓ Branch 61 → 62 taken 6 times.
✓ Branch 65 → 66 taken 6 times.
✓ Branch 66 → 67 taken 6 times.
✓ Branch 67 → 68 taken 6 times.
✓ Branch 71 → 63 taken 6 times.
✓ Branch 71 → 72 taken 6 times.
12 qCInfo(lcCalDav) << "sync-token no longer valid for" << calendarPath;
579
1/1
✓ Branch 72 → 73 taken 6 times.
6 emit collectionSyncRejected(calendarPath, true);
580 6 return;
581 }
582
4/4
✓ Branch 74 → 75 taken 3 times.
✓ Branch 74 → 79 taken 4 times.
✓ Branch 77 → 78 taken 1 time.
✓ Branch 77 → 79 taken 2 times.
7 const bool unsupported403 = status == 403 && error.contains("supported-report");
583
8/12
✓ Branch 80 → 81 taken 7 times.
✗ Branch 80 → 87 not taken.
✓ Branch 81 → 82 taken 6 times.
✓ Branch 81 → 87 taken 1 time.
✓ Branch 82 → 83 taken 6 times.
✗ Branch 82 → 87 not taken.
✓ Branch 83 → 84 taken 6 times.
✗ Branch 83 → 87 not taken.
✓ Branch 84 → 85 taken 6 times.
✗ Branch 84 → 87 not taken.
✓ Branch 85 → 86 taken 4 times.
✓ Branch 85 → 87 taken 2 times.
7 if (status == 400 || unsupported403 || status == 404 || status == 405 || status == 415 ||
584
1/2
✗ Branch 86 → 87 not taken.
✓ Branch 86 → 102 taken 4 times.
4 status == 501 || status == 409) {
585
8/8
✓ Branch 87 → 88 taken 3 times.
✓ Branch 91 → 92 taken 3 times.
✓ Branch 92 → 93 taken 3 times.
✓ Branch 93 → 94 taken 3 times.
✓ Branch 94 → 95 taken 3 times.
✓ Branch 95 → 96 taken 3 times.
✓ Branch 99 → 89 taken 3 times.
✓ Branch 99 → 100 taken 3 times.
6 qCInfo(lcCalDav) << "sync-collection not supported for" << calendarPath << "status" << status;
586
1/1
✓ Branch 100 → 101 taken 3 times.
3 emit collectionSyncRejected(calendarPath, false);
587 3 return;
588 }
589
8/8
✓ Branch 102 → 103 taken 4 times.
✓ Branch 106 → 107 taken 4 times.
✓ Branch 107 → 108 taken 4 times.
✓ Branch 108 → 109 taken 4 times.
✓ Branch 109 → 110 taken 4 times.
✓ Branch 110 → 111 taken 4 times.
✓ Branch 115 → 104 taken 4 times.
✓ Branch 115 → 116 taken 4 times.
8 qCWarning(lcCalDav) << "REPORT (sync-collection) failed:" << status << reply->errorString();
590
1/1
✓ Branch 116 → 117 taken 4 times.
4 m_lastNetworkError = reply->error();
591
2/2
✓ Branch 117 → 118 taken 4 times.
✓ Branch 118 → 119 taken 4 times.
4 emit syncFailed(reply->errorString());
592 4 return;
593 13 }
594
1/1
✓ Branch 123 → 124 taken 172 times.
172 const QByteArray data = reply->readAll();
595 172 DavCollectionChanges changes;
596 172 changes.full = full;
597 172 changes.requestToken = requestToken;
598 172 QString parseError;
599 172 MultistatusSummary summary;
600
1/1
✓ Branch 125 → 126 taken 172 times.
172 performanceTracker.parseStarted();
601
1/1
✓ Branch 126 → 127 taken 172 times.
172 if (!summarizeMultistatus(data, &summary, true, calendarPath) ||
602
4/7
✓ Branch 127 → 128 taken 172 times.
✗ Branch 127 → 132 not taken.
✓ Branch 128 → 129 taken 172 times.
✓ Branch 129 → 130 taken 172 times.
✗ Branch 129 → 132 not taken.
✗ Branch 134 → 135 not taken.
✓ Branch 134 → 159 taken 172 times.
344 !parseICalEvents(data, &changes.events, &parseError) ||
603
2/3
✓ Branch 130 → 131 taken 172 times.
✗ Branch 131 → 132 not taken.
✓ Branch 131 → 133 taken 172 times.
172 !parseICalTasks(data, &changes.tasks, &parseError)) {
604 ✗ performanceTracker.parseFinished(0, data.size());
605 ✗ qCWarning(lcCalDav) << "REPORT (sync-collection) parse failed:" << parseError;
606 ✗ emit syncFailed(parseError.isEmpty() ? QStringLiteral("Invalid XML response") : parseError);
607 ✗ return;
608 }
609
1/1
✓ Branch 162 → 163 taken 172 times.
172 performanceTracker.parseFinished(changes.events.size() + changes.tasks.size(), data.size());
610 172 changes.syncToken = summary.token;
611 172 changes.removedHrefs = summary.removed;
612 172 changes.truncated = summary.truncated;
613
1/2
✗ Branch 166 → 167 not taken.
✓ Branch 166 → 169 taken 172 times.
172 if (changes.syncToken.isEmpty()) {
614 // Without a new token the result cannot be applied incrementally.
615 ✗ emit collectionSyncRejected(calendarPath, false);
616 ✗ return;
617 }
618 344 const auto resolveAll = [this](auto &items) {
619
8/8
auto CalDavClient::onSyncCollectionReply(QNetworkReply*, QString const&, QString const&)::{lambda(auto:1&)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&) const:
✓ Branch 2 → 3 taken 172 times.
✓ Branch 3 → 4 taken 172 times.
✓ Branch 17 → 5 taken 7 times.
✓ Branch 17 → 18 taken 172 times.
auto CalDavClient::onSyncCollectionReply(QNetworkReply*, QString const&, QString const&)::{lambda(auto:1&)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&) const:
✓ Branch 2 → 3 taken 172 times.
✓ Branch 3 → 4 taken 172 times.
✓ Branch 17 → 5 taken 440 times.
✓ Branch 17 → 18 taken 172 times.
1135 for (auto &item : items) {
620
2/4
auto CalDavClient::onSyncCollectionReply(QNetworkReply*, QString const&, QString const&)::{lambda(auto:1&)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&) const:
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 7 times.
auto CalDavClient::onSyncCollectionReply(QNetworkReply*, QString const&, QString const&)::{lambda(auto:1&)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&) const:
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 440 times.
447 if (item.resourceHref.isEmpty()) continue;
621
2/2
auto CalDavClient::onSyncCollectionReply(QNetworkReply*, QString const&, QString const&)::{lambda(auto:1&)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&) const:
✓ Branch 9 → 10 taken 7 times.
auto CalDavClient::onSyncCollectionReply(QNetworkReply*, QString const&, QString const&)::{lambda(auto:1&)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&) const:
✓ Branch 9 → 10 taken 440 times.
447 item.resourceHref = resolveDavUrl(item.resourceHref);
622
2/4
auto CalDavClient::onSyncCollectionReply(QNetworkReply*, QString const&, QString const&)::{lambda(auto:1&)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&) const:
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 7 times.
auto CalDavClient::onSyncCollectionReply(QNetworkReply*, QString const&, QString const&)::{lambda(auto:1&)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&) const:
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 440 times.
447 if (item.resourceHref.isEmpty()) return false;
623 }
624 344 return true;
625 172 };
626
4/4
✓ Branch 169 → 170 taken 172 times.
✓ Branch 170 → 171 taken 172 times.
✓ Branch 176 → 172 taken 440 times.
✓ Branch 176 → 177 taken 172 times.
612 for (auto &event : changes.events) event.calendarPath = calendarPath;
627
4/4
✓ Branch 177 → 178 taken 172 times.
✓ Branch 178 → 179 taken 172 times.
✓ Branch 184 → 180 taken 7 times.
✓ Branch 184 → 185 taken 172 times.
179 for (auto &task : changes.tasks) task.calendarPath = calendarPath;
628
4/6
✓ Branch 185 → 186 taken 172 times.
✓ Branch 186 → 187 taken 172 times.
✗ Branch 186 → 190 not taken.
✓ Branch 187 → 188 taken 172 times.
✓ Branch 188 → 189 taken 172 times.
✗ Branch 188 → 190 not taken.
172 bool hrefsOk = resolveAll(changes.events) && resolveAll(changes.tasks);
629
4/4
✓ Branch 191 → 192 taken 172 times.
✓ Branch 192 → 193 taken 172 times.
✓ Branch 205 → 194 taken 7 times.
✓ Branch 205 → 206 taken 172 times.
179 for (auto &href : changes.removedHrefs) {
630
1/1
✓ Branch 195 → 196 taken 7 times.
7 href = resolveDavUrl(href);
631
2/4
✓ Branch 198 → 199 taken 7 times.
✗ Branch 198 → 202 not taken.
✓ Branch 200 → 201 taken 7 times.
✗ Branch 200 → 202 not taken.
7 hrefsOk = hrefsOk && !href.isEmpty();
632 }
633
1/2
✗ Branch 206 → 207 not taken.
✓ Branch 206 → 214 taken 172 times.
172 if (!hrefsOk) {
634 ✗ emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
635 ✗ return;
636 }
637
1/1
✓ Branch 214 → 215 taken 172 times.
172 changes.failedHrefs = failedMembers(summary.failed, calendarPath);
638
9/9
✓ Branch 217 → 218 taken 172 times.
✓ Branch 221 → 222 taken 172 times.
✓ Branch 222 → 223 taken 172 times.
✓ Branch 223 → 224 taken 172 times.
✓ Branch 224 → 225 taken 110 times.
✓ Branch 224 → 226 taken 62 times.
✓ Branch 227 → 228 taken 172 times.
✓ Branch 247 → 219 taken 172 times.
✓ Branch 247 → 248 taken 172 times.
344 qCInfo(lcCalDav) << "sync-collection" << calendarPath << (full ? "full" : "delta")
639
4/4
✓ Branch 228 → 229 taken 172 times.
✓ Branch 230 → 231 taken 172 times.
✓ Branch 231 → 232 taken 172 times.
✓ Branch 233 → 234 taken 172 times.
172 << "events" << changes.events.size() << "tasks" << changes.tasks.size()
640
4/4
✓ Branch 234 → 235 taken 172 times.
✓ Branch 236 → 237 taken 172 times.
✓ Branch 237 → 238 taken 172 times.
✓ Branch 239 → 240 taken 172 times.
172 << "removed" << changes.removedHrefs.size() << "failed" << changes.failedHrefs.size()
641
3/3
✓ Branch 240 → 241 taken 5 times.
✓ Branch 240 → 242 taken 167 times.
✓ Branch 243 → 244 taken 172 times.
172 << (changes.truncated ? "truncated" : "complete");
642 // F-24 (SPRINT-104): RFC 6578 does not oblige a server to carry
643 // calendar-data in the sync-collection answer. Instead of failing the
644 // page forever, the members that came back without data are fetched
645 // with calendar-multiget (RFC 4791 §7.9) and merged; a multiget that
646 // also fails keeps the former failure path. A truncated page stays as
647 // it is (S96's paging owns it).
648 // S106-057: more than 100 data-less members are fetched in batches of 100
649 // (the page used to fail wholesale and repeat identically every run).
650
6/6
✓ Branch 248 → 249 taken 167 times.
✓ Branch 248 → 252 taken 5 times.
✓ Branch 250 → 251 taken 6 times.
✓ Branch 250 → 252 taken 161 times.
✓ Branch 253 → 254 taken 6 times.
✓ Branch 253 → 258 taken 166 times.
172 if (!changes.truncated && !changes.failedHrefs.isEmpty())
651
1/1
✓ Branch 255 → 256 taken 6 times.
6 fetchFailedMembers(calendarPath, changes);
652 else
653
1/1
✓ Branch 258 → 259 taken 166 times.
166 emit collectionSynced(calendarPath, changes);
654 192 }
655
656 6 void CalDavClient::fetchFailedMembers(const QString &calendarPath,
657 DavCollectionChanges changes) {
658 // The members to fetch move out of failedHrefs; what stays unresolved is
659 // collected again and reported with the page.
660 6 const QStringList pending = std::exchange(changes.failedHrefs, {});
661
1/1
✓ Branch 8 → 9 taken 6 times.
6 fetchFailedMemberBatch(calendarPath, std::move(changes), pending);
662 6 }
663
664 12 void CalDavClient::fetchFailedMemberBatch(const QString &calendarPath,
665 DavCollectionChanges changes,
666 QStringList pending) {
667
2/2
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 6 taken 7 times.
12 if (pending.isEmpty()) {
668
1/1
✓ Branch 4 → 5 taken 5 times.
5 emit collectionSynced(calendarPath, changes);
669 5 return;
670 }
671
1/1
✓ Branch 6 → 7 taken 7 times.
7 const QStringList batch = pending.mid(0, MaxMultigetMembers);
672
1/1
✓ Branch 7 → 8 taken 7 times.
7 pending = pending.mid(MaxMultigetMembers);
673
1/1
✓ Branch 10 → 11 taken 7 times.
7 QNetworkReply *reply = sendCalendarMultiget(calendarPath, batch);
674
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 17 taken 7 times.
7 if (!reply) {
675 ✗ changes.failedHrefs += batch + pending;
676 ✗ emit collectionSynced(calendarPath, changes);
677 ✗ return;
678 }
679 7 connect(reply, &QNetworkReply::finished, this,
680
1/7
✓ Branch 21 → 22 taken 7 times.
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 39 not taken.
✗ Branch 40 → 41 not taken.
✗ Branch 40 → 42 not taken.
✗ Branch 43 → 44 not taken.
✗ Branch 43 → 45 not taken.
14 [this, reply, calendarPath, changes, batch, pending]() mutable {
681
1/1
✓ Branch 2 → 3 taken 7 times.
7 reply->deleteLater();
682
3/3
✓ Branch 3 → 4 taken 7 times.
✓ Branch 4 → 5 taken 7 times.
✓ Branch 5 → 6 taken 7 times.
7 MailJD::Performance::PimPerformanceTracker::instance().davWaitFinished(reply->bytesAvailable());
683
6/12
✓ Branch 6 → 7 taken 7 times.
✓ Branch 8 → 9 taken 7 times.
✗ Branch 8 → 11 not taken.
✓ Branch 9 → 10 taken 7 times.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 7 times.
✓ Branch 13 → 14 taken 7 times.
✗ Branch 13 → 15 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 31 taken 7 times.
✗ Branch 116 → 117 not taken.
✗ Branch 116 → 118 not taken.
7 if (!DavNetworkLimits::failureReason(reply).isEmpty() || reply->error() != QNetworkReply::NoError) {
684 // The multiget is a fallback: keep the page's own failure path.
685 ✗ qCWarning(lcCalDav) << "calendar-multiget fallback failed";
686 ✗ changes.failedHrefs += batch + pending;
687 ✗ emit collectionSynced(calendarPath, changes);
688 1 return;
689 }
690
1/1
✓ Branch 31 → 32 taken 7 times.
7 const QByteArray data = reply->readAll();
691 7 QString parseError;
692 7 MultistatusSummary multiget;
693 // S106-046: the parsers clear their output, and changes.events/tasks
694 // already hold the page's inline calendar-data: parse into fresh lists
695 // and append (a replace dropped those members; the enumeration prune
696 // then deleted them locally).
697 7 QList<CalendarEvent> events;
698 7 QList<CalendarTask> tasks;
699
2/4
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 60 taken 6 times.
✗ Branch 128 → 129 not taken.
✗ Branch 128 → 130 not taken.
7 if (!summarizeMultistatus(data, &multiget) ||
700
6/8
✓ Branch 33 → 34 taken 7 times.
✓ Branch 34 → 35 taken 6 times.
✓ Branch 34 → 39 taken 1 time.
✓ Branch 35 → 36 taken 6 times.
✓ Branch 36 → 37 taken 6 times.
✗ Branch 36 → 39 not taken.
✓ Branch 41 → 42 taken 7 times.
✗ Branch 41 → 43 not taken.
13 !parseICalEvents(data, &events, &parseError) ||
701
2/3
✓ Branch 37 → 38 taken 6 times.
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 40 taken 6 times.
6 !parseICalTasks(data, &tasks, &parseError)) {
702
6/6
✓ Branch 44 → 45 taken 1 time.
✓ Branch 48 → 49 taken 1 time.
✓ Branch 49 → 50 taken 1 time.
✓ Branch 50 → 51 taken 1 time.
✓ Branch 54 → 46 taken 1 time.
✓ Branch 54 → 55 taken 1 time.
2 qCWarning(lcCalDav) << "calendar-multiget parse failed:" << parseError;
703
2/2
✓ Branch 55 → 56 taken 1 time.
✓ Branch 56 → 57 taken 1 time.
1 changes.failedHrefs += batch + pending;
704
1/1
✓ Branch 58 → 59 taken 1 time.
1 emit collectionSynced(calendarPath, changes);
705 1 return;
706 }
707 18 const auto resolve = [this, calendarPath](auto &items) {
708
7/8
auto CalDavClient::fetchFailedMemberBatch(QString const&, DavCollectionChanges, QList<QString>)::{lambda()#1}::operator()()::{lambda(auto:1&)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&) const:
✓ Branch 2 → 3 taken 6 times.
✓ Branch 3 → 4 taken 6 times.
✗ Branch 15 → 5 not taken.
✓ Branch 15 → 16 taken 6 times.
auto CalDavClient::fetchFailedMemberBatch(QString const&, DavCollectionChanges, QList<QString>)::{lambda()#1}::operator()()::{lambda(auto:1&)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&) const:
✓ Branch 2 → 3 taken 6 times.
✓ Branch 3 → 4 taken 6 times.
✓ Branch 15 → 5 taken 4 times.
✓ Branch 15 → 16 taken 6 times.
28 for (auto &item : items) {
709 4 item.calendarPath = calendarPath;
710
1/4
auto CalDavClient::fetchFailedMemberBatch(QString const&, DavCollectionChanges, QList<QString>)::{lambda()#1}::operator()()::{lambda(auto:1&)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&) const:
✗ Branch 8 → 9 not taken.
✗ Branch 8 → 13 not taken.
auto CalDavClient::fetchFailedMemberBatch(QString const&, DavCollectionChanges, QList<QString>)::{lambda()#1}::operator()()::{lambda(auto:1&)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&) const:
✓ Branch 8 → 9 taken 4 times.
✗ Branch 8 → 13 not taken.
4 if (!item.resourceHref.isEmpty())
711
1/2
auto CalDavClient::fetchFailedMemberBatch(QString const&, DavCollectionChanges, QList<QString>)::{lambda()#1}::operator()()::{lambda(auto:1&)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&) const:
✗ Branch 9 → 10 not taken.
auto CalDavClient::fetchFailedMemberBatch(QString const&, DavCollectionChanges, QList<QString>)::{lambda()#1}::operator()()::{lambda(auto:1&)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&) const:
✓ Branch 9 → 10 taken 4 times.
4 item.resourceHref = resolveDavUrl(item.resourceHref);
712 }
713 18 };
714
1/1
✓ Branch 61 → 62 taken 6 times.
6 resolve(events);
715
1/1
✓ Branch 62 → 63 taken 6 times.
6 resolve(tasks);
716
6/6
✓ Branch 63 → 64 taken 6 times.
✓ Branch 67 → 68 taken 6 times.
✓ Branch 68 → 69 taken 6 times.
✓ Branch 71 → 72 taken 6 times.
✓ Branch 77 → 65 taken 6 times.
✓ Branch 77 → 78 taken 6 times.
12 qCInfo(lcCalDav) << "calendar-multiget filled" << events.size() + tasks.size()
717
2/2
✓ Branch 72 → 73 taken 6 times.
✓ Branch 73 → 74 taken 6 times.
6 << "member(s) of" << calendarPath;
718
1/1
✓ Branch 78 → 79 taken 6 times.
6 changes.events += events;
719
1/1
✓ Branch 79 → 80 taken 6 times.
6 changes.tasks += tasks;
720
2/2
✓ Branch 80 → 81 taken 6 times.
✓ Branch 81 → 82 taken 6 times.
6 changes.failedHrefs += failedMembers(multiget.failed, calendarPath);
721 6 changes.failedHrefs += unansweredMembers(batch, multiget.listed, multiget.failed,
722
2/2
✓ Branch 83 → 84 taken 6 times.
✓ Branch 84 → 85 taken 6 times.
6 multiget.removed, calendarPath);
723
1/1
✓ Branch 89 → 90 taken 6 times.
6 fetchFailedMemberBatch(calendarPath, std::move(changes), pending);
724
10/10
✓ Branch 95 → 96 taken 6 times.
✓ Branch 95 → 97 taken 1 time.
✓ Branch 99 → 100 taken 6 times.
✓ Branch 99 → 101 taken 1 time.
✓ Branch 103 → 104 taken 6 times.
✓ Branch 103 → 105 taken 1 time.
✓ Branch 107 → 108 taken 6 times.
✓ Branch 107 → 109 taken 1 time.
✓ Branch 111 → 112 taken 6 times.
✓ Branch 111 → 114 taken 1 time.
11 });
725 7 }
726
727 // ── F-54 (SPRINT-104): bounded initial download ─────────────────────
728
729 16 QNetworkReply *CalDavClient::sendCalendarMultiget(const QString &calendarPath,
730 const QStringList &hrefs) {
731
1/1
✓ Branch 2 → 3 taken 16 times.
16 const QString url = resolveDavUrl(calendarPath);
732
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 16 times.
16 if (url.isEmpty()) return nullptr;
733 16 QByteArray hrefXml;
734
2/2
✓ Branch 32 → 8 taken 287 times.
✓ Branch 32 → 33 taken 16 times.
303 for (const QString &href : hrefs) {
735 // S106-048: the href keeps its percent-encoding (%23, %3F, %2F) — the
736 // decoded path named another resource and the member 404ed every run
737 // (CardDavClient does the same).
738
2/2
✓ Branch 9 → 10 taken 287 times.
✓ Branch 11 → 12 taken 287 times.
287 const QString path = QUrl(href).path(QUrl::FullyEncoded);
739
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 287 times.
287 if (path.isEmpty()) return nullptr;
740
5/5
✓ Branch 16 → 17 taken 287 times.
✓ Branch 17 → 18 taken 287 times.
✓ Branch 18 → 19 taken 287 times.
✓ Branch 19 → 20 taken 287 times.
✓ Branch 20 → 21 taken 287 times.
287 hrefXml += "<d:href>" + path.toHtmlEscaped().toUtf8() + "</d:href>";
741 287 }
742
2/2
✓ Branch 33 → 34 taken 16 times.
✓ Branch 34 → 35 taken 16 times.
16 QNetworkRequest request{QUrl(url)};
743
3/3
✓ Branch 36 → 37 taken 16 times.
✓ Branch 37 → 38 taken 16 times.
✓ Branch 38 → 39 taken 16 times.
16 request.setRawHeader("Authorization", authHeader());
744
3/3
✓ Branch 41 → 42 taken 16 times.
✓ Branch 42 → 43 taken 16 times.
✓ Branch 43 → 44 taken 16 times.
16 request.setRawHeader("Depth", "1");
745
3/3
✓ Branch 46 → 47 taken 16 times.
✓ Branch 47 → 48 taken 16 times.
✓ Branch 48 → 49 taken 16 times.
16 request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
746 const QByteArray body =
747 "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
748 "<c:calendar-multiget xmlns:d=\"DAV:\" "
749 " xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
750 16 " <d:prop><d:getetag/><c:calendar-data/></d:prop>" +
751
2/2
✓ Branch 51 → 52 taken 16 times.
✓ Branch 52 → 53 taken 16 times.
16 hrefXml + "</c:calendar-multiget>";
752
2/2
✓ Branch 54 → 55 taken 16 times.
✓ Branch 55 → 56 taken 16 times.
16 auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
753
2/2
✓ Branch 57 → 58 taken 16 times.
✓ Branch 58 → 59 taken 16 times.
16 MailJD::Performance::PimPerformanceTracker::instance().davWaitStarted();
754
1/1
✓ Branch 59 → 60 taken 16 times.
16 DavNetworkLimits::apply(reply);
755
1/1
✓ Branch 60 → 61 taken 16 times.
16 trackReply(reply);
756 16 return reply;
757 16 }
758
759 6 void CalDavClient::requestEtagListing(const QString &calendarPath) {
760
1/1
✓ Branch 2 → 3 taken 6 times.
6 const QString url = resolveDavUrl(calendarPath);
761
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 12 taken 6 times.
6 if (url.isEmpty()) {
762 ✗ emit syncFailed(QStringLiteral("Cross-origin URL rejected"));
763 ✗ return;
764 }
765
2/2
✓ Branch 12 → 13 taken 6 times.
✓ Branch 13 → 14 taken 6 times.
6 QNetworkRequest request{QUrl(url)};
766
3/3
✓ Branch 15 → 16 taken 6 times.
✓ Branch 16 → 17 taken 6 times.
✓ Branch 17 → 18 taken 6 times.
6 request.setRawHeader("Authorization", authHeader());
767
3/3
✓ Branch 20 → 21 taken 6 times.
✓ Branch 21 → 22 taken 6 times.
✓ Branch 22 → 23 taken 6 times.
6 request.setRawHeader("Depth", "0");
768
3/3
✓ Branch 25 → 26 taken 6 times.
✓ Branch 26 → 27 taken 6 times.
✓ Branch 27 → 28 taken 6 times.
6 request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
769 // The same token-less sync-collection as syncCollection(), but the prop
770 // asks for ETags only: the answer is a small member list, never the
771 // whole calendar's data.
772 const QByteArray body =
773 "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
774 "<d:sync-collection xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
775 "<d:sync-token></d:sync-token>"
776 "<d:sync-level>1</d:sync-level>"
777 "<d:prop><d:getetag/></d:prop>"
778
1/1
✓ Branch 30 → 31 taken 6 times.
6 "</d:sync-collection>";
779
2/2
✓ Branch 31 → 32 taken 6 times.
✓ Branch 32 → 33 taken 6 times.
6 auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
780
2/2
✓ Branch 34 → 35 taken 6 times.
✓ Branch 35 → 36 taken 6 times.
6 MailJD::Performance::PimPerformanceTracker::instance().davWaitStarted();
781
1/1
✓ Branch 36 → 37 taken 6 times.
6 DavNetworkLimits::apply(reply);
782
1/1
✓ Branch 37 → 38 taken 6 times.
6 trackReply(reply);
783
1/1
✓ Branch 39 → 40 taken 6 times.
6 connect(reply, &QNetworkReply::finished, this,
784 12 [this, reply, calendarPath]() {
785 6 onEtagListingReply(reply, calendarPath);
786 6 });
787 6 }
788
789 6 void CalDavClient::onEtagListingReply(QNetworkReply *reply, const QString &calendarPath) {
790
1/1
✓ Branch 2 → 3 taken 6 times.
6 reply->deleteLater();
791
1/1
✓ Branch 3 → 4 taken 6 times.
6 auto &performanceTracker = MailJD::Performance::PimPerformanceTracker::instance();
792
2/2
✓ Branch 4 → 5 taken 6 times.
✓ Branch 5 → 6 taken 6 times.
6 performanceTracker.davWaitFinished(reply->bytesAvailable());
793
1/1
✓ Branch 6 → 7 taken 6 times.
6 const QString limitError = DavNetworkLimits::failureReason(reply);
794
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 22 taken 5 times.
6 if (!limitError.isEmpty()) {
795 // Even the ETag listing breaks the bounds (≈85k members or a stalled
796 // server) — this collection is honestly too large for this client.
797
6/6
✓ Branch 9 → 10 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 19 → 11 taken 1 time.
✓ Branch 19 → 20 taken 1 time.
2 qCWarning(lcCalDav) << "ETag listing failed:" << limitError;
798
1/1
✓ Branch 20 → 21 taken 1 time.
1 emit syncFailed(limitError);
799 1 return;
800 }
801
2/2
✓ Branch 22 → 23 taken 5 times.
✓ Branch 23 → 24 taken 5 times.
5 const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
802
2/3
✓ Branch 25 → 26 taken 5 times.
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 67 taken 5 times.
5 if (reply->error() != QNetworkReply::NoError) {
803 // The data-carrying request before this one was accepted, so the
804 // unsupported classification is mostly theoretical; kept for parity.
805 ✗ if (status == 400 || status == 404 || status == 405 || status == 415 || status == 501 ||
806 status == 409) {
807 ✗ qCInfo(lcCalDav) << "ETag listing not supported for" << calendarPath << "status" << status;
808 ✗ emit collectionSyncRejected(calendarPath, false);
809 ✗ return;
810 }
811 ✗ qCWarning(lcCalDav) << "ETag listing failed:" << status << reply->errorString();
812 ✗ m_lastNetworkError = reply->error();
813 ✗ emit syncFailed(reply->errorString());
814 ✗ return;
815 }
816
1/1
✓ Branch 67 → 68 taken 5 times.
5 const QByteArray data = reply->readAll();
817 5 MultistatusSummary summary;
818
2/3
✓ Branch 68 → 69 taken 5 times.
✗ Branch 69 → 70 not taken.
✓ Branch 69 → 77 taken 5 times.
5 if (!summarizeMultistatus(data, &summary, /*requireCalendarData=*/false, calendarPath)) {
819 ✗ emit syncFailed(QStringLiteral("Invalid XML response"));
820 ✗ return;
821 }
822
1/2
✗ Branch 78 → 79 not taken.
✓ Branch 78 → 81 taken 5 times.
5 if (summary.token.isEmpty()) {
823 // Without a new token the result cannot be applied incrementally.
824 ✗ emit collectionSyncRejected(calendarPath, false);
825 ✗ return;
826 }
827
1/2
✗ Branch 81 → 82 not taken.
✓ Branch 81 → 126 taken 5 times.
5 if (summary.truncated) {
828 // Checkpoint the truncated listing; the continuation rides the normal
829 // delta path (token + calendar-data), whose pages the server bounds.
830 ✗ DavCollectionChanges changes;
831 ✗ changes.full = true;
832 ✗ changes.truncated = true;
833 ✗ changes.syncToken = summary.token;
834 ✗ for (const QString &removed : std::as_const(summary.removed)) {
835 ✗ const QString resolved = resolveDavUrl(removed);
836 ✗ if (resolved.isEmpty()) {
837 ✗ emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
838 ✗ return;
839 }
840 ✗ changes.removedHrefs << resolved;
841 ✗ }
842 ✗ changes.failedHrefs = failedMembers(summary.failed, calendarPath);
843 ✗ qCInfo(lcCalDav) << "ETag listing truncated for" << calendarPath
844 ✗ << "— checkpoint" << summary.token;
845 ✗ emit collectionSynced(calendarPath, changes);
846 ✗ return;
847 ✗ }
848 // Complete listing: resolve the members, drop the collection itself and
849 // cross-origin answers, and start the batched multiget download.
850
1/1
✓ Branch 126 → 127 taken 5 times.
5 const QString collectionUrl = resolveDavUrl(calendarPath);
851 5 EnumerationFetch fetch;
852 5 fetch.syncToken = summary.token;
853
1/1
✓ Branch 128 → 129 taken 5 times.
5 fetch.failedHrefs = failedMembers(summary.failed, calendarPath);
854 5 bool hrefsOk = true;
855
2/2
✓ Branch 160 → 134 taken 128 times.
✓ Branch 160 → 161 taken 5 times.
133 for (const QString &listed : std::as_const(summary.listed)) {
856
1/1
✓ Branch 135 → 136 taken 128 times.
128 const QString resolved = resolveDavUrl(listed);
857
4/6
✓ Branch 137 → 138 taken 128 times.
✗ Branch 137 → 145 not taken.
✓ Branch 139 → 140 taken 127 times.
✓ Branch 139 → 145 taken 1 time.
✗ Branch 144 → 145 not taken.
✓ Branch 144 → 146 taken 127 times.
255 if (resolved.isEmpty() || resolved == collectionUrl ||
858
5/7
✓ Branch 142 → 143 taken 127 times.
✓ Branch 147 → 148 taken 127 times.
✓ Branch 147 → 149 taken 1 time.
✓ Branch 149 → 150 taken 1 time.
✓ Branch 149 → 151 taken 127 times.
✗ Branch 277 → 278 not taken.
✗ Branch 277 → 279 not taken.
255 resolved + QLatin1Char('/') == collectionUrl)
859 1 continue;
860
1/1
✓ Branch 151 → 152 taken 127 times.
127 fetch.pending << resolved;
861 128 }
862
2/2
✓ Branch 177 → 164 taken 1 time.
✓ Branch 177 → 178 taken 5 times.
6 for (const QString &removed : std::as_const(summary.removed)) {
863
1/1
✓ Branch 165 → 166 taken 1 time.
1 const QString resolved = resolveDavUrl(removed);
864
2/4
✓ Branch 166 → 167 taken 1 time.
✗ Branch 166 → 170 not taken.
✓ Branch 168 → 169 taken 1 time.
✗ Branch 168 → 170 not taken.
1 hrefsOk = hrefsOk && !resolved.isEmpty();
865
2/3
✓ Branch 172 → 173 taken 1 time.
✗ Branch 172 → 174 not taken.
✓ Branch 173 → 174 taken 1 time.
1 if (!resolved.isEmpty()) fetch.removedHrefs << resolved;
866 1 }
867
1/2
✗ Branch 178 → 179 not taken.
✓ Branch 178 → 186 taken 5 times.
5 if (!hrefsOk) {
868 ✗ emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
869 ✗ return;
870 }
871
8/8
✓ Branch 186 → 187 taken 5 times.
✓ Branch 190 → 191 taken 5 times.
✓ Branch 191 → 192 taken 5 times.
✓ Branch 192 → 193 taken 5 times.
✓ Branch 193 → 194 taken 5 times.
✓ Branch 195 → 196 taken 5 times.
✓ Branch 200 → 188 taken 5 times.
✓ Branch 200 → 201 taken 5 times.
10 qCInfo(lcCalDav) << "ETag listing for" << calendarPath << "lists" << fetch.pending.size()
872
1/1
✓ Branch 196 → 197 taken 5 times.
5 << "member(s) — batched multiget download starts";
873
1/1
✓ Branch 202 → 203 taken 5 times.
5 m_enumerations.insert(calendarPath, std::move(fetch));
874
1/1
✓ Branch 203 → 204 taken 5 times.
5 fetchNextEnumerationBatch(calendarPath);
875 6 }
876
877 14 void CalDavClient::fetchNextEnumerationBatch(const QString &calendarPath) {
878
1/1
✓ Branch 2 → 3 taken 14 times.
14 auto it = m_enumerations.find(calendarPath);
879
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 14 times.
19 if (it == m_enumerations.end()) return;
880
6/6
✓ Branch 9 → 10 taken 12 times.
✓ Branch 9 → 14 taken 2 times.
✓ Branch 12 → 13 taken 7 times.
✓ Branch 12 → 14 taken 5 times.
✓ Branch 15 → 16 taken 7 times.
✓ Branch 15 → 35 taken 7 times.
14 if (it->batches.isEmpty() && !it->pending.isEmpty()) {
881 // Group lazily so a halved batchSize shapes the groups still pending.
882 7 const int count = qMin(it->batchSize, it->pending.size());
883
2/2
✓ Branch 22 → 23 taken 7 times.
✓ Branch 23 → 24 taken 7 times.
7 it->batches.append(it->pending.mid(0, count));
884
4/4
✓ Branch 27 → 28 taken 7 times.
✓ Branch 28 → 29 taken 7 times.
✓ Branch 31 → 32 taken 7 times.
✓ Branch 33 → 34 taken 7 times.
7 it->pending.erase(it->pending.begin(), it->pending.begin() + count);
885 }
886
2/2
✓ Branch 37 → 38 taken 5 times.
✓ Branch 37 → 72 taken 9 times.
14 if (it->batches.isEmpty()) {
887 // Final page: everything arrived through bounded multiget batches.
888 5 DavCollectionChanges changes;
889 5 changes.full = true;
890 5 changes.syncToken = it->syncToken;
891 5 changes.events = it->events;
892 5 changes.tasks = it->tasks;
893 5 changes.removedHrefs = it->removedHrefs;
894 5 changes.failedHrefs = it->failedHrefs;
895
7/7
✓ Branch 48 → 49 taken 5 times.
✓ Branch 52 → 53 taken 5 times.
✓ Branch 53 → 54 taken 5 times.
✓ Branch 54 → 55 taken 5 times.
✓ Branch 55 → 56 taken 5 times.
✓ Branch 66 → 50 taken 5 times.
✓ Branch 66 → 67 taken 5 times.
10 qCInfo(lcCalDav) << "batched enumeration of" << calendarPath << "finished:"
896
2/2
✓ Branch 58 → 59 taken 5 times.
✓ Branch 59 → 60 taken 5 times.
5 << changes.events.size() + changes.tasks.size() << "resource(s),"
897
2/2
✓ Branch 61 → 62 taken 5 times.
✓ Branch 62 → 63 taken 5 times.
5 << changes.failedHrefs.size() << "failed";
898
1/1
✓ Branch 67 → 68 taken 5 times.
5 emit collectionSynced(calendarPath, changes);
899
1/1
✓ Branch 69 → 70 taken 5 times.
5 m_enumerations.erase(it);
900 5 return;
901 5 }
902
1/1
✓ Branch 73 → 74 taken 9 times.
9 const QStringList batch = it->batches.takeFirst();
903
1/1
✓ Branch 74 → 75 taken 9 times.
9 QNetworkReply *reply = sendCalendarMultiget(calendarPath, batch);
904
1/2
✗ Branch 75 → 76 not taken.
✓ Branch 75 → 85 taken 9 times.
9 if (!reply) {
905 ✗ emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
906 ✗ m_enumerations.erase(it);
907 ✗ return;
908 }
909 9 connect(reply, &QNetworkReply::finished, this,
910
1/3
✓ Branch 87 → 88 taken 9 times.
✗ Branch 121 → 122 not taken.
✗ Branch 121 → 123 not taken.
18 [this, reply, calendarPath, batch]() {
911
1/1
✓ Branch 2 → 3 taken 9 times.
9 reply->deleteLater();
912
3/3
✓ Branch 3 → 4 taken 9 times.
✓ Branch 4 → 5 taken 9 times.
✓ Branch 5 → 6 taken 9 times.
9 MailJD::Performance::PimPerformanceTracker::instance().davWaitFinished(reply->bytesAvailable());
913
1/1
✓ Branch 6 → 7 taken 9 times.
9 auto it = m_enumerations.find(calendarPath);
914
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 9 times.
11 if (it == m_enumerations.end()) return;
915
1/1
✓ Branch 11 → 12 taken 9 times.
9 const QString limitError = DavNetworkLimits::failureReason(reply);
916
2/2
✓ Branch 13 → 14 taken 2 times.
✓ Branch 13 → 68 taken 7 times.
9 if (!limitError.isEmpty()) {
917
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 50 taken 1 time.
2 if (batch.size() > 1) {
918 // Split the failed batch (ContactBookSync's bounded-answer pattern)
919 // and halve the size of groups still to be formed.
920 1 it->batchSize = qMax(1, qMin(it->batchSize, batch.size()) / 2);
921 1 const int half = batch.size() / 2;
922
2/2
✓ Branch 23 → 24 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
1 it->batches.prepend(batch.mid(half));
923
2/2
✓ Branch 27 → 28 taken 1 time.
✓ Branch 28 → 29 taken 1 time.
1 it->batches.prepend(batch.mid(0, half));
924
8/8
✓ Branch 30 → 31 taken 1 time.
✓ Branch 34 → 35 taken 1 time.
✓ Branch 35 → 36 taken 1 time.
✓ Branch 37 → 38 taken 1 time.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 39 → 40 taken 1 time.
✓ Branch 48 → 32 taken 1 time.
✓ Branch 48 → 49 taken 1 time.
2 qCInfo(lcCalDav) << "multiget batch of" << batch.size() << "for" << calendarPath
925
4/4
✓ Branch 40 → 41 taken 1 time.
✓ Branch 41 → 42 taken 1 time.
✓ Branch 42 → 43 taken 1 time.
✓ Branch 44 → 45 taken 1 time.
1 << "was too large — splitting into" << half << "+" << (batch.size() - half);
926 } else {
927 // A single resource above the response limit is a failed member;
928 // the collection sync is marked incomplete and retried next run.
929
7/7
✓ Branch 50 → 51 taken 1 time.
✓ Branch 54 → 55 taken 1 time.
✓ Branch 55 → 56 taken 1 time.
✓ Branch 57 → 58 taken 1 time.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 62 → 52 taken 1 time.
✓ Branch 62 → 63 taken 1 time.
2 qCWarning(lcCalDav) << "resource" << batch.first() << "exceeds the response limit";
930
1/1
✓ Branch 65 → 66 taken 1 time.
1 it->failedHrefs << batch.first();
931 }
932
1/1
✓ Branch 66 → 67 taken 2 times.
2 fetchNextEnumerationBatch(calendarPath);
933 2 return;
934 }
935
2/3
✓ Branch 68 → 69 taken 7 times.
✗ Branch 69 → 70 not taken.
✓ Branch 69 → 90 taken 7 times.
7 if (reply->error() != QNetworkReply::NoError) {
936 ✗ qCWarning(lcCalDav) << "batched multiget failed:" << reply->errorString();
937 ✗ m_lastNetworkError = reply->error();
938 ✗ emit syncFailed(reply->errorString());
939 ✗ m_enumerations.erase(it);
940 ✗ return;
941 }
942
1/1
✓ Branch 90 → 91 taken 7 times.
7 const QByteArray data = reply->readAll();
943 7 QString parseError;
944 7 MultistatusSummary multiget;
945 7 QList<CalendarEvent> events; // parseEvents clears its output — parse
946 7 QList<CalendarTask> tasks; // per batch, then append to the totals
947
1/4
✗ Branch 102 → 103 not taken.
✓ Branch 102 → 127 taken 7 times.
✗ Branch 208 → 209 not taken.
✗ Branch 208 → 210 not taken.
7 if (!summarizeMultistatus(data, &multiget) ||
948
5/8
✓ Branch 92 → 93 taken 7 times.
✓ Branch 93 → 94 taken 7 times.
✗ Branch 93 → 98 not taken.
✓ Branch 94 → 95 taken 7 times.
✓ Branch 95 → 96 taken 7 times.
✗ Branch 95 → 98 not taken.
✓ Branch 100 → 101 taken 7 times.
✗ Branch 100 → 102 not taken.
14 !parseICalEvents(data, &events, &parseError) ||
949
2/3
✓ Branch 96 → 97 taken 7 times.
✗ Branch 97 → 98 not taken.
✓ Branch 97 → 99 taken 7 times.
7 !parseICalTasks(data, &tasks, &parseError)) {
950 ✗ qCWarning(lcCalDav) << "batched multiget parse failed:" << parseError;
951 ✗ emit syncFailed(parseError.isEmpty() ? QStringLiteral("Invalid XML response") : parseError);
952 ✗ m_enumerations.erase(it);
953 ✗ return;
954 }
955 21 const auto absorb = [this, &calendarPath](auto &items, bool *ok) {
956
7/8
auto CalDavClient::fetchNextEnumerationBatch(QString const&)::{lambda()#1}::operator()() const::{lambda(auto:1&, bool*)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&, bool*) const:
✓ Branch 2 → 3 taken 7 times.
✓ Branch 3 → 4 taken 7 times.
✗ Branch 22 → 5 not taken.
✓ Branch 22 → 23 taken 7 times.
auto CalDavClient::fetchNextEnumerationBatch(QString const&)::{lambda()#1}::operator()() const::{lambda(auto:1&, bool*)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&, bool*) const:
✓ Branch 2 → 3 taken 7 times.
✓ Branch 3 → 4 taken 7 times.
✓ Branch 22 → 5 taken 126 times.
✓ Branch 22 → 23 taken 7 times.
154 for (auto &item : items) {
957 126 item.calendarPath = calendarPath;
958
1/4
auto CalDavClient::fetchNextEnumerationBatch(QString const&)::{lambda()#1}::operator()() const::{lambda(auto:1&, bool*)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&, bool*) const:
✗ Branch 8 → 9 not taken.
✗ Branch 8 → 20 not taken.
auto CalDavClient::fetchNextEnumerationBatch(QString const&)::{lambda()#1}::operator()() const::{lambda(auto:1&, bool*)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&, bool*) const:
✓ Branch 8 → 9 taken 126 times.
✗ Branch 8 → 20 not taken.
126 if (!item.resourceHref.isEmpty()) {
959
1/2
auto CalDavClient::fetchNextEnumerationBatch(QString const&)::{lambda()#1}::operator()() const::{lambda(auto:1&, bool*)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&, bool*) const:
✗ Branch 9 → 10 not taken.
auto CalDavClient::fetchNextEnumerationBatch(QString const&)::{lambda()#1}::operator()() const::{lambda(auto:1&, bool*)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&, bool*) const:
✓ Branch 9 → 10 taken 126 times.
126 const QString resolved = resolveDavUrl(item.resourceHref);
960
1/4
auto CalDavClient::fetchNextEnumerationBatch(QString const&)::{lambda()#1}::operator()() const::{lambda(auto:1&, bool*)#1}::operator()<QList<CalendarTask> >(QList<CalendarTask>&, bool*) const:
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 13 not taken.
auto CalDavClient::fetchNextEnumerationBatch(QString const&)::{lambda()#1}::operator()() const::{lambda(auto:1&, bool*)#1}::operator()<QList<CalendarEvent> >(QList<CalendarEvent>&, bool*) const:
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 126 times.
126 if (resolved.isEmpty()) {
961 ✗ *ok = false;
962 ✗ return;
963 }
964 126 item.resourceHref = resolved;
965 126 }
966 }
967 7 };
968 7 bool hrefsOk = true;
969
1/1
✓ Branch 127 → 128 taken 7 times.
7 absorb(events, &hrefsOk);
970
2/3
✓ Branch 128 → 129 taken 7 times.
✗ Branch 128 → 130 not taken.
✓ Branch 129 → 130 taken 7 times.
7 if (hrefsOk) absorb(tasks, &hrefsOk);
971
1/2
✗ Branch 130 → 131 not taken.
✓ Branch 130 → 140 taken 7 times.
7 if (!hrefsOk) {
972 ✗ emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
973 ✗ m_enumerations.erase(it);
974 ✗ return;
975 }
976
1/1
✓ Branch 141 → 142 taken 7 times.
7 it->events += events;
977
1/1
✓ Branch 143 → 144 taken 7 times.
7 it->tasks += tasks;
978
2/2
✓ Branch 145 → 146 taken 7 times.
✓ Branch 146 → 147 taken 7 times.
7 it->failedHrefs << failedMembers(multiget.failed, calendarPath);
979 14 it->failedHrefs << unansweredMembers(batch, multiget.listed, multiget.failed,
980
2/2
✓ Branch 149 → 150 taken 7 times.
✓ Branch 150 → 151 taken 7 times.
7 multiget.removed, calendarPath);
981
1/1
✓ Branch 152 → 153 taken 7 times.
7 fetchNextEnumerationBatch(calendarPath);
982
7/12
✓ Branch 155 → 156 taken 7 times.
✗ Branch 155 → 157 not taken.
✓ Branch 159 → 160 taken 7 times.
✗ Branch 159 → 161 not taken.
✓ Branch 163 → 164 taken 7 times.
✗ Branch 163 → 165 not taken.
✓ Branch 167 → 168 taken 7 times.
✗ Branch 167 → 169 not taken.
✓ Branch 171 → 172 taken 7 times.
✗ Branch 171 → 173 not taken.
✓ Branch 175 → 176 taken 7 times.
✓ Branch 175 → 178 taken 2 times.
9 });
983 9 }
984
985 // ═══════════════════════════════════════════════════════
986 // Task Sync (REPORT calendar-query VTODO)
987 // ═══════════════════════════════════════════════════════
988
989 16 void CalDavClient::syncTasks(const QString &calendarPath) {
990
1/1
✓ Branch 2 → 3 taken 16 times.
16 QString url = resolveDavUrl(calendarPath);
991
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 12 taken 14 times.
16 if (url.isEmpty()) {
992
1/1
✓ Branch 8 → 9 taken 2 times.
2 emit syncFailed(QStringLiteral("Cross-origin URL rejected"));
993 2 return;
994 }
995
996
2/2
✓ Branch 12 → 13 taken 14 times.
✓ Branch 13 → 14 taken 14 times.
14 QNetworkRequest request{QUrl(url)};
997
3/3
✓ Branch 15 → 16 taken 14 times.
✓ Branch 16 → 17 taken 14 times.
✓ Branch 17 → 18 taken 14 times.
14 request.setRawHeader("Authorization", authHeader());
998
3/3
✓ Branch 20 → 21 taken 14 times.
✓ Branch 21 → 22 taken 14 times.
✓ Branch 22 → 23 taken 14 times.
14 request.setRawHeader("Depth", "1");
999
3/3
✓ Branch 25 → 26 taken 14 times.
✓ Branch 26 → 27 taken 14 times.
✓ Branch 27 → 28 taken 14 times.
14 request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
1000
1001 QByteArray body =
1002 "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
1003 "<c:calendar-query xmlns:d=\"DAV:\" "
1004 " xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
1005 " <d:prop>"
1006 " <d:getetag/>"
1007 " <c:calendar-data/>"
1008 " </d:prop>"
1009 " <c:filter>"
1010 " <c:comp-filter name=\"VCALENDAR\">"
1011 " <c:comp-filter name=\"VTODO\"/>"
1012 " </c:comp-filter>"
1013 " </c:filter>"
1014
1/1
✓ Branch 30 → 31 taken 14 times.
14 "</c:calendar-query>";
1015
1016 #ifdef MAILJD_E2E_TESTING
1017
1/1
✓ Branch 34 → 35 taken 10 times.
10 reportE2eTransportPhase(QStringLiteral("REPORT_TASKS"));
1018 #endif
1019
4/4
✓ Branch 31 → 32 taken 4 times.
✓ Branch 32 → 33 taken 4 times.
✓ Branch 37 → 38 taken 10 times.
✓ Branch 38 → 39 taken 10 times.
14 auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
1020
4/4
✓ Branch 34 → 35 taken 4 times.
✓ Branch 35 → 36 taken 4 times.
✓ Branch 40 → 41 taken 10 times.
✓ Branch 41 → 42 taken 10 times.
14 MailJD::Performance::PimPerformanceTracker::instance().davWaitStarted();
1021
2/2
✓ Branch 36 → 37 taken 4 times.
✓ Branch 42 → 43 taken 10 times.
14 DavNetworkLimits::apply(reply);
1022
2/2
✓ Branch 37 → 38 taken 4 times.
✓ Branch 43 → 44 taken 10 times.
14 trackReply(reply); // T-79.F1: lifetime follows request completion
1023
2/2
✓ Branch 39 → 40 taken 4 times.
✓ Branch 45 → 46 taken 10 times.
14 connect(reply, &QNetworkReply::finished, this,
1024 28 [this, reply, calendarPath]() {
1025 14 onSyncTasksReply(reply, calendarPath);
1026 14 });
1027 16 }
1028
1029 14 void CalDavClient::onSyncTasksReply(QNetworkReply *reply,
1030 const QString &calendarPath) {
1031
1/1
✓ Branch 2 → 3 taken 14 times.
14 reply->deleteLater();
1032 auto &performanceTracker =
1033
1/1
✓ Branch 3 → 4 taken 14 times.
14 MailJD::Performance::PimPerformanceTracker::instance();
1034
2/2
✓ Branch 4 → 5 taken 14 times.
✓ Branch 5 → 6 taken 14 times.
14 performanceTracker.davWaitFinished(reply->bytesAvailable());
1035
1036
1/1
✓ Branch 6 → 7 taken 14 times.
14 const QString limitError = DavNetworkLimits::failureReason(reply);
1037
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 22 taken 13 times.
14 if (!limitError.isEmpty()) {
1038
6/6
✓ Branch 9 → 10 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 19 → 11 taken 1 time.
✓ Branch 19 → 20 taken 1 time.
2 qCWarning(lcCalDav) << "REPORT (tasks) failed:" << limitError;
1039
1/1
✓ Branch 20 → 21 taken 1 time.
1 emit syncFailed(limitError);
1040 1 return;
1041 }
1042
1043
3/3
✓ Branch 22 → 23 taken 13 times.
✓ Branch 23 → 24 taken 1 time.
✓ Branch 23 → 42 taken 12 times.
13 if (reply->error() != QNetworkReply::NoError) {
1044
5/5
✓ Branch 24 → 25 taken 1 time.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 29 → 30 taken 1 time.
✓ Branch 36 → 26 taken 1 time.
✓ Branch 36 → 37 taken 1 time.
2 qCWarning(lcCalDav) << "REPORT (tasks) failed:"
1045
2/2
✓ Branch 30 → 31 taken 1 time.
✓ Branch 31 → 32 taken 1 time.
1 << reply->errorString();
1046
1/1
✓ Branch 37 → 38 taken 1 time.
1 m_lastNetworkError = reply->error();
1047
2/2
✓ Branch 38 → 39 taken 1 time.
✓ Branch 39 → 40 taken 1 time.
1 emit syncFailed(reply->errorString());
1048 1 return;
1049 }
1050
1051
1/1
✓ Branch 42 → 43 taken 12 times.
12 QByteArray data = reply->readAll();
1052 12 QList<CalendarTask> tasks;
1053 12 QString parseError;
1054
1/1
✓ Branch 43 → 44 taken 12 times.
12 performanceTracker.parseStarted();
1055
3/3
✓ Branch 44 → 45 taken 12 times.
✓ Branch 45 → 46 taken 2 times.
✓ Branch 45 → 61 taken 10 times.
12 if (!parseICalTasks(data, &tasks, &parseError)) {
1056
1/1
✓ Branch 47 → 48 taken 2 times.
2 performanceTracker.parseFinished(0, data.size());
1057
6/6
✓ Branch 48 → 49 taken 2 times.
✓ Branch 52 → 53 taken 2 times.
✓ Branch 53 → 54 taken 2 times.
✓ Branch 54 → 55 taken 2 times.
✓ Branch 58 → 50 taken 2 times.
✓ Branch 58 → 59 taken 2 times.
4 qCWarning(lcCalDav) << "REPORT (tasks) parse failed:" << parseError;
1058
1/1
✓ Branch 59 → 60 taken 2 times.
2 emit syncFailed(parseError);
1059 2 return;
1060 }
1061
1/1
✓ Branch 63 → 64 taken 10 times.
10 performanceTracker.parseFinished(tasks.size(), data.size());
1062
1063
4/4
✓ Branch 64 → 65 taken 10 times.
✓ Branch 65 → 66 taken 10 times.
✓ Branch 90 → 67 taken 9 times.
✓ Branch 90 → 91 taken 9 times.
18 for (auto &t : tasks) {
1064 9 t.calendarPath = calendarPath;
1065
2/2
✓ Branch 70 → 71 taken 8 times.
✓ Branch 70 → 88 taken 1 time.
9 if (!t.resourceHref.isEmpty()) {
1066
1/1
✓ Branch 71 → 72 taken 8 times.
8 const QString resolvedHref = resolveDavUrl(t.resourceHref);
1067
2/2
✓ Branch 73 → 74 taken 1 time.
✓ Branch 73 → 81 taken 7 times.
8 if (resolvedHref.isEmpty()) {
1068
1/1
✓ Branch 77 → 78 taken 1 time.
1 emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
1069 1 return;
1070 }
1071 7 t.resourceHref = resolvedHref;
1072 8 }
1073 }
1074
1075
1/1
✓ Branch 91 → 92 taken 9 times.
9 const bool complete = queryComplete(data, calendarPath);
1076
7/7
✓ Branch 92 → 93 taken 9 times.
✓ Branch 96 → 97 taken 9 times.
✓ Branch 97 → 98 taken 9 times.
✓ Branch 99 → 100 taken 9 times.
✓ Branch 100 → 101 taken 9 times.
✓ Branch 109 → 94 taken 9 times.
✓ Branch 109 → 110 taken 9 times.
18 qCInfo(lcCalDav) << "Synced" << tasks.size() << "tasks from"
1077
3/4
✓ Branch 101 → 102 taken 9 times.
✓ Branch 102 → 103 taken 9 times.
✗ Branch 102 → 104 not taken.
✓ Branch 105 → 106 taken 9 times.
9 << calendarPath << (complete ? "complete" : "incomplete");
1078
1/1
✓ Branch 110 → 111 taken 9 times.
9 emit tasksSynced(calendarPath, tasks, complete);
1079 23 }
1080
1081 // ═══════════════════════════════════════════════════════
1082 // iCal Parsing (RFC 5545): CalDavXml (Sprint 102 S102.3.4)
1083 // ═══════════════════════════════════════════════════════
1084
1085 QList<CalendarEvent>
1086 21 CalDavClient::parseICalEvents(const QByteArray &xmlData) {
1087 21 QList<CalendarEvent> events;
1088 21 QString error;
1089
1/1
✓ Branch 2 → 3 taken 21 times.
21 parseICalEvents(xmlData, &events, &error);
1090 21 return events;
1091 21 }
1092
1093 233 bool CalDavClient::parseICalEvents(const QByteArray &xmlData,
1094 QList<CalendarEvent> *events,
1095 QString *error) {
1096 233 return CalDavXml::parseEvents(xmlData, events, error);
1097 }
1098
1099 QList<CalendarTask>
1100 37 CalDavClient::parseICalTasks(const QByteArray &xmlData) {
1101 37 QList<CalendarTask> tasks;
1102 37 QString error;
1103
1/1
✓ Branch 2 → 3 taken 37 times.
37 parseICalTasks(xmlData, &tasks, &error);
1104 37 return tasks;
1105 37 }
1106
1107 236 bool CalDavClient::parseICalTasks(const QByteArray &xmlData,
1108 QList<CalendarTask> *tasks,
1109 QString *error) {
1110 236 return CalDavXml::parseTasks(xmlData, tasks, error);
1111 }
1112
1113 // ═══════════════════════════════════════════════════════
1114 // Sprint 39 – T-530: Write API (iCal serialization + PUT/DELETE)
1115 // ═══════════════════════════════════════════════════════
1116
1117 ✗ static QByteArray escapeICalText(const QString &text) {
1118 ✗ QString escaped = text;
1119 ✗ escaped.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
1120 ✗ escaped.replace(QLatin1Char(';'), QStringLiteral("\\;"));
1121 ✗ escaped.replace(QLatin1Char(','), QStringLiteral("\\,"));
1122 ✗ escaped.replace(QLatin1Char('\n'), QStringLiteral("\\n"));
1123 ✗ escaped.remove(QLatin1Char('\r'));
1124 ✗ return escaped.toUtf8();
1125 ✗ }
1126
1127 ✗ static QByteArray formatICalDate(const QDateTime &dt, bool allDay) {
1128 ✗ if (allDay)
1129 ✗ return dt.date().toString(QStringLiteral("yyyyMMdd")).toUtf8();
1130 // Always emit UTC
1131 ✗ return dt.toUTC()
1132 ✗ .toString(QStringLiteral("yyyyMMdd'T'HHmmss'Z'"))
1133 ✗ .toUtf8();
1134 }
1135
1136 153 QByteArray CalDavClient::eventToICalendar(const CalendarEvent &event) {
1137
1/1
✓ Branch 3 → 4 taken 153 times.
153 return seriesToICalendar(event, {});
1138 }
1139
1140 370 QByteArray CalDavClient::seriesToICalendar(const CalendarEvent &master,
1141 const QList<CalendarEvent> &exceptions) {
1142 // Sprint 93 S93.1.1: patch the stored components instead of rebuilding
1143 // them from the interpreted fields (B-01).
1144 370 return ICalEventCodec::buildResource(master, exceptions, master.resourceIcal);
1145 }
1146
1147 122 QByteArray CalDavClient::taskToICalendar(const CalendarTask &task) {
1148 122 return ICalTodoCodec::buildResource(task);
1149 }
1150
1151 96 QString CalDavClient::resourceUrl(const QString &calendarPath,
1152 const QString &uid) const {
1153
1/1
✓ Branch 2 → 3 taken 96 times.
96 QString basePath = resolveDavUrl(calendarPath);
1154
2/2
✓ Branch 4 → 5 taken 7 times.
✓ Branch 4 → 6 taken 89 times.
96 if (basePath.isEmpty())
1155 7 return {};
1156
3/3
✓ Branch 8 → 9 taken 89 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 9 → 14 taken 86 times.
89 if (!basePath.endsWith(QLatin1Char('/')))
1157
1/1
✓ Branch 12 → 13 taken 3 times.
3 basePath += QLatin1Char('/');
1158
3/3
✓ Branch 19 → 20 taken 89 times.
✓ Branch 20 → 21 taken 89 times.
✓ Branch 21 → 22 taken 89 times.
178 return basePath + QString::fromUtf8(QUrl::toPercentEncoding(uid)) +
1159
1/1
✓ Branch 22 → 23 taken 89 times.
267 QStringLiteral(".ics");
1160 96 }
1161
1162 196 QString CalDavClient::resourceUrlForExistingResource(
1163 const QString &resourceHref, const QString &calendarPath,
1164 const QString &uid) const {
1165
2/2
✓ Branch 3 → 4 taken 119 times.
✓ Branch 3 → 5 taken 77 times.
196 if (!resourceHref.isEmpty())
1166 119 return resolveDavUrl(resourceHref);
1167 77 return resourceUrl(calendarPath, uid);
1168 }
1169
1170 // ═══════════════════════════════════════════════════════
1171 // Sprint 93 S93.1.6: raw resource requests (write outbox)
1172 // ═══════════════════════════════════════════════════════
1173
1174 168 QString CalDavClient::eventResourceUrl(const QString &resourceHref, const QString &calendarPath,
1175 const QString &uid) const {
1176 168 return resourceUrlForExistingResource(resourceHref, calendarPath, uid);
1177 }
1178
1179 234 quint64 CalDavClient::sendResourceRequest(const QByteArray &method, const QString &url,
1180 const QByteArray &body,
1181 const QList<QPair<QByteArray, QByteArray>> &headers) {
1182 234 const quint64 id = ++m_nextResourceRequest;
1183
2/3
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 234 times.
✓ Branch 5 → 6 taken 234 times.
234 const QString resolved = url.isEmpty() ? QString() : resolveDavUrl(url);
1184
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 13 taken 234 times.
234 if (resolved.isEmpty()) {
1185 // Answer asynchronously like a network reply would.
1186 ✗ QTimer::singleShot(0, this, [this, id, method, url] {
1187 ✗ DavResponse response;
1188 ✗ response.request = id;
1189 ✗ response.method = method;
1190 ✗ response.url = url;
1191 ✗ response.error = QStringLiteral("Rejected or empty resource URL");
1192 ✗ emit resourceResponse(response);
1193 ✗ });
1194 ✗ return id;
1195 }
1196
2/2
✓ Branch 13 → 14 taken 234 times.
✓ Branch 14 → 15 taken 234 times.
234 QNetworkRequest request{QUrl(resolved)};
1197
3/3
✓ Branch 16 → 17 taken 234 times.
✓ Branch 17 → 18 taken 234 times.
✓ Branch 18 → 19 taken 234 times.
234 request.setRawHeader("Authorization", authHeader());
1198
2/2
✓ Branch 22 → 23 taken 128 times.
✓ Branch 22 → 29 taken 106 times.
234 if (!body.isEmpty())
1199
3/3
✓ Branch 23 → 24 taken 128 times.
✓ Branch 24 → 25 taken 128 times.
✓ Branch 25 → 26 taken 128 times.
128 request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
1200
2/2
✓ Branch 35 → 31 taken 142 times.
✓ Branch 35 → 36 taken 234 times.
376 for (const auto &header : headers)
1201
1/1
✓ Branch 32 → 33 taken 142 times.
142 request.setRawHeader(header.first, header.second);
1202
8/8
✓ Branch 37 → 38 taken 109 times.
✓ Branch 37 → 40 taken 125 times.
✓ Branch 39 → 40 taken 17 times.
✓ Branch 39 → 43 taken 92 times.
✓ Branch 41 → 42 taken 1 time.
✓ Branch 41 → 43 taken 141 times.
✓ Branch 44 → 45 taken 1 time.
✓ Branch 44 → 55 taken 233 times.
234 if ((method == "PUT" || method == "DELETE") && !m_pushDontNotify.isEmpty())
1203
5/5
✓ Branch 45 → 46 taken 1 time.
✓ Branch 46 → 47 taken 1 time.
✓ Branch 47 → 48 taken 1 time.
✓ Branch 48 → 49 taken 1 time.
✓ Branch 49 → 50 taken 1 time.
1 request.setRawHeader("Push-Dont-Notify", "\"" + m_pushDontNotify.toUtf8() + "\"");
1204 #ifdef MAILJD_E2E_TESTING
1205
3/3
✓ Branch 58 → 59 taken 159 times.
✓ Branch 59 → 60 taken 159 times.
✓ Branch 60 → 61 taken 159 times.
159 reportE2eTransportPhase(QString::fromLatin1(method) + QStringLiteral("_RESOURCE"));
1206 #endif
1207 // S106-058: a resource request never follows a redirect — QNAM re-sent a
1208 // followed 301/302 PUT/DELETE as GET, and the GET's status/ETag was taken
1209 // for the write result (the edit was lost, a DELETE looked done). The 3xx
1210 // is the answer.
1211
2/2
✓ Branch 56 → 57 taken 75 times.
✓ Branch 66 → 67 taken 159 times.
234 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy);
1212
6/6
✓ Branch 59 → 60 taken 31 times.
✓ Branch 59 → 62 taken 44 times.
✓ Branch 60 → 61 taken 31 times.
✓ Branch 69 → 70 taken 94 times.
✓ Branch 69 → 72 taken 65 times.
✓ Branch 70 → 71 taken 94 times.
343 QNetworkReply *reply = method == "PUT" ? m_nam->put(request, body)
1213
6/6
✓ Branch 63 → 64 taken 1 time.
✓ Branch 63 → 66 taken 43 times.
✓ Branch 64 → 65 taken 1 time.
✓ Branch 73 → 74 taken 16 times.
✓ Branch 73 → 76 taken 49 times.
✓ Branch 74 → 75 taken 16 times.
201 : method == "DELETE" ? m_nam->deleteResource(request)
1214
4/6
✓ Branch 67 → 68 taken 3 times.
✓ Branch 67 → 70 taken 40 times.
✓ Branch 68 → 69 taken 3 times.
✗ Branch 77 → 78 not taken.
✓ Branch 77 → 80 taken 49 times.
✗ Branch 78 → 79 not taken.
181 : method == "POST" ? m_nam->post(request, body)
1215
2/12
✗ Branch 71 → 72 not taken.
✓ Branch 71 → 75 taken 40 times.
✗ Branch 72 → 73 not taken.
✗ Branch 73 → 74 not taken.
✗ Branch 81 → 82 not taken.
✓ Branch 81 → 85 taken 49 times.
✗ Branch 82 → 83 not taken.
✗ Branch 83 → 84 not taken.
✗ Branch 130 → 131 not taken.
✗ Branch 130 → 132 not taken.
✗ Branch 152 → 153 not taken.
✗ Branch 152 → 154 not taken.
89 : method == "OPTIONS" ? m_nam->sendCustomRequest(request, "OPTIONS")
1216
4/6
✓ Branch 75 → 76 taken 40 times.
✗ Branch 80 → 81 not taken.
✓ Branch 80 → 82 taken 75 times.
✓ Branch 85 → 86 taken 49 times.
✗ Branch 90 → 91 not taken.
✓ Branch 90 → 92 taken 159 times.
323 : m_nam->get(request);
1217
2/2
✓ Branch 82 → 83 taken 75 times.
✓ Branch 92 → 93 taken 159 times.
234 DavNetworkLimits::apply(reply);
1218
2/2
✓ Branch 83 → 84 taken 75 times.
✓ Branch 93 → 94 taken 159 times.
234 trackReply(reply);
1219
2/6
✓ Branch 86 → 87 taken 75 times.
✓ Branch 96 → 97 taken 159 times.
✗ Branch 140 → 141 not taken.
✗ Branch 140 → 142 not taken.
✗ Branch 162 → 163 not taken.
✗ Branch 162 → 164 not taken.
234 connect(reply, &QNetworkReply::finished, this, [this, reply, id, method, resolved]() {
1220
1/1
✓ Branch 2 → 3 taken 233 times.
233 reply->deleteLater();
1221 233 DavResponse response;
1222 233 response.request = id;
1223 233 response.method = method;
1224 233 response.url = resolved;
1225
1/1
✓ Branch 5 → 6 taken 233 times.
233 response.error = DavNetworkLimits::failureReason(reply);
1226
1/2
✓ Branch 9 → 10 taken 233 times.
✗ Branch 9 → 54 not taken.
233 if (response.error.isEmpty()) {
1227
2/2
✓ Branch 10 → 11 taken 233 times.
✓ Branch 11 → 12 taken 233 times.
233 response.status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
1228
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 18 taken 233 times.
233 if (response.status == 0)
1229 ✗ response.error = reply->errorString();
1230
1/1
✓ Branch 18 → 19 taken 233 times.
233 response.body = reply->readAll();
1231
2/2
✓ Branch 25 → 26 taken 233 times.
✓ Branch 26 → 27 taken 233 times.
466 response.etag = QString::fromUtf8(reply->rawHeader("ETag"));
1232
2/2
✓ Branch 34 → 35 taken 233 times.
✓ Branch 35 → 36 taken 233 times.
466 response.davHeader = reply->rawHeader("DAV").left(4096);
1233
1/1
✓ Branch 43 → 44 taken 233 times.
466 const QByteArray location = reply->rawHeader("Location");
1234
1/2
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 52 taken 233 times.
233 if (!location.isEmpty())
1235 ✗ response.location = resolveDavUrl(QString::fromUtf8(location));
1236 233 }
1237
9/9
✓ Branch 54 → 55 taken 233 times.
✓ Branch 58 → 59 taken 233 times.
✓ Branch 59 → 60 taken 233 times.
✓ Branch 60 → 61 taken 233 times.
✓ Branch 61 → 62 taken 233 times.
✓ Branch 62 → 63 taken 233 times.
✓ Branch 63 → 64 taken 233 times.
✓ Branch 68 → 56 taken 233 times.
✓ Branch 68 → 69 taken 233 times.
466 qCDebug(lcCalDav) << method << "resource" << response.url << "->" << response.status
1238
1/1
✓ Branch 64 → 65 taken 233 times.
233 << response.error;
1239
1/1
✓ Branch 69 → 70 taken 233 times.
233 emit resourceResponse(response);
1240 233 });
1241 234 return id;
1242 234 }
1243
1244 125 quint64 CalDavClient::putResource(const QString &url, const QByteArray &body,
1245 const QString &ifMatch, bool createOnly) {
1246 125 QList<QPair<QByteArray, QByteArray>> headers;
1247
2/2
✓ Branch 2 → 3 taken 23 times.
✓ Branch 2 → 11 taken 102 times.
125 if (createOnly) {
1248
4/4
✓ Branch 3 → 4 taken 23 times.
✓ Branch 4 → 5 taken 23 times.
✓ Branch 5 → 6 taken 23 times.
✓ Branch 6 → 7 taken 23 times.
23 headers.append(qMakePair(QByteArray("If-None-Match"), QByteArray("*")));
1249
2/3
✓ Branch 11 → 12 taken 102 times.
✓ Branch 12 → 13 taken 102 times.
✗ Branch 12 → 21 not taken.
102 } else if (isValidEtag(ifMatch)) {
1250
4/4
✓ Branch 13 → 14 taken 102 times.
✓ Branch 14 → 15 taken 102 times.
✓ Branch 15 → 16 taken 102 times.
✓ Branch 16 → 17 taken 102 times.
102 headers.append(qMakePair(QByteArray("If-Match"), ifMatch.toUtf8()));
1251 } else {
1252 ✗ qCWarning(lcCalDav) << "Refusing PUT without a valid entity-tag for" << url;
1253 ✗ return sendResourceRequest("PUT", QString(), {}, {});
1254 }
1255
2/2
✓ Branch 43 → 44 taken 125 times.
✓ Branch 44 → 45 taken 125 times.
125 return sendResourceRequest("PUT", url, body, headers);
1256 125 }
1257
1258 17 quint64 CalDavClient::deleteResource(const QString &url, const QString &ifMatch) {
1259
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 26 taken 17 times.
17 if (!isValidEtag(ifMatch)) {
1260 ✗ qCWarning(lcCalDav) << "Refusing DELETE without a valid entity-tag for" << url;
1261 ✗ return sendResourceRequest("DELETE", QString(), {}, {});
1262 }
1263
5/7
✓ Branch 27 → 28 taken 17 times.
✓ Branch 31 → 32 taken 17 times.
✓ Branch 32 → 33 taken 17 times.
✓ Branch 38 → 39 taken 17 times.
✓ Branch 38 → 40 taken 17 times.
✗ Branch 70 → 71 not taken.
✗ Branch 70 → 72 not taken.
51 return sendResourceRequest("DELETE", url, {}, {qMakePair(QByteArray("If-Match"), ifMatch.toUtf8())});
1264 34 }
1265
1266 89 quint64 CalDavClient::getResource(const QString &url) {
1267
2/2
✓ Branch 4 → 5 taken 89 times.
✓ Branch 5 → 6 taken 89 times.
89 return sendResourceRequest("GET", url, {}, {});
1268 }
1269
1270 3 quint64 CalDavClient::postFreeBusy(const QString &outboxUrl, const QByteArray &request) {
1271
2/2
✓ Branch 3 → 4 taken 3 times.
✓ Branch 4 → 5 taken 3 times.
3 return sendResourceRequest("POST", outboxUrl, request, {});
1272 }
1273
1274 ✗ quint64 CalDavClient::options(const QString &url) {
1275 ✗ return sendResourceRequest("OPTIONS", url, {}, {});
1276 }
1277
1278 8 void CalDavClient::createEvent(const QString &calendarPath,
1279 const CalendarEvent &event) {
1280
1/1
✓ Branch 2 → 3 taken 8 times.
8 QString url = resourceUrl(calendarPath, event.uid);
1281
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 12 taken 7 times.
8 if (url.isEmpty()) {
1282
1/1
✓ Branch 8 → 9 taken 1 time.
1 emit writeFailed(QStringLiteral("Invalid calendar path for event creation"));
1283 1 return;
1284 }
1285
1286
2/2
✓ Branch 12 → 13 taken 7 times.
✓ Branch 13 → 14 taken 7 times.
7 QNetworkRequest request{QUrl(url)};
1287
3/3
✓ Branch 15 → 16 taken 7 times.
✓ Branch 16 → 17 taken 7 times.
✓ Branch 17 → 18 taken 7 times.
7 request.setRawHeader("Authorization", authHeader());
1288
3/3
✓ Branch 20 → 21 taken 7 times.
✓ Branch 21 → 22 taken 7 times.
✓ Branch 22 → 23 taken 7 times.
7 request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
1289
3/3
✓ Branch 25 → 26 taken 7 times.
✓ Branch 26 → 27 taken 7 times.
✓ Branch 27 → 28 taken 7 times.
7 request.setRawHeader("If-None-Match", "*"); // Only create, don't overwrite
1290
1291
1/1
✓ Branch 30 → 31 taken 7 times.
7 QByteArray body = eventToICalendar(event);
1292 #ifdef MAILJD_E2E_TESTING
1293
1/1
✓ Branch 34 → 35 taken 5 times.
5 reportE2eTransportPhase(QStringLiteral("PUT_EVENT"));
1294 #endif
1295
2/2
✓ Branch 32 → 33 taken 2 times.
✓ Branch 38 → 39 taken 5 times.
7 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); // S106-058
1296
2/2
✓ Branch 34 → 35 taken 2 times.
✓ Branch 40 → 41 taken 5 times.
7 auto *reply = m_nam->put(request, body);
1297
2/2
✓ Branch 35 → 36 taken 2 times.
✓ Branch 41 → 42 taken 5 times.
7 DavNetworkLimits::apply(reply);
1298
2/2
✓ Branch 36 → 37 taken 2 times.
✓ Branch 42 → 43 taken 5 times.
7 trackReply(reply); // T-79.F1: lifetime follows request completion
1299
1300 7 connect(reply, &QNetworkReply::finished, this,
1301
2/10
✓ Branch 40 → 41 taken 2 times.
✓ Branch 46 → 47 taken 5 times.
✗ Branch 84 → 85 not taken.
✗ Branch 84 → 86 not taken.
✗ Branch 87 → 88 not taken.
✗ Branch 87 → 89 not taken.
✗ Branch 96 → 97 not taken.
✗ Branch 96 → 98 not taken.
✗ Branch 99 → 100 not taken.
✗ Branch 99 → 101 not taken.
14 [this, reply, event, url, body]() {
1302
1/1
✓ Branch 2 → 3 taken 7 times.
7 reply->deleteLater();
1303
1/1
✓ Branch 3 → 4 taken 7 times.
7 const QString limitError = DavNetworkLimits::failureReason(reply);
1304
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 8 taken 6 times.
7 if (!limitError.isEmpty()) {
1305
1/1
✓ Branch 6 → 7 taken 1 time.
1 emit writeFailed(limitError);
1306 1 return;
1307 }
1308
1/1
✓ Branch 8 → 9 taken 6 times.
6 int status = reply->attribute(
1309
1/1
✓ Branch 9 → 10 taken 6 times.
6 QNetworkRequest::HttpStatusCodeAttribute).toInt();
1310
4/4
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 3 times.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 58 taken 2 times.
6 if (status == 201 || status == 204) {
1311 // Update etag from response
1312 4 CalendarEvent saved = event;
1313
1/1
✓ Branch 14 → 15 taken 4 times.
4 ICalEventCodec::adoptPushedResource(&saved, body);
1314
1/1
✓ Branch 19 → 20 taken 4 times.
8 QByteArray newEtag = reply->rawHeader("ETag");
1315
2/2
✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 26 taken 1 time.
4 if (!newEtag.isEmpty())
1316
1/1
✓ Branch 22 → 23 taken 3 times.
3 saved.etag = QString::fromUtf8(newEtag);
1317
1/1
✓ Branch 30 → 31 taken 4 times.
8 const QByteArray location = reply->rawHeader("Location");
1318 4 const QString savedHref = location.isEmpty()
1319 6 ? url
1320
6/8
✓ Branch 32 → 33 taken 2 times.
✓ Branch 32 → 34 taken 2 times.
✓ Branch 34 → 35 taken 2 times.
✓ Branch 35 → 36 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
✓ Branch 36 → 38 taken 2 times.
✗ Branch 98 → 99 not taken.
✗ Branch 98 → 100 not taken.
4 : resolveDavUrl(QString::fromUtf8(location));
1321
2/2
✓ Branch 39 → 40 taken 3 times.
✓ Branch 39 → 41 taken 1 time.
4 if (!savedHref.isEmpty())
1322 3 saved.resourceHref = savedHref;
1323
6/6
✓ Branch 41 → 42 taken 4 times.
✓ Branch 45 → 46 taken 4 times.
✓ Branch 46 → 47 taken 4 times.
✓ Branch 47 → 48 taken 4 times.
✓ Branch 51 → 43 taken 4 times.
✓ Branch 51 → 52 taken 4 times.
8 qCDebug(lcCalDav) << "Event created:" << saved.uid;
1324
1/1
✓ Branch 52 → 53 taken 4 times.
4 emit eventSaved(saved);
1325 4 } else {
1326 4 QString err = QStringLiteral("Create event failed (HTTP %1): %2")
1327
4/4
✓ Branch 62 → 63 taken 2 times.
✓ Branch 64 → 65 taken 2 times.
✓ Branch 65 → 66 taken 2 times.
✓ Branch 66 → 67 taken 2 times.
4 .arg(status).arg(QString::fromUtf8(reply->readAll()));
1328 4 qCWarning(lcCalDav) << err; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; reply validation remains measured.
1329
1/1
✓ Branch 82 → 83 taken 2 times.
2 emit writeFailed(err);
1330 2 }
1331
2/2
✓ Branch 87 → 88 taken 6 times.
✓ Branch 87 → 90 taken 1 time.
7 });
1332 8 }
1333
1334 8 void CalDavClient::updateEvent(const CalendarEvent &event) {
1335 8 QString url = resourceUrlForExistingResource(event.resourceHref,
1336 8 event.calendarPath,
1337
1/1
✓ Branch 2 → 3 taken 8 times.
8 event.uid);
1338
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 12 taken 7 times.
8 if (url.isEmpty()) {
1339
1/1
✓ Branch 8 → 9 taken 1 time.
1 emit writeFailed(QStringLiteral("Invalid calendar path for event update"));
1340 1 return;
1341 }
1342
1343
2/2
✓ Branch 12 → 13 taken 7 times.
✓ Branch 13 → 14 taken 7 times.
7 QNetworkRequest request{QUrl(url)};
1344
3/3
✓ Branch 15 → 16 taken 7 times.
✓ Branch 16 → 17 taken 7 times.
✓ Branch 17 → 18 taken 7 times.
7 request.setRawHeader("Authorization", authHeader());
1345
3/3
✓ Branch 20 → 21 taken 7 times.
✓ Branch 21 → 22 taken 7 times.
✓ Branch 22 → 23 taken 7 times.
7 request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
1346
3/3
✓ Branch 25 → 26 taken 7 times.
✓ Branch 26 → 27 taken 2 times.
✓ Branch 26 → 44 taken 5 times.
7 if (!isValidEtag(event.etag)) {
1347
5/5
✓ Branch 27 → 28 taken 2 times.
✓ Branch 31 → 32 taken 2 times.
✓ Branch 32 → 33 taken 2 times.
✓ Branch 36 → 29 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
4 qCWarning(lcCalDav) << "Rejecting PUT without a valid RFC entity-tag";
1348
1/1
✓ Branch 40 → 41 taken 2 times.
2 emit writeFailed(QStringLiteral("Invalid or missing server ETag — write "
1349 "refused to preserve concurrency protection"));
1350 2 return;
1351 }
1352
3/3
✓ Branch 44 → 45 taken 5 times.
✓ Branch 45 → 46 taken 5 times.
✓ Branch 46 → 47 taken 5 times.
5 request.setRawHeader("If-Match", event.etag.toUtf8());
1353
1354
1/1
✓ Branch 49 → 50 taken 5 times.
5 QByteArray body = eventToICalendar(event);
1355 #ifdef MAILJD_E2E_TESTING
1356
1/1
✓ Branch 53 → 54 taken 2 times.
2 reportE2eTransportPhase(QStringLiteral("PUT_EVENT"));
1357 #endif
1358
2/2
✓ Branch 51 → 52 taken 3 times.
✓ Branch 57 → 58 taken 2 times.
5 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); // S106-058
1359
2/2
✓ Branch 53 → 54 taken 3 times.
✓ Branch 59 → 60 taken 2 times.
5 auto *reply = m_nam->put(request, body);
1360
2/2
✓ Branch 54 → 55 taken 3 times.
✓ Branch 60 → 61 taken 2 times.
5 DavNetworkLimits::apply(reply);
1361
2/2
✓ Branch 55 → 56 taken 3 times.
✓ Branch 61 → 62 taken 2 times.
5 trackReply(reply); // T-79.F1: lifetime follows request completion
1362
1363 5 connect(reply, &QNetworkReply::finished, this,
1364
2/6
✓ Branch 58 → 59 taken 3 times.
✓ Branch 64 → 65 taken 2 times.
✗ Branch 116 → 117 not taken.
✗ Branch 116 → 118 not taken.
✗ Branch 128 → 129 not taken.
✗ Branch 128 → 130 not taken.
10 [this, reply, event, body]() {
1365
1/1
✓ Branch 2 → 3 taken 5 times.
5 reply->deleteLater();
1366
1/1
✓ Branch 3 → 4 taken 5 times.
5 const QString limitError = DavNetworkLimits::failureReason(reply);
1367
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 8 taken 4 times.
5 if (!limitError.isEmpty()) {
1368
1/1
✓ Branch 6 → 7 taken 1 time.
1 emit writeFailed(limitError);
1369 1 return;
1370 }
1371
1/1
✓ Branch 8 → 9 taken 4 times.
4 int status = reply->attribute(
1372
1/1
✓ Branch 9 → 10 taken 4 times.
4 QNetworkRequest::HttpStatusCodeAttribute).toInt();
1373
4/4
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 41 taken 2 times.
4 if (status == 200 || status == 204) {
1374 2 CalendarEvent saved = event;
1375
1/1
✓ Branch 14 → 15 taken 2 times.
2 ICalEventCodec::adoptPushedResource(&saved, body);
1376
1/1
✓ Branch 19 → 20 taken 2 times.
4 QByteArray newEtag = reply->rawHeader("ETag");
1377
1/2
✓ Branch 21 → 22 taken 2 times.
✗ Branch 21 → 26 not taken.
2 if (!newEtag.isEmpty())
1378
1/1
✓ Branch 22 → 23 taken 2 times.
2 saved.etag = QString::fromUtf8(newEtag);
1379
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 36 → 28 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
4 qCDebug(lcCalDav) << "Event updated:" << saved.uid;
1380
1/1
✓ Branch 37 → 38 taken 2 times.
2 emit eventSaved(saved);
1381
2/2
✓ Branch 41 → 42 taken 1 time.
✓ Branch 41 → 49 taken 1 time.
4 } else if (status == 412) {
1382
1/1
✓ Branch 45 → 46 taken 1 time.
2 emit writeFailed(QStringLiteral(
1383 "Conflict: event was modified on server (ETag mismatch)"));
1384 } else {
1385 2 QString err = QStringLiteral("Update event failed (HTTP %1): %2")
1386
4/4
✓ Branch 53 → 54 taken 1 time.
✓ Branch 55 → 56 taken 1 time.
✓ Branch 56 → 57 taken 1 time.
✓ Branch 57 → 58 taken 1 time.
2 .arg(status).arg(QString::fromUtf8(reply->readAll()));
1387
5/5
✓ Branch 63 → 64 taken 1 time.
✓ Branch 67 → 68 taken 1 time.
✓ Branch 68 → 69 taken 1 time.
✓ Branch 72 → 65 taken 1 time.
✓ Branch 72 → 73 taken 1 time.
2 qCWarning(lcCalDav) << err;
1388
1/1
✓ Branch 73 → 74 taken 1 time.
1 emit writeFailed(err);
1389 1 }
1390
2/2
✓ Branch 78 → 79 taken 4 times.
✓ Branch 78 → 81 taken 1 time.
5 });
1391 10 }
1392
1393 1 void CalDavClient::setCalendarColor(const QString &calendarPath, const QString &color) {
1394
1/1
✓ Branch 2 → 3 taken 1 time.
1 const QString url = resolveDavUrl(calendarPath);
1395
3/7
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 16 not taken.
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 16 not taken.
✓ Branch 10 → 11 taken 1 time.
✗ Branch 79 → 80 not taken.
✗ Branch 79 → 81 not taken.
2 static const QRegularExpression rgb(QStringLiteral("^#[0-9A-Fa-f]{6}$"));
1396
6/12
✓ Branch 17 → 18 taken 1 time.
✗ Branch 17 → 22 not taken.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 20 → 21 taken 1 time.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
✗ Branch 24 → 26 not taken.
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 34 taken 1 time.
✗ Branch 82 → 83 not taken.
✗ Branch 82 → 84 not taken.
2 if (url.isEmpty() || !rgb.match(color).hasMatch()) {
1397 ✗ emit writeFailed(QStringLiteral("Invalid calendar color request"));
1398 ✗ return;
1399 }
1400
2/2
✓ Branch 34 → 35 taken 1 time.
✓ Branch 35 → 36 taken 1 time.
1 QNetworkRequest request{QUrl(url)};
1401
3/3
✓ Branch 37 → 38 taken 1 time.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 39 → 40 taken 1 time.
1 request.setRawHeader("Authorization", authHeader());
1402
3/3
✓ Branch 42 → 43 taken 1 time.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 44 → 45 taken 1 time.
1 request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
1403 // Apple's form #RRGGBBAA; Nextcloud, Radicale and iCloud accept it.
1404 const QByteArray body =
1405 "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
1406 "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:apple=\"http://apple.com/ns/ical/\">"
1407
3/3
✓ Branch 47 → 48 taken 1 time.
✓ Branch 48 → 49 taken 1 time.
✓ Branch 49 → 50 taken 1 time.
2 "<d:set><d:prop><apple:calendar-color>" + color.toUpper().toLatin1() + "FF"
1408
1/1
✓ Branch 50 → 51 taken 1 time.
1 "</apple:calendar-color></d:prop></d:set></d:propertyupdate>";
1409
2/2
✓ Branch 54 → 55 taken 1 time.
✓ Branch 55 → 56 taken 1 time.
1 auto *reply = m_nam->sendCustomRequest(request, "PROPPATCH", body);
1410
1/1
✓ Branch 57 → 58 taken 1 time.
1 DavNetworkLimits::apply(reply);
1411
1/1
✓ Branch 58 → 59 taken 1 time.
1 trackReply(reply);
1412
1/1
✓ Branch 60 → 61 taken 1 time.
1 connect(reply, &QNetworkReply::finished, this, [this, reply, calendarPath]() {
1413
1/1
✓ Branch 2 → 3 taken 1 time.
1 reply->deleteLater();
1414
1/1
✓ Branch 3 → 4 taken 1 time.
1 const QString limitError = DavNetworkLimits::failureReason(reply);
1415
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 5 → 6 taken 1 time.
1 const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
1416
2/3
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 10 not taken.
✓ Branch 9 → 11 taken 1 time.
1 const QByteArray answer = limitError.isEmpty() ? reply->readAll() : QByteArray();
1417 // 207: the property's own propstat decides (403 = not allowed).
1418
2/4
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 23 not taken.
✓ Branch 13 → 14 taken 1 time.
✗ Branch 13 → 22 not taken.
2 const bool saved = limitError.isEmpty() &&
1419
4/8
✓ Branch 14 → 15 taken 1 time.
✗ Branch 14 → 22 not taken.
✓ Branch 15 → 16 taken 1 time.
✗ Branch 15 → 23 not taken.
✓ Branch 18 → 19 taken 1 time.
✗ Branch 18 → 23 not taken.
✓ Branch 21 → 22 taken 1 time.
✗ Branch 21 → 23 not taken.
1 (status == 200 || status == 204 || (status == 207 && answer.contains(" 200 ") && !answer.contains(" 403 ")));
1420
1/2
✓ Branch 24 → 25 taken 1 time.
✗ Branch 24 → 37 not taken.
1 if (saved) {
1421
6/6
✓ Branch 25 → 26 taken 1 time.
✓ Branch 29 → 30 taken 1 time.
✓ Branch 30 → 31 taken 1 time.
✓ Branch 31 → 32 taken 1 time.
✓ Branch 35 → 27 taken 1 time.
✓ Branch 35 → 36 taken 1 time.
2 qCInfo(lcCalDav) << "Calendar color saved for" << calendarPath;
1422
1/1
✓ Branch 36 → 62 taken 1 time.
1 emit calendarColorSaved(calendarPath);
1423 } else {
1424 ✗ const QString error = !limitError.isEmpty() ? limitError
1425 ✗ : QStringLiteral("Calendar color not saved (HTTP %1)").arg(status);
1426 ✗ qCWarning(lcCalDav) << error;
1427 ✗ emit writeFailed(error);
1428 ✗ }
1429 1 });
1430 1 }
1431
1432 6 void CalDavClient::deleteEvent(const CalendarEvent &event) {
1433 6 QString url = resourceUrlForExistingResource(event.resourceHref,
1434 6 event.calendarPath,
1435
1/1
✓ Branch 2 → 3 taken 6 times.
6 event.uid);
1436
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 12 taken 5 times.
6 if (url.isEmpty()) {
1437
1/1
✓ Branch 8 → 9 taken 1 time.
1 emit writeFailed(QStringLiteral("Invalid calendar path for event deletion"));
1438 1 return;
1439 }
1440
1441
2/2
✓ Branch 12 → 13 taken 5 times.
✓ Branch 13 → 14 taken 5 times.
5 QNetworkRequest request{QUrl(url)};
1442
3/3
✓ Branch 15 → 16 taken 5 times.
✓ Branch 16 → 17 taken 5 times.
✓ Branch 17 → 18 taken 5 times.
5 request.setRawHeader("Authorization", authHeader());
1443
2/3
✓ Branch 20 → 21 taken 5 times.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 39 taken 5 times.
5 if (!isValidEtag(event.etag)) {
1444 ✗ qCWarning(lcCalDav) << "Rejecting DELETE without a valid RFC entity-tag";
1445 ✗ emit writeFailed(QStringLiteral("Invalid or missing server ETag — delete "
1446 "refused to preserve concurrency protection"));
1447 ✗ return;
1448 }
1449
3/3
✓ Branch 39 → 40 taken 5 times.
✓ Branch 40 → 41 taken 5 times.
✓ Branch 41 → 42 taken 5 times.
5 request.setRawHeader("If-Match", event.etag.toUtf8());
1450
1451 #ifdef MAILJD_E2E_TESTING
1452
1/1
✓ Branch 47 → 48 taken 3 times.
3 reportE2eTransportPhase(QStringLiteral("DELETE_EVENT"));
1453 #endif
1454
2/2
✓ Branch 45 → 46 taken 2 times.
✓ Branch 51 → 52 taken 3 times.
5 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); // S106-058
1455
2/2
✓ Branch 47 → 48 taken 2 times.
✓ Branch 53 → 54 taken 3 times.
5 auto *reply = m_nam->deleteResource(request);
1456
2/2
✓ Branch 48 → 49 taken 2 times.
✓ Branch 54 → 55 taken 3 times.
5 DavNetworkLimits::apply(reply);
1457
2/2
✓ Branch 49 → 50 taken 2 times.
✓ Branch 55 → 56 taken 3 times.
5 trackReply(reply); // T-79.F1: lifetime follows request completion
1458
1459
2/2
✓ Branch 51 → 52 taken 2 times.
✓ Branch 57 → 58 taken 3 times.
5 connect(reply, &QNetworkReply::finished, this,
1460 10 [this, reply, uid = event.uid]() {
1461
1/1
✓ Branch 2 → 3 taken 5 times.
5 reply->deleteLater();
1462
1/1
✓ Branch 3 → 4 taken 5 times.
5 const QString limitError = DavNetworkLimits::failureReason(reply);
1463
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 8 taken 4 times.
5 if (!limitError.isEmpty()) {
1464
1/1
✓ Branch 6 → 7 taken 1 time.
1 emit writeFailed(limitError);
1465 1 return;
1466 }
1467
1/1
✓ Branch 8 → 9 taken 4 times.
4 int status = reply->attribute(
1468
1/1
✓ Branch 9 → 10 taken 4 times.
4 QNetworkRequest::HttpStatusCodeAttribute).toInt();
1469
4/4
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 26 taken 2 times.
4 if (status == 200 || status == 204) {
1470
6/6
✓ Branch 13 → 14 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 18 → 19 taken 2 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 23 → 15 taken 2 times.
✓ Branch 23 → 24 taken 2 times.
4 qCDebug(lcCalDav) << "Event deleted:" << uid;
1471
1/1
✓ Branch 24 → 25 taken 2 times.
2 emit eventDeleted(uid);
1472
2/2
✓ Branch 26 → 27 taken 1 time.
✓ Branch 26 → 34 taken 1 time.
4 } else if (status == 412) {
1473
1/1
✓ Branch 30 → 31 taken 1 time.
2 emit writeFailed(QStringLiteral(
1474 "Conflict: event was modified on server (ETag mismatch)"));
1475 } else {
1476 2 QString err = QStringLiteral("Delete event failed (HTTP %1)")
1477
1/1
✓ Branch 38 → 39 taken 1 time.
1 .arg(status);
1478
5/5
✓ Branch 41 → 42 taken 1 time.
✓ Branch 45 → 46 taken 1 time.
✓ Branch 46 → 47 taken 1 time.
✓ Branch 50 → 43 taken 1 time.
✓ Branch 50 → 51 taken 1 time.
2 qCWarning(lcCalDav) << err;
1479
1/1
✓ Branch 51 → 52 taken 1 time.
1 emit writeFailed(err);
1480 1 }
1481
2/2
✓ Branch 56 → 57 taken 4 times.
✓ Branch 56 → 59 taken 1 time.
5 });
1482 6 }
1483
1484 7 void CalDavClient::createTask(const QString &calendarPath,
1485 const CalendarTask &task) {
1486
1/1
✓ Branch 2 → 3 taken 7 times.
7 QString url = resourceUrl(calendarPath, task.uid);
1487
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 12 taken 6 times.
7 if (url.isEmpty()) {
1488
1/1
✓ Branch 8 → 9 taken 1 time.
1 emit writeFailed(QStringLiteral("Invalid calendar path for task creation"));
1489 1 return;
1490 }
1491
1492
2/2
✓ Branch 12 → 13 taken 6 times.
✓ Branch 13 → 14 taken 6 times.
6 QNetworkRequest request{QUrl(url)};
1493
3/3
✓ Branch 15 → 16 taken 6 times.
✓ Branch 16 → 17 taken 6 times.
✓ Branch 17 → 18 taken 6 times.
6 request.setRawHeader("Authorization", authHeader());
1494
3/3
✓ Branch 20 → 21 taken 6 times.
✓ Branch 21 → 22 taken 6 times.
✓ Branch 22 → 23 taken 6 times.
6 request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
1495
3/3
✓ Branch 25 → 26 taken 6 times.
✓ Branch 26 → 27 taken 6 times.
✓ Branch 27 → 28 taken 6 times.
6 request.setRawHeader("If-None-Match", "*");
1496
1497
1/1
✓ Branch 30 → 31 taken 6 times.
6 QByteArray body = taskToICalendar(task);
1498 #ifdef MAILJD_E2E_TESTING
1499
1/1
✓ Branch 34 → 35 taken 4 times.
4 reportE2eTransportPhase(QStringLiteral("PUT_TASK"));
1500 #endif
1501
2/2
✓ Branch 32 → 33 taken 2 times.
✓ Branch 38 → 39 taken 4 times.
6 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); // S106-058
1502
2/2
✓ Branch 34 → 35 taken 2 times.
✓ Branch 40 → 41 taken 4 times.
6 auto *reply = m_nam->put(request, body);
1503
2/2
✓ Branch 35 → 36 taken 2 times.
✓ Branch 41 → 42 taken 4 times.
6 DavNetworkLimits::apply(reply);
1504
2/2
✓ Branch 36 → 37 taken 2 times.
✓ Branch 42 → 43 taken 4 times.
6 trackReply(reply); // T-79.F1: lifetime follows request completion
1505
1506 6 connect(reply, &QNetworkReply::finished, this,
1507
2/6
✓ Branch 39 → 40 taken 2 times.
✓ Branch 45 → 46 taken 4 times.
✗ Branch 83 → 84 not taken.
✗ Branch 83 → 85 not taken.
✗ Branch 95 → 96 not taken.
✗ Branch 95 → 97 not taken.
12 [this, reply, task, url]() {
1508
1/1
✓ Branch 2 → 3 taken 6 times.
6 reply->deleteLater();
1509
1/1
✓ Branch 3 → 4 taken 6 times.
6 const QString limitError = DavNetworkLimits::failureReason(reply);
1510
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 8 taken 5 times.
6 if (!limitError.isEmpty()) {
1511
1/1
✓ Branch 6 → 7 taken 1 time.
1 emit writeFailed(limitError);
1512 1 return;
1513 }
1514
1/1
✓ Branch 8 → 9 taken 5 times.
5 int status = reply->attribute(
1515
1/1
✓ Branch 9 → 10 taken 5 times.
5 QNetworkRequest::HttpStatusCodeAttribute).toInt();
1516
4/4
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 2 times.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 57 taken 2 times.
5 if (status == 201 || status == 204) {
1517 3 CalendarTask saved = task;
1518
1/1
✓ Branch 18 → 19 taken 3 times.
6 QByteArray newEtag = reply->rawHeader("ETag");
1519
2/2
✓ Branch 20 → 21 taken 2 times.
✓ Branch 20 → 25 taken 1 time.
3 if (!newEtag.isEmpty())
1520
1/1
✓ Branch 21 → 22 taken 2 times.
2 saved.etag = QString::fromUtf8(newEtag);
1521
1/1
✓ Branch 29 → 30 taken 3 times.
6 const QByteArray location = reply->rawHeader("Location");
1522 3 const QString savedHref = location.isEmpty()
1523 4 ? url
1524
6/8
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 33 taken 2 times.
✓ Branch 33 → 34 taken 2 times.
✓ Branch 34 → 35 taken 2 times.
✓ Branch 35 → 36 taken 2 times.
✓ Branch 35 → 37 taken 1 time.
✗ Branch 97 → 98 not taken.
✗ Branch 97 → 99 not taken.
3 : resolveDavUrl(QString::fromUtf8(location));
1525
2/2
✓ Branch 38 → 39 taken 2 times.
✓ Branch 38 → 40 taken 1 time.
3 if (!savedHref.isEmpty())
1526 2 saved.resourceHref = savedHref;
1527
6/6
✓ Branch 40 → 41 taken 3 times.
✓ Branch 44 → 45 taken 3 times.
✓ Branch 45 → 46 taken 3 times.
✓ Branch 46 → 47 taken 3 times.
✓ Branch 50 → 42 taken 3 times.
✓ Branch 50 → 51 taken 3 times.
6 qCDebug(lcCalDav) << "Task created:" << saved.uid;
1528
1/1
✓ Branch 51 → 52 taken 3 times.
3 emit taskSaved(saved);
1529 3 } else {
1530 4 QString err = QStringLiteral("Create task failed (HTTP %1): %2")
1531
4/4
✓ Branch 61 → 62 taken 2 times.
✓ Branch 63 → 64 taken 2 times.
✓ Branch 64 → 65 taken 2 times.
✓ Branch 65 → 66 taken 2 times.
4 .arg(status).arg(QString::fromUtf8(reply->readAll()));
1532
5/5
✓ Branch 71 → 72 taken 2 times.
✓ Branch 75 → 76 taken 2 times.
✓ Branch 76 → 77 taken 2 times.
✓ Branch 80 → 73 taken 2 times.
✓ Branch 80 → 81 taken 2 times.
4 qCWarning(lcCalDav) << err;
1533
1/1
✓ Branch 81 → 82 taken 2 times.
2 emit writeFailed(err);
1534 2 }
1535
2/2
✓ Branch 86 → 87 taken 5 times.
✓ Branch 86 → 89 taken 1 time.
6 });
1536 7 }
1537
1538 6 void CalDavClient::updateTask(const CalendarTask &task) {
1539 6 QString url = resourceUrlForExistingResource(task.resourceHref,
1540 6 task.calendarPath,
1541
1/1
✓ Branch 2 → 3 taken 6 times.
6 task.uid);
1542
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 12 taken 5 times.
6 if (url.isEmpty()) {
1543
1/1
✓ Branch 8 → 9 taken 1 time.
1 emit writeFailed(QStringLiteral("Invalid calendar path for task update"));
1544 1 return;
1545 }
1546
1547
2/2
✓ Branch 12 → 13 taken 5 times.
✓ Branch 13 → 14 taken 5 times.
5 QNetworkRequest request{QUrl(url)};
1548
3/3
✓ Branch 15 → 16 taken 5 times.
✓ Branch 16 → 17 taken 5 times.
✓ Branch 17 → 18 taken 5 times.
5 request.setRawHeader("Authorization", authHeader());
1549
3/3
✓ Branch 20 → 21 taken 5 times.
✓ Branch 21 → 22 taken 5 times.
✓ Branch 22 → 23 taken 5 times.
5 request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
1550
2/3
✓ Branch 25 → 26 taken 5 times.
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 44 taken 5 times.
5 if (!isValidEtag(task.etag)) {
1551 ✗ qCWarning(lcCalDav) << "Rejecting task PUT without a valid RFC entity-tag";
1552 ✗ emit writeFailed(QStringLiteral("Invalid or missing server ETag — write "
1553 "refused to preserve concurrency protection"));
1554 ✗ return;
1555 }
1556
3/3
✓ Branch 44 → 45 taken 5 times.
✓ Branch 45 → 46 taken 5 times.
✓ Branch 46 → 47 taken 5 times.
5 request.setRawHeader("If-Match", task.etag.toUtf8());
1557
1558
1/1
✓ Branch 49 → 50 taken 5 times.
5 QByteArray body = taskToICalendar(task);
1559 #ifdef MAILJD_E2E_TESTING
1560
1/1
✓ Branch 53 → 54 taken 3 times.
3 reportE2eTransportPhase(QStringLiteral("PUT_TASK"));
1561 #endif
1562
2/2
✓ Branch 51 → 52 taken 2 times.
✓ Branch 57 → 58 taken 3 times.
5 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); // S106-058
1563
2/2
✓ Branch 53 → 54 taken 2 times.
✓ Branch 59 → 60 taken 3 times.
5 auto *reply = m_nam->put(request, body);
1564
2/2
✓ Branch 54 → 55 taken 2 times.
✓ Branch 60 → 61 taken 3 times.
5 DavNetworkLimits::apply(reply);
1565
2/2
✓ Branch 55 → 56 taken 2 times.
✓ Branch 61 → 62 taken 3 times.
5 trackReply(reply); // T-79.F1: lifetime follows request completion
1566
1567
2/2
✓ Branch 57 → 58 taken 2 times.
✓ Branch 63 → 64 taken 3 times.
5 connect(reply, &QNetworkReply::finished, this,
1568 10 [this, reply, task]() {
1569
1/1
✓ Branch 2 → 3 taken 5 times.
5 reply->deleteLater();
1570
1/1
✓ Branch 3 → 4 taken 5 times.
5 const QString limitError = DavNetworkLimits::failureReason(reply);
1571
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 8 taken 4 times.
5 if (!limitError.isEmpty()) {
1572
1/1
✓ Branch 6 → 7 taken 1 time.
1 emit writeFailed(limitError);
1573 1 return;
1574 }
1575
1/1
✓ Branch 8 → 9 taken 4 times.
4 int status = reply->attribute(
1576
1/1
✓ Branch 9 → 10 taken 4 times.
4 QNetworkRequest::HttpStatusCodeAttribute).toInt();
1577
4/4
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 1 time.
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 40 taken 1 time.
4 if (status == 200 || status == 204) {
1578 3 CalendarTask saved = task;
1579
1/1
✓ Branch 18 → 19 taken 3 times.
6 QByteArray newEtag = reply->rawHeader("ETag");
1580
2/2
✓ Branch 20 → 21 taken 2 times.
✓ Branch 20 → 25 taken 1 time.
3 if (!newEtag.isEmpty())
1581
1/1
✓ Branch 21 → 22 taken 2 times.
2 saved.etag = QString::fromUtf8(newEtag);
1582
6/6
✓ Branch 25 → 26 taken 3 times.
✓ Branch 29 → 30 taken 3 times.
✓ Branch 30 → 31 taken 3 times.
✓ Branch 31 → 32 taken 3 times.
✓ Branch 35 → 27 taken 3 times.
✓ Branch 35 → 36 taken 3 times.
6 qCDebug(lcCalDav) << "Task updated:" << saved.uid;
1583
1/1
✓ Branch 36 → 37 taken 3 times.
3 emit taskSaved(saved);
1584
1/2
✓ Branch 40 → 41 taken 1 time.
✗ Branch 40 → 48 not taken.
4 } else if (status == 412) {
1585
1/1
✓ Branch 44 → 45 taken 1 time.
2 emit writeFailed(QStringLiteral(
1586 "Conflict: task was modified on server (ETag mismatch)"));
1587 } else {
1588 ✗ QString err = QStringLiteral("Update task failed (HTTP %1): %2")
1589 ✗ .arg(status).arg(QString::fromUtf8(reply->readAll()));
1590 ✗ qCWarning(lcCalDav) << err;
1591 ✗ emit writeFailed(err);
1592 ✗ }
1593
2/2
✓ Branch 77 → 78 taken 4 times.
✓ Branch 77 → 80 taken 1 time.
5 });
1594 6 }
1595
1596 6 void CalDavClient::deleteTask(const CalendarTask &task) {
1597 6 QString url = resourceUrlForExistingResource(task.resourceHref,
1598 6 task.calendarPath,
1599
1/1
✓ Branch 2 → 3 taken 6 times.
6 task.uid);
1600
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 12 taken 5 times.
6 if (url.isEmpty()) {
1601
1/1
✓ Branch 8 → 9 taken 1 time.
1 emit writeFailed(QStringLiteral("Invalid calendar path for task deletion"));
1602 1 return;
1603 }
1604
1605
2/2
✓ Branch 12 → 13 taken 5 times.
✓ Branch 13 → 14 taken 5 times.
5 QNetworkRequest request{QUrl(url)};
1606
3/3
✓ Branch 15 → 16 taken 5 times.
✓ Branch 16 → 17 taken 5 times.
✓ Branch 17 → 18 taken 5 times.
5 request.setRawHeader("Authorization", authHeader());
1607
2/3
✓ Branch 20 → 21 taken 5 times.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 39 taken 5 times.
5 if (!isValidEtag(task.etag)) {
1608 ✗ qCWarning(lcCalDav) << "Rejecting task DELETE without a valid RFC entity-tag";
1609 ✗ emit writeFailed(QStringLiteral("Invalid or missing server ETag — delete "
1610 "refused to preserve concurrency protection"));
1611 ✗ return;
1612 }
1613
3/3
✓ Branch 39 → 40 taken 5 times.
✓ Branch 40 → 41 taken 5 times.
✓ Branch 41 → 42 taken 5 times.
5 request.setRawHeader("If-Match", task.etag.toUtf8());
1614
1615 #ifdef MAILJD_E2E_TESTING
1616
1/1
✓ Branch 47 → 48 taken 3 times.
3 reportE2eTransportPhase(QStringLiteral("DELETE_TASK"));
1617 #endif
1618
2/2
✓ Branch 45 → 46 taken 2 times.
✓ Branch 51 → 52 taken 3 times.
5 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); // S106-058
1619
2/2
✓ Branch 47 → 48 taken 2 times.
✓ Branch 53 → 54 taken 3 times.
5 auto *reply = m_nam->deleteResource(request);
1620
2/2
✓ Branch 48 → 49 taken 2 times.
✓ Branch 54 → 55 taken 3 times.
5 DavNetworkLimits::apply(reply);
1621
2/2
✓ Branch 49 → 50 taken 2 times.
✓ Branch 55 → 56 taken 3 times.
5 trackReply(reply); // T-79.F1: lifetime follows request completion
1622
1623
2/2
✓ Branch 51 → 52 taken 2 times.
✓ Branch 57 → 58 taken 3 times.
5 connect(reply, &QNetworkReply::finished, this,
1624 10 [this, reply, uid = task.uid]() {
1625
1/1
✓ Branch 2 → 3 taken 5 times.
5 reply->deleteLater();
1626
1/1
✓ Branch 3 → 4 taken 5 times.
5 const QString limitError = DavNetworkLimits::failureReason(reply);
1627
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 8 taken 4 times.
5 if (!limitError.isEmpty()) {
1628
1/1
✓ Branch 6 → 7 taken 1 time.
1 emit writeFailed(limitError);
1629 1 return;
1630 }
1631
1/1
✓ Branch 8 → 9 taken 4 times.
4 int status = reply->attribute(
1632
1/1
✓ Branch 9 → 10 taken 4 times.
4 QNetworkRequest::HttpStatusCodeAttribute).toInt();
1633
4/4
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 26 taken 2 times.
4 if (status == 200 || status == 204) {
1634
6/6
✓ Branch 13 → 14 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 18 → 19 taken 2 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 23 → 15 taken 2 times.
✓ Branch 23 → 24 taken 2 times.
4 qCDebug(lcCalDav) << "Task deleted:" << uid;
1635
1/1
✓ Branch 24 → 25 taken 2 times.
2 emit taskDeleted(uid);
1636
2/2
✓ Branch 26 → 27 taken 1 time.
✓ Branch 26 → 34 taken 1 time.
4 } else if (status == 412) {
1637
1/1
✓ Branch 30 → 31 taken 1 time.
2 emit writeFailed(QStringLiteral(
1638 "Conflict: task was modified on server (ETag mismatch)"));
1639 } else {
1640 2 QString err = QStringLiteral("Delete task failed (HTTP %1)")
1641
1/1
✓ Branch 38 → 39 taken 1 time.
1 .arg(status);
1642
5/5
✓ Branch 41 → 42 taken 1 time.
✓ Branch 45 → 46 taken 1 time.
✓ Branch 46 → 47 taken 1 time.
✓ Branch 50 → 43 taken 1 time.
✓ Branch 50 → 51 taken 1 time.
2 qCWarning(lcCalDav) << err;
1643
1/1
✓ Branch 51 → 52 taken 1 time.
1 emit writeFailed(err);
1644 1 }
1645
2/2
✓ Branch 56 → 57 taken 4 times.
✓ Branch 56 → 59 taken 1 time.
5 });
1646 6 }
1647