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: 100.0% 6 / 0 / 6
Functions: 100.0% 6 / 0 / 6
Branches: 57.1% 4 / 0 / 7

service/CalDavClient.h
Line Branch Exec Source
1 #pragma once
2
3 #include <optional>
4
5 #include <QHash>
6 #include <QList>
7 #include <QNetworkReply>
8 #include <QObject>
9 #include <QSet>
10 #include <QString>
11
12 #include "data/CalendarModels.h"
13 #include "service/DavResponse.h"
14
15 // Sprint 93 S93.6.2: one sync-collection (RFC 6578) result. `full` = the
16 // request carried no token, so the lists are the complete collection —
17 // unless `truncated` (Sprint 96 S96.3.0): the server answered with a 507
18 // for the collection and further pages follow from `syncToken`.
19 struct DavCollectionChanges {
20 bool full = false;
21 bool truncated = false; // RFC 6578 §3.6: more pages follow
22 QList<CalendarEvent> events; // rows of changed resources (resolved hrefs)
23 QList<CalendarTask> tasks;
24 QStringList removedHrefs; // resolved URLs of removed resources
25 QStringList failedHrefs; // members with an error / without calendar-data
26 QString syncToken; // the token to store after applying
27 QString requestToken; // the token this page was requested with
28 };
29
4/7
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 14 not taken.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✗ Branch 14 → 15 not taken.
1 Q_DECLARE_METATYPE(DavCollectionChanges)
30
31 class QNetworkAccessManager;
32 class QNetworkReply;
33 class QTimer;
34
35 // ═══════════════════════════════════════════════════════
36 // CalDAV client for calendar discovery and sync (Sprint 32 – T-331)
37 // Architecture mirrors CardDavClient (Sprint 16).
38 // ═══════════════════════════════════════════════════════
39
40 class CalDavClient : public QObject {
41 Q_OBJECT
42 public:
43 CalDavClient(const QString &serverUrl, const QString &username,
44 const QString &password, QObject *parent = nullptr);
45
46 /// Discover available calendars via PROPFIND
47 void discoverCalendars();
48
49 /// Sync all events (VEVENT) from a calendar via REPORT
50 void syncCalendar(const QString &calendarPath);
51
52 /// Sync all tasks (VTODO) from a calendar via REPORT
53 void syncTasks(const QString &calendarPath);
54
55 // ── Sprint 39 – T-530: Write API ──────────────────────
56
57 /// Create a new event on the server (HTTP PUT + If-None-Match)
58 void createEvent(const QString &calendarPath, const CalendarEvent &event);
59 /// Update an existing event (HTTP PUT + If-Match: etag)
60 void updateEvent(const CalendarEvent &event);
61 /// Delete an event (HTTP DELETE + If-Match: etag)
62 void deleteEvent(const CalendarEvent &event);
63 // Sprint 93 S93.5.8: writes the collection's APPLE:calendar-color
64 // (PROPPATCH). calendarColorSaved on success, writeFailed otherwise.
65 void setCalendarColor(const QString &calendarPath, const QString &color);
66
67 /// Create a new task on the server
68 void createTask(const QString &calendarPath, const CalendarTask &task);
69 /// Update an existing task
70 void updateTask(const CalendarTask &task);
71 /// Delete a task
72 void deleteTask(const CalendarTask &task);
73
74 // ── Sprint 93 S93.1.6: raw resource requests for the write outbox ──
75 // Each returns a request id echoed in resourceResponse(). `ifMatch` is a
76 // validated entity-tag (invalid ones are refused locally with status 0);
77 // `createOnly` sends If-None-Match: * instead.
78 quint64 putResource(const QString &url, const QByteArray &body, const QString &ifMatch,
79 bool createOnly);
80 quint64 deleteResource(const QString &url, const QString &ifMatch);
81 quint64 getResource(const QString &url);
82 // Sprint 108 S108.8.5 (RFC 6638 §5): a VFREEBUSY request POSTed to the
83 // schedule outbox; the schedule-response arrives as resourceResponse().
84 quint64 postFreeBusy(const QString &outboxUrl, const QByteArray &request);
85 // S108.8.4: OPTIONS on a URL; resourceResponse().davHeader carries the
86 // compliance classes (calendar-auto-schedule).
87 quint64 options(const QString &url);
88 // The URL of an event resource: its stored href, else <calendar>/<uid>.ics.
89 QString eventResourceUrl(const QString &resourceHref, const QString &calendarPath,
90 const QString &uid) const;
91
92 /// Serialize a CalendarEvent to iCalendar format (RFC 5545 VEVENT)
93 static QByteArray eventToICalendar(const CalendarEvent &event);
94 // Q11: a master with recurrence exceptions shares one DAV resource;
95 // pushes must serialize the whole series or the PUT would drop half of
96 // it on the server.
97 static QByteArray seriesToICalendar(const CalendarEvent &master,
98 const QList<CalendarEvent> &exceptions);
99 /// Serialize a CalendarTask to iCalendar format (RFC 5545 VTODO)
100 static QByteArray taskToICalendar(const CalendarTask &task);
101
102 /// Test seam: replace the network access manager (takes ownership). Lets
103 /// unit tests inject a fake NAM that returns canned replies for the write API.
104 void setNetworkAccessManager(QNetworkAccessManager *nam);
105
106 // T-79.F1/H8: number of network replies still in flight. The client
107 // emits allRequestsFinished() when it drops to 0 — callers connect that
108 // to deleteLater() instead of a fixed-lifetime timer.
109 int pendingReplies() const { return m_pendingReplies; }
110 // Sprint 93 S93.6.3: the transport error behind the last syncFailed()
111 // (authentication, host, TLS, timeout …) so the sync status can tell a
112 // rejected password from an unreachable server without parsing the
113 // (possibly translated) error text. NoError for data/protocol errors.
114 8 QNetworkReply::NetworkError lastNetworkError() const { return m_lastNetworkError; }
115 // Sprint 93 S93.5.2: the principal announced an RFC 6638
116 // schedule-outbox-URL (the server sends iMIP replies itself). Unset when
117 // discovery did not read the principal (configured calendar home).
118 78 std::optional<bool> serverScheduling() const { return m_serverScheduling; }
119 // Sprint 108: the resolved schedule-outbox URL and the principal's
120 // calendar-user-address-set (lower case) of the last discovery.
121 1 QString scheduleOutboxUrl() const { return m_scheduleOutboxUrl; }
122 1 QStringList calendarUserAddresses() const { return m_calendarUserAddresses; }
123
124 // Sprint 93 S93.6.2: RFC 6578 sync-collection. An empty token returns
125 // the whole collection (changes.full); otherwise only the resources
126 // changed or removed since the token. Results: collectionSynced(), or
127 // collectionSyncRejected() when the server does not support the report
128 // (invalidToken = false) or no longer accepts the token (true).
129 void syncCollection(const QString &calendarPath, const QString &syncToken);
130
131 // Sprint 96 S96.3.7: PUT/DELETE resource requests carry
132 // `Push-Dont-Notify: "<registrationUrl>"` (never "*") while set.
133 142 void setPushDontNotify(const QString &registrationUrl) { m_pushDontNotify = registrationUrl; }
134
135 signals:
136 // T-79.F1/H8: the last outstanding reply finished (or the inactivity
137 // watchdog gave up). Safe point to deleteLater() the client.
138 void allRequestsFinished();
139 // F-54 (SPRINT-104): every finished reply re-emits this, so an account
140 // run can restart its fixed budget and long batched downloads survive.
141 void requestAnswered();
142
143 void calendarsDiscovered(const QList<CalendarInfo> &calendars);
144 // `complete` = false when the server truncated the result (507) or a
145 // member came without calendar-data (Sprint 96 S96.3.0): store the rows,
146 // but prune nothing and commit no CTag.
147 void eventsSynced(const QString &calendarPath,
148 const QList<CalendarEvent> &events, bool complete);
149 void tasksSynced(const QString &calendarPath,
150 const QList<CalendarTask> &tasks, bool complete);
151 void syncFailed(const QString &error);
152 void collectionSynced(const QString &calendarPath, const DavCollectionChanges &changes);
153 void collectionSyncRejected(const QString &calendarPath, bool invalidToken);
154
155 // T-530 write result signals
156 void eventSaved(const CalendarEvent &event);
157 void eventDeleted(const QString &uid);
158 void calendarColorSaved(const QString &calendarPath);
159 void taskSaved(const CalendarTask &task);
160 void taskDeleted(const QString &uid);
161 void writeFailed(const QString &error);
162 void resourceResponse(const DavResponse &response);
163 #ifdef MAILJD_E2E_TESTING
164 // Metadata only: operation class and an opaque per-client request token.
165 void e2eTransportPhase(const QString &phase, quint64 requestToken);
166 #endif
167
168 private:
169 friend class TestCalDav;
170 friend class TestSprint39;
171 friend class TestSprint79ICal;
172 friend class TestSprint79Services;
173
174 // T-79.F1/H8: register a dispatched reply for lifetime tracking.
175 // Call AFTER connecting the reply's business handler, so follow-up
176 // requests started from result signals are counted before the zero
177 // check runs.
178 void trackReply(QNetworkReply *reply);
179 void onInactivityTimeout();
180 quint64 sendResourceRequest(const QByteArray &method, const QString &url, const QByteArray &body,
181 const QList<QPair<QByteArray, QByteArray>> &headers);
182 quint64 m_nextResourceRequest = 0;
183
184 void onDiscoverReply(QNetworkReply *reply);
185 enum class DiscoveryStep {
186 ConfiguredCollection,
187 WellKnown,
188 Principal,
189 CalendarHome,
190 NextcloudFallback
191 };
192 void requestDiscovery(const QString &url, DiscoveryStep step);
193 void advanceDiscovery(const QString &reason);
194 void onSyncEventsReply(QNetworkReply *reply, const QString &calendarPath);
195 void onSyncTasksReply(QNetworkReply *reply, const QString &calendarPath);
196 // F-24 (SPRINT-104): fetch members that came back without data.
197 void fetchFailedMembers(const QString &calendarPath, DavCollectionChanges changes);
198 void fetchFailedMemberBatch(const QString &calendarPath, DavCollectionChanges changes,
199 QStringList pending);
200 // One calendar-multiget carries at most this many hrefs (S106-057).
201 static constexpr int MaxMultigetMembers = 100;
202 void onSyncCollectionReply(QNetworkReply *reply, const QString &calendarPath,
203 const QString &requestToken);
204 // F-54 (SPRINT-104): when a token-less full download breaks the DAV
205 // response bounds (10 MiB / 30 s), the client falls back to what the
206 // address books do — an ETag-only listing, then calendar-multiget in
207 // batches that halve on too-large answers. Every response stays bounded.
208 void requestEtagListing(const QString &calendarPath);
209 void onEtagListingReply(QNetworkReply *reply, const QString &calendarPath);
210 void fetchNextEnumerationBatch(const QString &calendarPath);
211 // Shared wire form of RFC 4791 §7.9 calendar-multiget for the resolved
212 // `hrefs` (F-24 fallback and F-54 batches). Returns nullptr when the
213 // collection URL or a member path is unusable.
214 QNetworkReply *sendCalendarMultiget(const QString &calendarPath, const QStringList &hrefs);
215 bool queryComplete(const QByteArray &xml, const QString &calendarPath) const;
216 QStringList failedMembers(const QStringList &hrefs, const QString &calendarPath) const;
217 // S106-704: the `requested` members a calendar-multiget reply neither
218 // listed, refused nor removed (resolved hrefs). They stay failed.
219 QStringList unansweredMembers(const QStringList &requested, const QStringList &listed,
220 const QStringList &failed, const QStringList &removed,
221 const QString &calendarPath) const;
222
223 /// Build the CalDAV resource URL for a new resource path + UID
224 QString resourceUrl(const QString &calendarPath, const QString &uid) const;
225 QString resourceUrlForExistingResource(const QString &resourceHref,
226 const QString &calendarPath,
227 const QString &uid) const;
228 QString resolveDavUrl(const QString &href) const;
229
230 /// Parse iCal text into CalendarEvent list
231 static QList<CalendarEvent> parseICalEvents(const QByteArray &xmlData);
232 static bool parseICalEvents(const QByteArray &xmlData,
233 QList<CalendarEvent> *events,
234 QString *error);
235
236 /// Parse iCal text into CalendarTask list
237 static QList<CalendarTask> parseICalTasks(const QByteArray &xmlData);
238 static bool parseICalTasks(const QByteArray &xmlData,
239 QList<CalendarTask> *tasks,
240 QString *error);
241
242 QByteArray authHeader() const;
243 #ifdef MAILJD_E2E_TESTING
244 void reportE2eTransportPhase(const QString &operation);
245 #endif
246
247 // Validate the complete RFC entity-tag grammar before setting If-Match.
248 static bool isValidEtag(const QString &etag);
249
250 QNetworkAccessManager *m_nam = nullptr;
251 QString m_serverUrl;
252 QString m_username;
253 QString m_password;
254 DiscoveryStep m_discoveryStep = DiscoveryStep::ConfiguredCollection;
255 int m_discoveryRedirects = 0; // same-origin PROPFIND redirects followed in this discovery
256 bool m_pushPropertiesRejected = false;
257 QString m_pushDontNotify; // S96.1.2: retry discovery without push properties
258 QString m_discoveryError;
259 QNetworkReply::NetworkError m_lastNetworkError = QNetworkReply::NoError;
260 std::optional<bool> m_serverScheduling;
261 QString m_scheduleOutboxUrl;
262 QStringList m_calendarUserAddresses;
263
264 // T-79.F1/H8: outstanding-reply tracking + inactivity watchdog.
265 // The watchdog restarts on every request/response; a stall longer than
266 // kInactivityTimeoutMs fails the outstanding work (syncFailed +
267 // writeFailed) and releases the client via allRequestsFinished().
268 int m_pendingReplies = 0;
269 QSet<QNetworkReply *> m_trackedReplies;
270 QTimer *m_inactivityTimer = nullptr;
271
272 // F-54 (SPRINT-104): client-side batching state of one collection's
273 // listing-based enumeration. Pending hrefs are grouped lazily into
274 // `batches` of `batchSize`; a too-large answer splits its batch in half
275 // (ContactBookSync's pattern) and halves the size of future batches. A
276 // single resource above the limit fails as a member (failedHrefs), never
277 // as an endless split.
278 struct EnumerationFetch {
279 QString syncToken;
280 QStringList pending; // resolved hrefs not yet grouped
281 QList<QStringList> batches; // ready groups; halves are prepended
282 QStringList failedHrefs; // members that failed their multiget
283 QStringList removedHrefs;
284 QList<CalendarEvent> events;
285 QList<CalendarTask> tasks;
286 int batchSize = kEnumerationBatchSize;
287 };
288 QHash<QString, EnumerationFetch> m_enumerations;
289 QSet<QString> m_listingFallback; // collections already retried via listing
290 #ifdef MAILJD_E2E_TESTING
291 quint64 m_e2eRequestToken = 0;
292 #endif
293 static constexpr int kInactivityTimeoutMs = 60000;
294 static constexpr int kEnumerationBatchSize = 50;
295 };
296