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% 2 / 0 / 2
Functions: 100.0% 2 / 0 / 2
Branches: -% 0 / 0 / 0

app/core/CalendarOutbox.h
Line Branch Exec Source
1 #pragma once
2
3 #include <QHash>
4 #include <QObject>
5 #include <QSet>
6 #include <QString>
7 #include <QTimer>
8 #include <functional>
9 #include <optional>
10
11 #include "data/CalendarModels.h"
12
13 class CalendarStore;
14 class CalDavClient;
15 struct DavResponse;
16
17 // Event write-back through a persistent outbox (Sprint 93 S93.1.6,
18 // CAL-CONFLICT). Replaces the rollback-on-failure contract for events:
19 //
20 // 1. Local first: the store row changes at once and the DAV resource
21 // (account, calendar, UID) gets one coalesced outbox entry ("put" or
22 // "delete") that remembers the server ETag the change is based on.
23 // 2. flush() pushes due entries: PUT with If-Match (If-None-Match for new
24 // resources) of the current local rows patched onto the stored
25 // resource, or DELETE with If-Match.
26 // 3. Success adopts the pushed body as the new base and removes the entry
27 // (unless a newer local change bumped its revision meanwhile).
28 // 4. Transport failures and 5xx keep the edit and retry with backoff
29 // (30 s … 30 min) on the retry timer, the sync timer and reconnects.
30 // 5. 412: the server version is fetched and merged on property level
31 // (ICalMerge). Non-overlapping changes are pushed automatically; an
32 // overlap marks the entry "conflict" with the server version kept for
33 // resolveConflict() ("mine" / "server" / "both").
34 //
35 // Display: rows of a resource with an entry carry syncState "pending" or
36 // "conflict". Tasks share this contract (Sprint 94).
37 class CalendarOutbox : public QObject {
38 275 Q_OBJECT
39 public:
40 struct Endpoint {
41 QString serverUrl;
42 QString username;
43 QByteArray password;
44 };
45 struct Deps {
46 CalendarStore *store = nullptr;
47 std::function<std::optional<Endpoint>(const QString &accountId)> endpoint;
48 // Whether the account has a server configured, without reading its
49 // password (the keyring read may run a nested event loop). Empty: the
50 // endpoint answers.
51 std::function<bool(const QString &accountId)> hasEndpoint;
52 std::function<CalDavClient *(const QString &url, const QString &user, const QString &password)> makeClient;
53 std::function<bool()> networkAllowed;
54 std::function<qint64()> now; // epoch seconds (tests inject a clock)
55 // Sprint 96 S96.3.7: the own push registration URL of a collection
56 // ("" = none); sent as Push-Dont-Notify with every write so MailJD is
57 // not notified about its own change (other devices still are).
58 std::function<QString(const QString &accountId, const QString &calendarPath)> pushDontNotify;
59 };
60 explicit CalendarOutbox(Deps deps, QObject *parent = nullptr);
61
62 // Writes one row (master or exception) locally and queues its resource.
63 bool saveEvent(const CalendarEvent &event);
64 bool saveTask(const CalendarTask &task);
65 bool removeTask(const QString &uid, const QString &accountId, const QString &calendarPath);
66 bool moveTask(const CalendarTask &source, const CalendarTask &target);
67 // Removes the series (all rows of the UID) and queues the deletion.
68 bool removeSeries(const QString &uid, const QString &accountId, const QString &calendarPath);
69 // Replaces every row of a resource with `rows` (undo; empty = remove).
70 bool restoreSeries(const QString &uid, const QString &accountId, const QString &calendarPath,
71 const QList<CalendarEvent> &rows);
72 // F-52 (SPRINT-104): occurrence-scoped writes through the series engine.
73 // An invitation that carries only RECURRENCE-ID rows merges them into
74 // the stored series (the master row and its other exceptions survive);
75 // a cancellation removes exactly that occurrence and adds its date to
76 // the master's EXDATE list (RFC 5545). Both return false when no master
77 // is stored to merge into.
78 bool applyOccurrenceUpdate(const QString &uid, const QString &accountId,
79 const QString &calendarPath,
80 const QList<CalendarEvent> &exceptionRows);
81 bool applyOccurrenceCancel(const QString &uid, const QString &accountId,
82 const QString &calendarPath,
83 const QDateTime &recurrenceId);
84 // choice: "mine" (overwrite the server), "server" (take the server
85 // version), "both" (take the server version and keep mine as a copy).
86 bool resolveConflict(const QString &uid, const QString &accountId, const QString &calendarPath,
87 const QString &choice);
88 // F108-12: the ways out of a write the server refuses ("failed", or
89 // pending with retries): try it again now, or drop the local change and
90 // take the server's version (none: the item goes) — the dropped version
91 // is kept in the conflict history.
92 bool retryWrite(const QString &uid, const QString &accountId, const QString &calendarPath);
93 bool discardLocalChange(const QString &uid, const QString &accountId, const QString &calendarPath);
94 // Pushes due entries; `ignoreBackoff` also pushes entries waiting for
95 // their next retry ("Retry now").
96 void flush(bool ignoreBackoff = false);
97 // The flush after a local write, one event-loop turn later and coalesced.
98 // A local write runs inside UI handlers (a task row's check); the
99 // credential read of a flush may spin a nested event loop that would
100 // deliver queued model updates under them and destroy the row whose
101 // handler is running (user report 2026-10-13: a double-click aborted).
102 void requestFlush();
103 42 void beginBatch() { ++m_batchDepth; }
104 void endBatch();
105 bool hasEntry(const QString &uid, const QString &accountId, const QString &calendarPath) const;
106 static qint64 backoffSeconds(int attempts);
107
108 // ─── Sprint 107: the conflict resolver (CalendarOutboxResolve.cpp) ───
109 // The three versions of a conflicted resource for the resolver's plan.
110 struct ConflictInputs {
111 bool valid = false;
112 QString base; // the last server version (a moved task: its source's)
113 QString local; // what this computer would push now
114 QString server; // the server version ("" = deleted there)
115 QString serverEtag; // identity of the shown server version (D-9)
116 QString componentType; // "VEVENT" or "VTODO"
117 QString operation; // "put" or "delete" (deleted here)
118 QStringList fields; // CalendarOutboxEntry::conflictFields
119 QString movedFrom; // D-22: the calendar path the task was moved from
120 };
121 ConflictInputs conflictInputs(const QString &uid, const QString &accountId, const QString &calendarPath) const;
122 // S107.3.1/3.2: writes a resolution. The resolved resource replaces the
123 // local rows, the server version becomes the merge base and the entry is
124 // pushed against `serverEtag`. False (nothing written) when the conflict
125 // changed meanwhile (another server version, D-9) or the text cannot be
126 // read. `keptSide` ("combined", "local", "server") goes to the history.
127 bool resolveWith(const QString &uid, const QString &accountId, const QString &calendarPath,
128 const QString &resolved, const QString &serverEtag, const QString &keptSide);
129 // S107.3.6: fetches the server version again; a newer one replaces the
130 // shown one (or merges cleanly). Emits storeChanged.
131 void refreshConflict(const QString &uid, const QString &accountId, const QString &calendarPath);
132 // S107.3.7 (D-21): restores the version a resolution replaced as a new
133 // local edit (outbox put) and removes the history entry.
134 bool restoreFromHistory(qint64 historyId);
135
136 signals:
137 void storeChanged();
138 void statusMessage(const QString &message);
139
140 private:
141 void flushOnce(bool ignoreBackoff);
142 void push(const CalendarOutboxEntry &entry, const Endpoint &endpoint);
143 void onPutFinished(const CalendarOutboxEntry &entry, const QByteArray &body, const DavResponse &response,
144 const Endpoint &endpoint);
145 void onDeleteFinished(const CalendarOutboxEntry &entry, const DavResponse &response,
146 const Endpoint &endpoint);
147 void fetchServerVersion(const CalendarOutboxEntry &entry, const QString &url, const QByteArray &ours,
148 const Endpoint &endpoint);
149 // F108-12: a 404 on writing a resource the server had — deleted, or a
150 // write the server refuses at that address (Nextcloud in a shared
151 // calendar)? A GET decides.
152 void confirmDeletedOnServer(const CalendarOutboxEntry &entry, const QString &url, const Endpoint &endpoint);
153 void onServerVersion(const CalendarOutboxEntry &entry, const QByteArray &ours, const DavResponse &response);
154 void mergeServerVersion(const CalendarOutboxEntry &entry, const QByteArray &ours, const QString &theirs,
155 const QString &etag);
156 QByteArray localBody(const CalendarOutboxEntry &entry) const;
157 // D-22: the source DELETE of a task move that met a changed source.
158 bool isMoveSource(const CalendarOutboxEntry &entry) const;
159 void mergeMovedSource(const CalendarOutboxEntry &entry, const QString &theirs, const QString &etag);
160 bool replaceRows(const CalendarOutboxEntry &entry, const QString &ical, const QString &etag);
161 void recordHistory(const CalendarOutboxEntry &entry, const QString &keptSide, const QString &losingText);
162 bool keepBothTask(const CalendarOutboxEntry &entry);
163 void retryLater(const CalendarOutboxEntry &entry, const QString &error, bool permanent = false);
164 void markConflict(const CalendarOutboxEntry &entry, const QString &serverIcal, const QString &serverEtag,
165 const QStringList &fields);
166 bool adoptServerVersion(const CalendarOutboxEntry &entry, const QString &ical, const QString &etag);
167 void finishInFlight(const CalendarOutboxEntry &entry);
168 void scheduleRetry();
169 CalDavClient *clientFor(const Endpoint &endpoint);
170 static QString key(const QString &accountId, const QString &calendarPath, const QString &uid);
171
172 void publishChange();
173 int m_batchDepth = 0;
174 bool m_flushing = false; // S106-159: no re-entrant flush
175 bool m_flushAgain = false;
176 bool m_flushAgainIgnoringBackoff = false;
177 bool m_batchChanged = false;
178 bool m_flushRequested = false;
179 Deps m_deps;
180 QSet<QString> m_inFlight;
181 QSet<QString> m_refreshAgain; // S107.3.6: a check asked for while one runs
182 QTimer m_retryTimer;
183 };
184