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: 89.3% 576 / 0 / 645
Functions: 90.2% 46 / 0 / 51
Branches: 73.5% 745 / 0 / 1014

app/core/ContactOutbox.cpp
Line Branch Exec Source
1 #include "ContactOutbox.h"
2
3 #include "data/ContactQueries.h"
4 #include "data/ContactStore.h"
5 #include "data/VCardCodec.h"
6 #include "data/VCardMerge.h"
7 #include "service/CardDavClient.h"
8 #include "util/SecureUtil.h"
9
10 #include <QDateTime>
11 #include <QLoggingCategory>
12 #include <QPointer>
13 #include <QUuid>
14
15
4/7
✓ Branch 2 → 3 taken 11 times.
✓ Branch 2 → 8 taken 469 times.
✓ Branch 4 → 5 taken 11 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 11 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
480 Q_LOGGING_CATEGORY(lcContactOutbox, "mailjd.contacts.outbox")
16
17 namespace {
18
19 // Handles exactly one response of `request` on `client`, then disconnects.
20 template <typename Handler>
21 147 void onResponse(CardDavClient *client, quint64 request, QObject *context, Handler handler) {
22
5/6
void (anonymous namespace)::onResponse<ContactOutbox::onPutFinished(ContactOutboxEntry const&, QString const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#2}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::onPutFinished(ContactOutboxEntry const&, QString const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#2}):
✗ Branch 2 → 3 not taken.
void (anonymous namespace)::onResponse<ContactOutbox::onPutFinished(ContactOutboxEntry const&, QString const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#1}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::onPutFinished(ContactOutboxEntry const&, QString const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#1}):
✓ Branch 2 → 3 taken 1 time.
void (anonymous namespace)::onResponse<ContactOutbox::onDeleteFinished(ContactOutboxEntry const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#1}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::onDeleteFinished(ContactOutboxEntry const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#1}):
✓ Branch 2 → 3 taken 8 times.
void (anonymous namespace)::onResponse<ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#2}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#2}):
✓ Branch 2 → 3 taken 90 times.
void (anonymous namespace)::onResponse<ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#1}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#1}):
✓ Branch 2 → 3 taken 17 times.
void (anonymous namespace)::onResponse<ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#2}::operator()(DavResponse const&) const::{lambda(DavResponse const&)#1}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#2}::operator()(DavResponse const&) const::{lambda(DavResponse const&)#1}):
✓ Branch 2 → 3 taken 31 times.
147 auto connection = std::make_shared<QMetaObject::Connection>();
23 147 *connection = QObject::connect(client, &CardDavClient::resourceResponse, context,
24
5/18
void (anonymous namespace)::onResponse<ContactOutbox::onPutFinished(ContactOutboxEntry const&, QString const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#2}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::onPutFinished(ContactOutboxEntry const&, QString const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#2}):
✗ Branch 5 → 6 not taken.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
void (anonymous namespace)::onResponse<ContactOutbox::onPutFinished(ContactOutboxEntry const&, QString const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#1}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::onPutFinished(ContactOutboxEntry const&, QString const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#1}):
✓ Branch 5 → 6 taken 1 time.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
void (anonymous namespace)::onResponse<ContactOutbox::onDeleteFinished(ContactOutboxEntry const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#1}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::onDeleteFinished(ContactOutboxEntry const&, DavResponse const&, CardDavClient*)::{lambda(DavResponse const&)#1}):
✓ Branch 5 → 6 taken 8 times.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
void (anonymous namespace)::onResponse<ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#2}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#2}):
✓ Branch 5 → 6 taken 90 times.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
void (anonymous namespace)::onResponse<ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#1}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#1}):
✓ Branch 5 → 6 taken 17 times.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
void (anonymous namespace)::onResponse<ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#2}::operator()(DavResponse const&) const::{lambda(DavResponse const&)#1}>(CardDavClient*, unsigned long long, QObject*, ContactOutbox::push(ContactOutboxEntry const&, CardDavClient*)::{lambda(DavResponse const&)#2}::operator()(DavResponse const&) const::{lambda(DavResponse const&)#1}):
✓ Branch 5 → 6 taken 31 times.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
437 [connection, request, handler](const DavResponse &response) {
25
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 143 times.
143 if (response.request != request)
26 ✗ return;
27 143 QObject::disconnect(*connection);
28 143 handler(response);
29 });
30 147 }
31
32 // S106-160 (INV-12): 403 (a read-only collection) and 507 (no storage)
33 // are permanent — F-19's bounded retries end in the failed state; they
34 // used to back off forever as transient errors.
35 16 bool retryableStatus(int status) {
36
6/10
✓ Branch 2 → 3 taken 16 times.
✗ Branch 2 → 8 not taken.
✓ Branch 3 → 4 taken 16 times.
✗ Branch 3 → 8 not taken.
✓ Branch 4 → 5 taken 16 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 16 times.
✗ Branch 5 → 8 not taken.
✓ Branch 6 → 7 taken 6 times.
✓ Branch 6 → 9 taken 10 times.
22 return status == 0 || status == 401 || status == 408 || status == 429 ||
37
2/2
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 9 taken 3 times.
22 (status >= 500 && status != 507);
38 }
39
40 // Technical text for the log and "Details" only (G16).
41 19 QString responseError(const DavResponse &response) {
42
2/2
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 5 taken 16 times.
19 if (!response.error.isEmpty())
43 3 return response.error;
44
3/3
✓ Branch 9 → 10 taken 16 times.
✓ Branch 10 → 11 taken 16 times.
✓ Branch 12 → 13 taken 16 times.
48 return QStringLiteral("%1 answered HTTP %2").arg(QString::fromLatin1(response.method)).arg(response.status);
45 }
46
47 // The card with another UID (conflict "keep both").
48 2 QString withUid(const QString &raw, const QString &uid) {
49 2 ICal::Component card;
50 2 QString error;
51 2 int skipped = 0;
52 // S106-152: a card whose parse dropped lines is not copied — the copy
53 // would be uploaded without them.
54
4/7
✓ Branch 2 → 3 taken 2 times.
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 2 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 2 times.
2 if (!VCardCodec::parseTree(raw, &card, &error, &skipped) || skipped > 0)
55 ✗ return {};
56
2/2
✓ Branch 9 → 10 taken 2 times.
✓ Branch 13 → 14 taken 2 times.
4 card.setPropertyValue(QStringLiteral("UID"), ICal::escapeText(uid));
57
1/1
✓ Branch 17 → 18 taken 2 times.
2 return ICal::serialize(card);
58 2 }
59
60 } // namespace
61
62
1/1
✓ Branch 9 → 10 taken 956 times.
956 ContactOutbox::ContactOutbox(Deps deps, QObject *parent) : QObject(parent), m_deps(std::move(deps)) {
63
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 14 taken 956 times.
956 if (!m_deps.now)
64 ✗ m_deps.now = [] { return QDateTime::currentSecsSinceEpoch(); };
65
1/1
✓ Branch 14 → 15 taken 956 times.
956 m_retryTimer.setSingleShot(true);
66
1/1
✓ Branch 15 → 16 taken 956 times.
956 connect(&m_retryTimer, &QTimer::timeout, this, [this] { flush(false); });
67 956 }
68
69 1561 QString ContactOutbox::key(qint64 bookId, const QString &uid) { return ContactQueries::cardKey(bookId, uid); }
70
71 // F-29 (SPRINT-104): permanent rejections stop after this many attempts.
72 constexpr int kMaxPermanentAttempts = 3;
73
74 4 qint64 ContactOutbox::backoffSeconds(int attempts) {
75 static const qint64 steps[] = {30, 60, 120, 300, 600, 1800};
76
1/1
✓ Branch 4 → 5 taken 4 times.
4 const int index = qBound(0, attempts - 1, int(std::size(steps)) - 1);
77 4 return steps[index];
78 }
79
80 456 void ContactOutbox::changed(const QStringList &keys) {
81
2/2
✓ Branch 2 → 3 taken 35 times.
✓ Branch 2 → 5 taken 421 times.
456 if (m_batchDepth > 0) {
82 35 m_batchKeys += keys;
83 35 return;
84 }
85 421 emit cardsChanged(keys);
86 }
87
88 20 void ContactOutbox::endBatch() {
89
5/6
✓ Branch 2 → 3 taken 20 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 19 times.
✓ Branch 3 → 5 taken 1 time.
✓ Branch 6 → 7 taken 19 times.
✓ Branch 6 → 12 taken 1 time.
20 if (m_batchDepth > 0 && --m_batchDepth == 0) {
90
2/2
✓ Branch 8 → 9 taken 18 times.
✓ Branch 8 → 10 taken 1 time.
19 if (!m_batchKeys.isEmpty())
91 18 emit cardsChanged(m_batchKeys);
92 19 m_batchKeys.clear();
93 19 flush(false);
94 }
95 20 }
96
97 255 std::optional<AddressBookInfo> ContactOutbox::writableBook(qint64 bookId, QString *error) const {
98
1/1
✓ Branch 2 → 3 taken 255 times.
255 const auto book = m_deps.store->book(bookId);
99
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 11 taken 254 times.
255 if (!book) {
100
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 10 not taken.
1 if (error)
101
1/1
✓ Branch 6 → 7 taken 1 time.
1 *error = tr("The address book is no longer available.");
102 1 return std::nullopt;
103 }
104
2/2
✓ Branch 12 → 13 taken 6 times.
✓ Branch 12 → 19 taken 248 times.
254 if (book->readOnly) {
105
1/2
✓ Branch 13 → 14 taken 6 times.
✗ Branch 13 → 18 not taken.
6 if (error)
106
1/1
✓ Branch 14 → 15 taken 6 times.
6 *error = tr("This address book is read-only.");
107 6 return std::nullopt;
108 }
109
1/1
✓ Branch 19 → 20 taken 248 times.
248 return book;
110 255 }
111
112 // ─── local changes ─────────────────────────────────────────
113
114 187 QString ContactOutbox::saveCard(qint64 bookId, const QString &uid, const ContactCard &edited, QString *error) {
115
1/1
✓ Branch 2 → 3 taken 187 times.
187 const auto book = writableBook(bookId, error);
116
1/1
✓ Branch 3 → 4 taken 187 times.
187 const auto stored = m_deps.store->card(bookId, uid);
117
6/6
✓ Branch 5 → 6 taken 183 times.
✓ Branch 5 → 8 taken 4 times.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 182 times.
✓ Branch 10 → 11 taken 5 times.
✓ Branch 10 → 22 taken 182 times.
187 if (!book || !stored) {
118
5/6
✓ Branch 11 → 12 taken 5 times.
✗ Branch 11 → 15 not taken.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 15 taken 4 times.
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 21 taken 4 times.
5 if (error && book)
119
1/1
✓ Branch 17 → 18 taken 1 time.
1 *error = tr("The contact no longer exists.");
120 5 return {};
121 }
122
2/2
✓ Branch 23 → 24 taken 1 time.
✓ Branch 23 → 30 taken 181 times.
182 if (!stored->writable) {
123
1/2
✓ Branch 24 → 25 taken 1 time.
✗ Branch 24 → 29 not taken.
1 if (error)
124
1/1
✓ Branch 25 → 26 taken 1 time.
1 *error = tr("This contact cannot be changed in MailJD. Edit it where it was created.");
125 1 return {};
126 }
127 181 QString codecError;
128
1/1
✓ Branch 32 → 33 taken 181 times.
181 const QString raw = VCardCodec::patch(stored->raw, edited, &codecError);
129
1/2
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 53 taken 181 times.
181 if (raw.isEmpty()) {
130 ✗ qCWarning(lcContactOutbox) << "Patch refused:" << codecError;
131 ✗ if (error)
132 ✗ *error = tr("This contact cannot be changed in MailJD. Edit it where it was created.");
133 ✗ return {};
134 }
135
2/2
✓ Branch 55 → 56 taken 16 times.
✓ Branch 55 → 58 taken 165 times.
181 if (raw == stored->raw)
136
1/1
✓ Branch 56 → 57 taken 16 times.
16 return key(bookId, uid);
137
3/4
✓ Branch 58 → 59 taken 165 times.
✓ Branch 59 → 60 taken 165 times.
✗ Branch 59 → 61 not taken.
✓ Branch 60 → 62 taken 165 times.
165 return replaceRaw(bookId, uid, raw) ? key(bookId, uid) : QString();
138 187 }
139
140 221 bool ContactOutbox::replaceRaw(qint64 bookId, const QString &uid, const QString &raw) {
141
1/1
✓ Branch 2 → 3 taken 221 times.
221 auto stored = m_deps.store->card(bookId, uid);
142
1/1
✓ Branch 3 → 4 taken 221 times.
221 const auto book = m_deps.store->book(bookId);
143
5/6
✓ Branch 5 → 6 taken 220 times.
✓ Branch 5 → 8 taken 1 time.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 220 times.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 220 times.
221 if (!stored || !book)
144 1 return false;
145 220 stored->raw = raw;
146 220 const bool remote = !book->isLocal();
147
2/2
✓ Branch 16 → 17 taken 218 times.
✓ Branch 16 → 25 taken 2 times.
220 if (remote)
148 218 stored->syncState = QStringLiteral("pending");
149
3/3
✓ Branch 26 → 27 taken 220 times.
✓ Branch 27 → 28 taken 1 time.
✓ Branch 27 → 29 taken 219 times.
220 if (!m_deps.store->putCard(*stored))
150 1 return false;
151
2/2
✓ Branch 29 → 30 taken 217 times.
✓ Branch 29 → 50 taken 2 times.
219 if (remote) {
152
1/1
✓ Branch 35 → 36 taken 217 times.
434 m_deps.store->enqueueOutbox(bookId, uid, QStringLiteral("put"), stored->href, stored->etag);
153
6/6
✓ Branch 38 → 39 taken 217 times.
✓ Branch 42 → 43 taken 217 times.
✓ Branch 43 → 44 taken 217 times.
✓ Branch 44 → 45 taken 217 times.
✓ Branch 48 → 40 taken 217 times.
✓ Branch 48 → 49 taken 217 times.
434 qCInfo(lcContactOutbox) << "Queued put of a card in book" << bookId;
154 }
155
3/5
✓ Branch 52 → 53 taken 219 times.
✓ Branch 55 → 56 taken 219 times.
✓ Branch 55 → 57 taken 219 times.
✗ Branch 78 → 79 not taken.
✗ Branch 78 → 80 not taken.
438 changed({key(bookId, uid)});
156
2/2
✓ Branch 57 → 58 taken 217 times.
✓ Branch 57 → 59 taken 2 times.
219 if (remote)
157
1/1
✓ Branch 58 → 59 taken 217 times.
217 flush(false);
158 219 return true;
159 440 }
160
161 10 QString ContactOutbox::importRaw(qint64 bookId, const QString &raw, QString *error) {
162
1/1
✓ Branch 2 → 3 taken 10 times.
10 ContactCard card;
163 10 bool writable = false;
164
3/3
✓ Branch 3 → 4 taken 10 times.
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 9 times.
10 if (!VCardCodec::parse(raw, &card, error, &writable))
165 1 return {};
166
6/8
✓ Branch 7 → 8 taken 6 times.
✓ Branch 7 → 12 taken 3 times.
✓ Branch 10 → 11 taken 6 times.
✗ Branch 10 → 12 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 6 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 14 → 17 taken 6 times.
9 if (card.uid.isEmpty() || card.version == QLatin1String("2.1") || !writable)
167
1/1
✓ Branch 15 → 16 taken 3 times.
3 return createCard(bookId, card, error);
168
1/1
✓ Branch 17 → 18 taken 6 times.
6 const auto book = writableBook(bookId, error);
169
2/2
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 5 times.
6 if (!book)
170 1 return {};
171
3/3
✓ Branch 21 → 22 taken 5 times.
✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 31 taken 4 times.
5 if (m_deps.store->card(bookId, card.uid)) {
172
1/2
✓ Branch 25 → 26 taken 1 time.
✗ Branch 25 → 30 not taken.
1 if (error)
173
1/1
✓ Branch 26 → 27 taken 1 time.
1 *error = tr("This contact is already in that address book.");
174 1 return {};
175 }
176 4 StoredCard stored;
177 4 stored.bookId = bookId;
178 4 stored.uid = card.uid;
179
2/2
✓ Branch 35 → 36 taken 4 times.
✓ Branch 36 → 37 taken 4 times.
4 stored.raw = raw.trimmed() + QStringLiteral("\r\n");
180
2/2
✓ Branch 44 → 45 taken 3 times.
✓ Branch 44 → 76 taken 1 time.
4 if (!book->isLocal()) {
181 3 QString path = book->path;
182
3/3
✓ Branch 49 → 50 taken 3 times.
✓ Branch 50 → 51 taken 1 time.
✓ Branch 50 → 55 taken 2 times.
3 if (!path.endsWith(QLatin1Char('/')))
183
1/1
✓ Branch 53 → 54 taken 1 time.
1 path += QLatin1Char('/');
184
4/4
✓ Branch 58 → 59 taken 3 times.
✓ Branch 59 → 60 taken 3 times.
✓ Branch 60 → 61 taken 3 times.
✓ Branch 61 → 62 taken 3 times.
3 stored.href = path + QUuid::createUuid().toString(QUuid::WithoutBraces) + QStringLiteral(".vcf");
185 3 stored.syncState = QStringLiteral("pending");
186 3 }
187
2/3
✓ Branch 76 → 77 taken 4 times.
✗ Branch 77 → 78 not taken.
✓ Branch 77 → 79 taken 4 times.
4 if (!m_deps.store->putCard(stored))
188 ✗ return {};
189
2/2
✓ Branch 81 → 82 taken 3 times.
✓ Branch 81 → 91 taken 1 time.
4 if (!book->isLocal())
190
1/1
✓ Branch 86 → 87 taken 3 times.
6 m_deps.store->enqueueOutbox(bookId, card.uid, QStringLiteral("put"), stored.href, QString());
191
3/5
✓ Branch 93 → 94 taken 4 times.
✓ Branch 96 → 97 taken 4 times.
✓ Branch 96 → 98 taken 4 times.
✗ Branch 165 → 166 not taken.
✗ Branch 165 → 167 not taken.
8 changed({key(bookId, card.uid)});
192
6/6
✓ Branch 98 → 99 taken 4 times.
✓ Branch 102 → 103 taken 4 times.
✓ Branch 103 → 104 taken 4 times.
✓ Branch 104 → 105 taken 4 times.
✓ Branch 108 → 100 taken 4 times.
✓ Branch 108 → 109 taken 4 times.
8 qCInfo(lcContactOutbox) << "Imported a card into book" << bookId;
193
2/2
✓ Branch 111 → 112 taken 3 times.
✓ Branch 111 → 113 taken 1 time.
4 if (!book->isLocal())
194
1/1
✓ Branch 112 → 113 taken 3 times.
3 flush(false);
195
1/1
✓ Branch 113 → 114 taken 4 times.
4 return key(bookId, card.uid);
196 14 }
197
198 22 QString ContactOutbox::createCard(qint64 bookId, const ContactCard &input, QString *error) {
199
1/1
✓ Branch 2 → 3 taken 22 times.
22 const auto book = writableBook(bookId, error);
200
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 21 times.
22 if (!book)
201 1 return {};
202 21 ContactCard card = input;
203
2/2
✓ Branch 8 → 9 taken 18 times.
✓ Branch 8 → 14 taken 3 times.
21 if (card.uid.isEmpty())
204
2/2
✓ Branch 9 → 10 taken 18 times.
✓ Branch 10 → 11 taken 18 times.
18 card.uid = QUuid::createUuid().toString(QUuid::WithoutBraces);
205
3/3
✓ Branch 14 → 15 taken 21 times.
✓ Branch 17 → 18 taken 1 time.
✓ Branch 17 → 24 taken 20 times.
21 if (m_deps.store->card(bookId, card.uid)) {
206
1/2
✓ Branch 18 → 19 taken 1 time.
✗ Branch 18 → 23 not taken.
1 if (error)
207
1/1
✓ Branch 19 → 20 taken 1 time.
1 *error = tr("This contact is already in that address book.");
208 1 return {};
209 }
210 // D-3: 3.0 unless the book accepts only vCard 4.0.
211
3/5
✓ Branch 29 → 30 taken 20 times.
✓ Branch 32 → 33 taken 20 times.
✓ Branch 32 → 34 taken 20 times.
✗ Branch 129 → 130 not taken.
✗ Branch 129 → 131 not taken.
40 const bool only40 = book->supportedVersions == QStringList{QStringLiteral("4.0")};
212 20 StoredCard stored;
213 20 stored.bookId = bookId;
214 20 stored.uid = card.uid;
215
4/11
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 41 taken 20 times.
✓ Branch 44 → 45 taken 20 times.
✓ Branch 48 → 49 taken 20 times.
✗ Branch 48 → 50 not taken.
✗ Branch 50 → 51 not taken.
✓ Branch 50 → 52 taken 20 times.
✗ Branch 144 → 145 not taken.
✗ Branch 144 → 146 not taken.
✗ Branch 148 → 149 not taken.
✗ Branch 148 → 150 not taken.
40 stored.raw = VCardCodec::create(card, only40 ? QStringLiteral("4.0") : QStringLiteral("3.0"));
216
2/2
✓ Branch 55 → 56 taken 16 times.
✓ Branch 55 → 87 taken 4 times.
20 if (!book->isLocal()) {
217 // A fresh UUID names the resource, never the (possibly foreign) UID.
218 16 QString path = book->path;
219
3/3
✓ Branch 60 → 61 taken 16 times.
✓ Branch 61 → 62 taken 1 time.
✓ Branch 61 → 66 taken 15 times.
16 if (!path.endsWith(QLatin1Char('/')))
220
1/1
✓ Branch 64 → 65 taken 1 time.
1 path += QLatin1Char('/');
221
4/4
✓ Branch 69 → 70 taken 16 times.
✓ Branch 70 → 71 taken 16 times.
✓ Branch 71 → 72 taken 16 times.
✓ Branch 72 → 73 taken 16 times.
16 stored.href = path + QUuid::createUuid().toString(QUuid::WithoutBraces) + QStringLiteral(".vcf");
222 16 stored.syncState = QStringLiteral("pending");
223 16 }
224
4/7
✓ Branch 88 → 89 taken 20 times.
✗ Branch 88 → 91 not taken.
✓ Branch 89 → 90 taken 20 times.
✗ Branch 90 → 91 not taken.
✓ Branch 90 → 92 taken 20 times.
✗ Branch 93 → 94 not taken.
✓ Branch 93 → 95 taken 20 times.
20 if (stored.raw.isEmpty() || !m_deps.store->putCard(stored))
225 ✗ return {};
226
2/2
✓ Branch 97 → 98 taken 16 times.
✓ Branch 97 → 107 taken 4 times.
20 if (!book->isLocal())
227
1/1
✓ Branch 102 → 103 taken 16 times.
32 m_deps.store->enqueueOutbox(bookId, card.uid, QStringLiteral("put"), stored.href, QString());
228
3/5
✓ Branch 109 → 110 taken 20 times.
✓ Branch 112 → 113 taken 20 times.
✓ Branch 112 → 114 taken 20 times.
✗ Branch 190 → 191 not taken.
✗ Branch 190 → 192 not taken.
40 changed({key(bookId, card.uid)});
229
1/1
✓ Branch 114 → 115 taken 20 times.
20 flush(false);
230
1/1
✓ Branch 115 → 116 taken 20 times.
20 return key(bookId, card.uid);
231 82 }
232
233 53 bool ContactOutbox::removeCard(qint64 bookId, const QString &uid, StoredCard *removed) {
234
1/1
✓ Branch 2 → 3 taken 53 times.
53 const auto book = m_deps.store->book(bookId);
235
1/1
✓ Branch 3 → 4 taken 53 times.
53 const auto stored = m_deps.store->card(bookId, uid);
236
7/8
✓ Branch 5 → 6 taken 53 times.
✗ Branch 5 → 10 not taken.
✓ Branch 7 → 8 taken 51 times.
✓ Branch 7 → 10 taken 2 times.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 11 taken 50 times.
✓ Branch 12 → 13 taken 3 times.
✓ Branch 12 → 14 taken 50 times.
53 if (!book || !stored || book->readOnly)
237 3 return false;
238
2/2
✓ Branch 14 → 15 taken 25 times.
✓ Branch 14 → 17 taken 25 times.
50 if (removed)
239 25 *removed = *stored;
240
1/1
✓ Branch 17 → 18 taken 50 times.
50 const auto existing = m_deps.store->outboxEntry(bookId, uid);
241
3/3
✓ Branch 18 → 19 taken 50 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 19 → 21 taken 48 times.
50 if (!m_deps.store->removeCard(bookId, uid))
242 2 return false;
243
2/2
✓ Branch 22 → 23 taken 10 times.
✓ Branch 22 → 25 taken 38 times.
48 const QString baseEtag = existing ? existing->baseEtag : stored->etag;
244
5/6
✓ Branch 30 → 31 taken 44 times.
✓ Branch 30 → 35 taken 4 times.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 44 times.
✓ Branch 37 → 38 taken 44 times.
✓ Branch 37 → 74 taken 4 times.
48 if (book->isLocal() || stored->syncState == QLatin1String("legacy")) {
245 // Nothing on a server to remove.
246
2/2
✓ Branch 39 → 40 taken 25 times.
✓ Branch 39 → 41 taken 19 times.
44 } else if (baseEtag.isEmpty()) {
247
1/1
✓ Branch 40 → 74 taken 25 times.
25 m_deps.store->removeOutbox(bookId, uid); // never reached the server
248 } else {
249
2/3
✗ Branch 42 → 43 not taken.
✓ Branch 42 → 45 taken 19 times.
✓ Branch 50 → 51 taken 19 times.
38 m_deps.store->enqueueOutbox(bookId, uid, QStringLiteral("delete"), existing ? existing->href : stored->href,
250 baseEtag);
251 // Sprint 107: the card's last server version stays with its delete —
252 // the resolver's base when the server changed it (D-12/D-22).
253
2/3
✓ Branch 53 → 54 taken 19 times.
✓ Branch 55 → 56 taken 19 times.
✗ Branch 55 → 61 not taken.
19 if (auto entry = m_deps.store->outboxEntry(bookId, uid)) {
254 19 entry->baseRaw = stored->baseRaw;
255
1/1
✓ Branch 60 → 61 taken 19 times.
19 m_deps.store->updateOutbox(*entry);
256 19 }
257
6/6
✓ Branch 62 → 63 taken 19 times.
✓ Branch 66 → 67 taken 19 times.
✓ Branch 67 → 68 taken 19 times.
✓ Branch 68 → 69 taken 19 times.
✓ Branch 72 → 64 taken 19 times.
✓ Branch 72 → 73 taken 19 times.
38 qCInfo(lcContactOutbox) << "Queued delete of a card in book" << bookId;
258 }
259
3/5
✓ Branch 76 → 77 taken 48 times.
✓ Branch 79 → 80 taken 48 times.
✓ Branch 79 → 81 taken 48 times.
✗ Branch 105 → 106 not taken.
✗ Branch 105 → 107 not taken.
96 changed({key(bookId, uid)});
260
1/1
✓ Branch 81 → 82 taken 48 times.
48 flush(false);
261 48 return true;
262 101 }
263
264 20 bool ContactOutbox::restoreCard(const StoredCard &input) {
265
1/1
✓ Branch 2 → 3 taken 20 times.
20 const auto book = m_deps.store->book(input.bookId);
266
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 19 times.
20 if (!book)
267 1 return false;
268 19 StoredCard card = input;
269 19 card.id = 0;
270
1/1
✓ Branch 7 → 8 taken 19 times.
19 const auto entry = m_deps.store->outboxEntry(card.bookId, card.uid);
271
2/2
✓ Branch 10 → 11 taken 17 times.
✓ Branch 10 → 18 taken 2 times.
19 if (!book->isLocal())
272 17 card.syncState = QStringLiteral("pending");
273
3/3
✓ Branch 18 → 19 taken 19 times.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 18 times.
19 if (!m_deps.store->putCard(card))
274 1 return false;
275
2/2
✓ Branch 23 → 24 taken 16 times.
✓ Branch 23 → 53 taken 2 times.
18 if (!book->isLocal()) {
276 // A queued delete turns back into a put of the same base; a delete that
277 // already reached the server makes the restore a create.
278
2/2
✓ Branch 25 → 26 taken 2 times.
✓ Branch 25 → 28 taken 14 times.
16 const QString baseEtag = entry ? entry->baseEtag : QString();
279
2/2
✓ Branch 30 → 31 taken 2 times.
✓ Branch 30 → 33 taken 14 times.
16 const QString href = entry ? entry->href : card.href;
280
1/1
✓ Branch 38 → 39 taken 16 times.
32 m_deps.store->enqueueOutbox(card.bookId, card.uid, QStringLiteral("put"), href, baseEtag);
281
6/6
✓ Branch 42 → 43 taken 14 times.
✓ Branch 42 → 46 taken 2 times.
✓ Branch 44 → 45 taken 1 time.
✓ Branch 44 → 46 taken 13 times.
✓ Branch 47 → 48 taken 1 time.
✓ Branch 47 → 50 taken 15 times.
16 if (!entry && !card.etag.isEmpty()) {
282 1 card.etag.clear();
283
1/1
✓ Branch 49 → 50 taken 1 time.
1 m_deps.store->putCard(card);
284 }
285 16 }
286
3/5
✓ Branch 55 → 56 taken 18 times.
✓ Branch 58 → 59 taken 18 times.
✓ Branch 58 → 60 taken 18 times.
✗ Branch 81 → 82 not taken.
✗ Branch 81 → 83 not taken.
36 changed({key(card.bookId, card.uid)});
287
1/1
✓ Branch 60 → 61 taken 18 times.
18 flush(false);
288 18 return true;
289 38 }
290
291 21 QString ContactOutbox::moveCard(qint64 fromBookId, const QString &uid, qint64 toBookId, QString *error) {
292
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 5 taken 20 times.
21 if (fromBookId == toBookId)
293
1/1
✓ Branch 3 → 4 taken 1 time.
1 return key(fromBookId, uid);
294
1/1
✓ Branch 5 → 6 taken 20 times.
20 const auto target = writableBook(toBookId, error);
295
1/1
✓ Branch 6 → 7 taken 20 times.
20 const auto source = writableBook(fromBookId, error);
296
1/1
✓ Branch 7 → 8 taken 20 times.
20 const auto stored = m_deps.store->card(fromBookId, uid);
297
6/8
✓ Branch 9 → 10 taken 19 times.
✓ Branch 9 → 14 taken 1 time.
✓ Branch 11 → 12 taken 19 times.
✗ Branch 11 → 14 not taken.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 19 times.
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 18 taken 19 times.
20 if (!target || !source || !stored)
298 1 return {};
299
3/3
✓ Branch 18 → 19 taken 19 times.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 28 taken 18 times.
19 if (m_deps.store->card(toBookId, uid)) {
300
1/2
✓ Branch 22 → 23 taken 1 time.
✗ Branch 22 → 27 not taken.
1 if (error)
301
1/1
✓ Branch 23 → 24 taken 1 time.
1 *error = tr("This contact is already in that address book.");
302 1 return {};
303 }
304 18 beginBatch();
305 18 StoredCard copy;
306 18 copy.bookId = toBookId;
307 18 copy.uid = uid;
308 18 copy.raw = stored->raw;
309 18 qint64 createEntry = 0;
310
2/2
✓ Branch 34 → 35 taken 15 times.
✓ Branch 34 → 66 taken 3 times.
18 if (!target->isLocal()) {
311 15 QString path = target->path;
312
3/3
✓ Branch 39 → 40 taken 15 times.
✓ Branch 40 → 41 taken 1 time.
✓ Branch 40 → 45 taken 14 times.
15 if (!path.endsWith(QLatin1Char('/')))
313
1/1
✓ Branch 43 → 44 taken 1 time.
1 path += QLatin1Char('/');
314
4/4
✓ Branch 48 → 49 taken 15 times.
✓ Branch 49 → 50 taken 15 times.
✓ Branch 50 → 51 taken 15 times.
✓ Branch 51 → 52 taken 15 times.
15 copy.href = path + QUuid::createUuid().toString(QUuid::WithoutBraces) + QStringLiteral(".vcf");
315 15 copy.syncState = QStringLiteral("pending");
316 15 }
317 // S106-180: copy, its upload, the removal and its dependency are one
318 // transaction — a failure after the copy used to leave a duplicate.
319
1/1
✓ Branch 66 → 67 taken 18 times.
18 bool ok = m_deps.store->beginTransaction();
320
3/5
✓ Branch 67 → 68 taken 18 times.
✗ Branch 67 → 71 not taken.
✓ Branch 68 → 69 taken 18 times.
✓ Branch 69 → 70 taken 18 times.
✗ Branch 69 → 71 not taken.
18 ok = ok && m_deps.store->putCard(copy) > 0;
321
5/6
✓ Branch 72 → 73 taken 18 times.
✗ Branch 72 → 77 not taken.
✓ Branch 75 → 76 taken 15 times.
✓ Branch 75 → 77 taken 3 times.
✓ Branch 78 → 79 taken 15 times.
✓ Branch 78 → 88 taken 3 times.
18 if (ok && !target->isLocal()) {
322
1/1
✓ Branch 83 → 84 taken 15 times.
30 createEntry = m_deps.store->enqueueOutbox(toBookId, uid, QStringLiteral("put"), copy.href, QString());
323 15 ok = createEntry > 0;
324 }
325
1/2
✓ Branch 88 → 89 taken 18 times.
✗ Branch 88 → 104 not taken.
18 if (ok) {
326
1/1
✓ Branch 89 → 90 taken 18 times.
18 ok = removeCard(fromBookId, uid);
327 // The source delete waits until the target exists on its server.
328
9/9
✓ Branch 90 → 91 taken 18 times.
✓ Branch 91 → 92 taken 17 times.
✓ Branch 91 → 96 taken 1 time.
✓ Branch 93 → 94 taken 10 times.
✓ Branch 93 → 96 taken 7 times.
✓ Branch 94 → 95 taken 9 times.
✓ Branch 94 → 96 taken 1 time.
✓ Branch 97 → 98 taken 9 times.
✓ Branch 97 → 102 taken 9 times.
18 if (auto entry = m_deps.store->outboxEntry(fromBookId, uid); ok && entry && createEntry > 0) {
329 9 entry->dependsOn = createEntry;
330
1/1
✓ Branch 100 → 101 taken 9 times.
9 ok = m_deps.store->updateOutbox(*entry);
331 18 }
332 }
333
2/2
✓ Branch 104 → 105 taken 17 times.
✓ Branch 104 → 107 taken 1 time.
18 if (ok)
334
1/1
✓ Branch 105 → 106 taken 17 times.
17 ok = m_deps.store->commitTransaction();
335
2/2
✓ Branch 107 → 108 taken 1 time.
✓ Branch 107 → 120 taken 17 times.
18 if (!ok) {
336
1/1
✓ Branch 108 → 109 taken 1 time.
1 m_deps.store->rollbackTransaction();
337
5/5
✓ Branch 109 → 110 taken 1 time.
✓ Branch 113 → 114 taken 1 time.
✓ Branch 114 → 115 taken 1 time.
✓ Branch 118 → 111 taken 1 time.
✓ Branch 118 → 119 taken 1 time.
2 qCWarning(lcContactOutbox) << "Moving a card between books failed; nothing changed";
338 }
339
3/5
✓ Branch 123 → 124 taken 18 times.
✓ Branch 126 → 127 taken 36 times.
✓ Branch 126 → 128 taken 18 times.
✗ Branch 188 → 189 not taken.
✗ Branch 188 → 190 not taken.
54 changed({key(toBookId, uid), key(fromBookId, uid)});
340
1/1
✓ Branch 128 → 129 taken 18 times.
18 endBatch();
341
3/4
✓ Branch 129 → 130 taken 1 time.
✓ Branch 129 → 135 taken 17 times.
✓ Branch 130 → 131 taken 1 time.
✗ Branch 130 → 135 not taken.
18 if (!ok && error)
342
1/1
✓ Branch 131 → 132 taken 1 time.
1 *error = tr("The contact could not be moved.");
343
3/3
✓ Branch 135 → 136 taken 17 times.
✓ Branch 135 → 137 taken 1 time.
✓ Branch 136 → 138 taken 17 times.
18 return ok ? key(toBookId, uid) : QString();
344 38 }
345
346 18 bool ContactOutbox::resolveConflict(qint64 bookId, const QString &uid, const QString &choice) {
347
1/1
✓ Branch 2 → 3 taken 18 times.
18 auto entry = m_deps.store->outboxEntry(bookId, uid);
348
5/6
✓ Branch 4 → 5 taken 16 times.
✓ Branch 4 → 9 taken 2 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 16 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 13 taken 16 times.
18 if (!entry || entry->state != QLatin1String("conflict"))
349 2 return false;
350
6/6
✓ Branch 13 → 14 taken 16 times.
✓ Branch 17 → 18 taken 16 times.
✓ Branch 18 → 19 taken 16 times.
✓ Branch 19 → 20 taken 16 times.
✓ Branch 23 → 15 taken 16 times.
✓ Branch 23 → 24 taken 16 times.
32 qCInfo(lcContactOutbox) << "Resolving a contact conflict with" << choice;
351 // D-22: a moved card's question is about its old book's server version.
352
1/2
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 61 taken 16 times.
32 if (entry->conflictFields.contains(QStringLiteral("moved"))) {
353 ✗ const auto card = m_deps.store->card(bookId, uid);
354 ✗ if (choice == QLatin1String("server"))
355 ✗ return resolveWith(bookId, uid, entry->serverRaw, entry->serverEtag, choice);
356 ✗ if (choice == QLatin1String("mine") && card)
357 ✗ return resolveWith(bookId, uid, card->raw, entry->serverEtag, QStringLiteral("local"));
358 ✗ return false;
359 ✗ }
360 // D-21: the losing side is kept; the written bytes are unchanged (G2).
361
7/7
✓ Branch 61 → 62 taken 16 times.
✓ Branch 63 → 64 taken 14 times.
✓ Branch 63 → 68 taken 2 times.
✓ Branch 66 → 67 taken 12 times.
✓ Branch 66 → 68 taken 2 times.
✓ Branch 69 → 70 taken 12 times.
✓ Branch 69 → 89 taken 4 times.
16 if (const auto card = m_deps.store->card(bookId, uid); card && choice != QLatin1String("both"))
362
7/9
✓ Branch 72 → 73 taken 7 times.
✓ Branch 72 → 75 taken 5 times.
✓ Branch 79 → 80 taken 7 times.
✓ Branch 79 → 83 taken 5 times.
✓ Branch 84 → 85 taken 12 times.
✓ Branch 86 → 87 taken 7 times.
✓ Branch 86 → 88 taken 5 times.
✗ Branch 274 → 275 not taken.
✗ Branch 274 → 276 not taken.
31 recordHistory(bookId, uid, choice == QLatin1String("mine") ? QStringLiteral("local") : choice,
363 40 choice == QLatin1String("mine") ? entry->serverRaw : card->raw);
364
2/2
✓ Branch 92 → 93 taken 7 times.
✓ Branch 92 → 103 taken 9 times.
16 if (choice == QLatin1String("server")) {
365
2/3
✓ Branch 96 → 97 taken 7 times.
✗ Branch 97 → 98 not taken.
✓ Branch 97 → 99 taken 7 times.
7 if (!adoptServerVersion(*entry, entry->serverRaw, entry->serverEtag))
366 ✗ return false;
367
2/2
✓ Branch 99 → 100 taken 7 times.
✓ Branch 100 → 101 taken 7 times.
7 emit statusMessage(tr("The version from the server was kept."));
368 7 return true;
369 }
370
2/2
✓ Branch 105 → 106 taken 2 times.
✓ Branch 105 → 190 taken 7 times.
9 if (choice == QLatin1String("both")) {
371
1/1
✓ Branch 106 → 107 taken 2 times.
2 const auto stored = m_deps.store->card(bookId, uid);
372
2/2
✓ Branch 107 → 108 taken 2 times.
✓ Branch 108 → 109 taken 2 times.
2 const QString copyUid = QUuid::createUuid().toString(QUuid::WithoutBraces);
373
1/2
✗ Branch 110 → 111 not taken.
✓ Branch 110 → 112 taken 2 times.
2 if (!stored)
374 ✗ return false;
375 2 StoredCard copy;
376 2 copy.bookId = bookId;
377 2 copy.uid = copyUid;
378
1/1
✓ Branch 114 → 115 taken 2 times.
2 copy.raw = withUid(stored->raw, copyUid);
379
1/1
✓ Branch 117 → 118 taken 2 times.
2 const auto book = m_deps.store->book(bookId);
380
3/6
✓ Branch 119 → 120 taken 2 times.
✗ Branch 119 → 122 not taken.
✗ Branch 121 → 122 not taken.
✓ Branch 121 → 123 taken 2 times.
✗ Branch 124 → 125 not taken.
✓ Branch 124 → 126 taken 2 times.
2 if (copy.raw.isEmpty() || !book)
381 ✗ return false;
382 2 QString path = book->path;
383
2/3
✓ Branch 130 → 131 taken 2 times.
✗ Branch 131 → 132 not taken.
✓ Branch 131 → 136 taken 2 times.
2 if (!path.endsWith(QLatin1Char('/')))
384 ✗ path += QLatin1Char('/');
385
4/4
✓ Branch 139 → 140 taken 2 times.
✓ Branch 140 → 141 taken 2 times.
✓ Branch 141 → 142 taken 2 times.
✓ Branch 142 → 143 taken 2 times.
2 copy.href = path + QUuid::createUuid().toString(QUuid::WithoutBraces) + QStringLiteral(".vcf");
386 2 copy.syncState = QStringLiteral("pending");
387
2/3
✓ Branch 155 → 156 taken 2 times.
✗ Branch 156 → 157 not taken.
✓ Branch 156 → 158 taken 2 times.
2 if (!m_deps.store->putCard(copy))
388 ✗ return false;
389
1/1
✓ Branch 162 → 163 taken 2 times.
4 m_deps.store->enqueueOutbox(bookId, copyUid, QStringLiteral("put"), copy.href, QString());
390
2/3
✓ Branch 169 → 170 taken 2 times.
✗ Branch 170 → 171 not taken.
✓ Branch 170 → 172 taken 2 times.
2 if (!adoptServerVersion(*entry, entry->serverRaw, entry->serverEtag))
391 ✗ return false;
392
3/5
✓ Branch 174 → 175 taken 2 times.
✓ Branch 177 → 178 taken 2 times.
✓ Branch 177 → 179 taken 2 times.
✗ Branch 319 → 320 not taken.
✗ Branch 319 → 321 not taken.
4 changed({key(bookId, copyUid)});
393
2/2
✓ Branch 179 → 180 taken 2 times.
✓ Branch 180 → 181 taken 2 times.
2 emit statusMessage(tr("Both versions were kept."));
394
1/1
✓ Branch 182 → 183 taken 2 times.
2 flush(false);
395 2 return true;
396 2 }
397
1/2
✗ Branch 192 → 193 not taken.
✓ Branch 192 → 194 taken 7 times.
7 if (choice != QLatin1String("mine"))
398 ✗ return false;
399 7 const bool deletedOnServer = entry->serverRaw.isEmpty();
400
2/6
✗ Branch 199 → 200 not taken.
✓ Branch 199 → 202 taken 7 times.
✗ Branch 200 → 201 not taken.
✗ Branch 200 → 202 not taken.
✗ Branch 203 → 204 not taken.
✓ Branch 203 → 213 taken 7 times.
7 if (entry->operation == QLatin1String("delete") && deletedOnServer) {
401 ✗ m_deps.store->removeOutbox(bookId, uid);
402 ✗ changed({key(bookId, uid)});
403 ✗ return true;
404 }
405 7 entry->state = QStringLiteral("pending");
406
2/2
✓ Branch 220 → 221 taken 2 times.
✓ Branch 220 → 222 taken 5 times.
7 entry->baseEtag = deletedOnServer ? QString() : entry->serverEtag;
407 7 entry->attempts = 0;
408 7 entry->nextAttempt = 0;
409
1/1
✓ Branch 230 → 231 taken 7 times.
7 entry->conflictFields.clear();
410 7 entry->serverRaw.clear();
411 7 entry->serverEtag.clear();
412
1/1
✓ Branch 236 → 237 taken 7 times.
7 m_deps.store->updateOutbox(*entry);
413
1/1
✓ Branch 240 → 241 taken 7 times.
14 m_deps.store->setSyncState(bookId, uid, QStringLiteral("pending"));
414
3/5
✓ Branch 245 → 246 taken 7 times.
✓ Branch 248 → 249 taken 7 times.
✓ Branch 248 → 250 taken 7 times.
✗ Branch 365 → 366 not taken.
✗ Branch 365 → 367 not taken.
14 changed({key(bookId, uid)});
415
2/2
✓ Branch 250 → 251 taken 7 times.
✓ Branch 251 → 252 taken 7 times.
7 emit statusMessage(tr("Your version will be saved on the server."));
416
1/1
✓ Branch 253 → 254 taken 7 times.
7 flush(true);
417 7 return true;
418 27 }
419
420 // ─── pushing ───────────────────────────────────────────────
421
422 494 void ContactOutbox::flush(bool ignoreBackoff) {
423
7/9
✓ Branch 2 → 3 taken 477 times.
✓ Branch 2 → 6 taken 17 times.
✓ Branch 3 → 4 taken 477 times.
✗ Branch 3 → 6 not taken.
✓ Branch 4 → 5 taken 477 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 477 times.
✓ Branch 8 → 9 taken 17 times.
✓ Branch 8 → 10 taken 477 times.
494 if (m_batchDepth > 0 || !m_deps.store || !m_deps.store->isOpen())
424 74 return;
425
6/7
✓ Branch 11 → 12 taken 477 times.
✗ Branch 11 → 15 not taken.
✓ Branch 12 → 13 taken 477 times.
✓ Branch 13 → 14 taken 57 times.
✓ Branch 13 → 15 taken 420 times.
✓ Branch 16 → 17 taken 57 times.
✓ Branch 16 → 18 taken 420 times.
477 if (m_deps.networkAllowed && !m_deps.networkAllowed())
426 57 return;
427
1/1
✓ Branch 18 → 19 taken 420 times.
420 const qint64 now = m_deps.now();
428 420 QHash<QString, QList<ContactOutboxEntry>> due;
429
1/1
✓ Branch 19 → 20 taken 420 times.
420 const QList<ContactOutboxEntry> entries = m_deps.store->outboxEntries();
430 420 QSet<qint64> queuedIds;
431 420 QSet<qint64> stuckIds; // S106-064: dependencies parked in failed/conflict
432
2/2
✓ Branch 39 → 24 taken 481 times.
✓ Branch 39 → 40 taken 420 times.
901 for (const ContactOutboxEntry &entry : entries) {
433
1/1
✓ Branch 25 → 26 taken 481 times.
481 queuedIds.insert(entry.id);
434
6/6
✓ Branch 28 → 29 taken 474 times.
✓ Branch 28 → 32 taken 7 times.
✓ Branch 31 → 32 taken 4 times.
✓ Branch 31 → 33 taken 470 times.
✓ Branch 34 → 35 taken 11 times.
✓ Branch 34 → 37 taken 470 times.
481 if (entry.state == QLatin1String("failed") || entry.state == QLatin1String("conflict"))
435
1/1
✓ Branch 35 → 36 taken 11 times.
11 stuckIds.insert(entry.id);
436 }
437
2/2
✓ Branch 115 → 42 taken 481 times.
✓ Branch 115 → 116 taken 420 times.
901 for (const ContactOutboxEntry &entry : entries) {
438
9/11
✓ Branch 45 → 46 taken 470 times.
✓ Branch 45 → 49 taken 11 times.
✓ Branch 46 → 47 taken 470 times.
✓ Branch 48 → 49 taken 12 times.
✓ Branch 48 → 50 taken 458 times.
✓ Branch 51 → 52 taken 470 times.
✓ Branch 51 → 53 taken 11 times.
✓ Branch 53 → 54 taken 23 times.
✓ Branch 53 → 55 taken 458 times.
✗ Branch 156 → 157 not taken.
✗ Branch 156 → 158 not taken.
481 if (entry.state != QLatin1String("pending") || m_inFlight.contains(key(entry.bookId, entry.uid)))
439 41 continue;
440 // S106-064: the step this entry waits for (the copy of a move) failed or
441 // waits for the user. The source delete must not run (the card would be
442 // lost), and it must not wait silently forever either: it fails too, so
443 // the move shows as not done in both books.
444
6/6
✓ Branch 55 → 56 taken 25 times.
✓ Branch 55 → 59 taken 433 times.
✓ Branch 57 → 58 taken 1 time.
✓ Branch 57 → 59 taken 24 times.
✓ Branch 60 → 61 taken 1 time.
✓ Branch 60 → 85 taken 457 times.
458 if (entry.dependsOn != 0 && stuckIds.contains(entry.dependsOn)) {
445 1 ContactOutboxEntry failed = entry;
446 1 failed.state = QStringLiteral("failed");
447 1 failed.nextAttempt = 0;
448 1 failed.lastError = QStringLiteral("The copy into the other address book was not stored");
449
1/1
✓ Branch 74 → 75 taken 1 time.
1 m_deps.store->updateOutbox(failed);
450
3/5
✓ Branch 77 → 78 taken 1 time.
✓ Branch 80 → 81 taken 1 time.
✓ Branch 80 → 82 taken 1 time.
✗ Branch 164 → 165 not taken.
✗ Branch 164 → 166 not taken.
2 changed({key(entry.bookId, entry.uid)});
451 1 continue;
452 1 }
453
8/8
✓ Branch 85 → 86 taken 412 times.
✓ Branch 85 → 87 taken 45 times.
✓ Branch 86 → 87 taken 411 times.
✓ Branch 86 → 89 taken 1 time.
✓ Branch 88 → 89 taken 15 times.
✓ Branch 88 → 90 taken 441 times.
✓ Branch 91 → 92 taken 16 times.
✓ Branch 91 → 93 taken 441 times.
457 if ((!ignoreBackoff && entry.nextAttempt > now) || queuedIds.contains(entry.dependsOn))
454 16 continue;
455
1/1
✓ Branch 93 → 94 taken 441 times.
441 const auto book = m_deps.store->book(entry.bookId);
456
5/6
✓ Branch 95 → 96 taken 441 times.
✗ Branch 95 → 99 not taken.
✓ Branch 98 → 99 taken 1 time.
✓ Branch 98 → 100 taken 440 times.
✓ Branch 101 → 102 taken 1 time.
✓ Branch 101 → 104 taken 440 times.
441 if (!book || book->isLocal()) {
457
1/1
✓ Branch 102 → 103 taken 1 time.
1 m_deps.store->removeOutbox(entry.bookId, entry.uid);
458 1 continue;
459 }
460
2/2
✓ Branch 105 → 106 taken 440 times.
✓ Branch 106 → 107 taken 440 times.
440 due[book->accountId].append(entry);
461 441 }
462
2/2
✓ Branch 144 → 117 taken 289 times.
✓ Branch 144 → 145 taken 420 times.
709 for (auto it = due.cbegin(); it != due.cend(); ++it) {
463 289 const QString accountId = it.key();
464
1/2
✗ Branch 120 → 121 not taken.
✓ Branch 120 → 122 taken 289 times.
289 if (m_resolving.contains(accountId))
465 ✗ continue;
466
1/1
✓ Branch 122 → 123 taken 289 times.
289 m_resolving.insert(accountId);
467
1/1
✓ Branch 123 → 124 taken 289 times.
289 QPointer<ContactOutbox> guard(this);
468 289 const QList<ContactOutboxEntry> batch = it.value();
469
2/6
✓ Branch 129 → 130 taken 289 times.
✓ Branch 130 → 131 taken 289 times.
✗ Branch 186 → 187 not taken.
✗ Branch 186 → 188 not taken.
✗ Branch 189 → 190 not taken.
✗ Branch 189 → 191 not taken.
289 m_deps.endpoint(this, accountId, [guard, accountId, batch](std::optional<Endpoint> endpoint) {
470
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 289 times.
289 if (!guard)
471 ✗ return;
472 289 guard->m_resolving.remove(accountId);
473
2/2
✓ Branch 8 → 9 taken 182 times.
✓ Branch 8 → 10 taken 107 times.
289 if (!endpoint)
474 182 return; // stays queued until the account is reachable
475 107 guard->pushAccount(accountId, batch, *endpoint);
476 107 SecureUtil::zeroMemory(endpoint->password);
477 });
478 289 }
479
1/1
✓ Branch 145 → 146 taken 420 times.
420 scheduleRetry();
480 421 }
481
482 107 void ContactOutbox::pushAccount(const QString &accountId, const QList<ContactOutboxEntry> &entries,
483 const Endpoint &endpoint) {
484 Q_UNUSED(accountId);
485
2/2
✓ Branch 2 → 3 taken 107 times.
✓ Branch 3 → 4 taken 107 times.
107 CardDavClient *client = m_deps.makeClient(endpoint.serverUrl, endpoint.username, QString::fromUtf8(endpoint.password));
486
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 107 times.
107 if (!client)
487 ✗ return;
488
1/1
✓ Branch 7 → 8 taken 107 times.
107 client->setParent(this);
489
1/1
✓ Branch 8 → 9 taken 107 times.
107 auto remaining = std::make_shared<int>(0);
490
2/2
✓ Branch 41 → 11 taken 107 times.
✓ Branch 41 → 42 taken 107 times.
214 for (const ContactOutboxEntry &queued : entries) {
491 // The entry may have changed while the credentials were read.
492
1/1
✓ Branch 12 → 13 taken 107 times.
107 const auto entry = m_deps.store->outboxEntry(queued.bookId, queued.uid);
493
6/13
✓ Branch 14 → 15 taken 107 times.
✗ Branch 14 → 24 not taken.
✓ Branch 18 → 19 taken 107 times.
✗ Branch 18 → 24 not taken.
✓ Branch 21 → 22 taken 107 times.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 107 times.
✓ Branch 26 → 27 taken 107 times.
✗ Branch 26 → 28 not taken.
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 30 taken 107 times.
✗ Branch 54 → 55 not taken.
✗ Branch 54 → 56 not taken.
107 if (!entry || entry->state != QLatin1String("pending") || m_inFlight.contains(key(entry->bookId, entry->uid)))
494 ✗ continue;
495 107 ++*remaining;
496
1/1
✓ Branch 32 → 33 taken 107 times.
107 push(*entry, client);
497 107 }
498
1/2
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 107 times.
107 if (*remaining == 0)
499 ✗ client->deleteLater();
500 else
501
1/1
✓ Branch 45 → 46 taken 107 times.
107 QTimer::singleShot(10 * 60 * 1000, client, &QObject::deleteLater); // DavNetworkLimits bound each request
502 107 }
503
504 434 void ContactOutbox::scheduleRetry() {
505 434 const qint64 now = m_deps.now();
506 434 qint64 next = 0;
507
5/5
✓ Branch 3 → 4 taken 434 times.
✓ Branch 4 → 5 taken 434 times.
✓ Branch 5 → 6 taken 434 times.
✓ Branch 20 → 7 taken 497 times.
✓ Branch 20 → 21 taken 434 times.
931 for (const ContactOutboxEntry &entry : m_deps.store->outboxEntries())
508
7/10
✓ Branch 10 → 11 taken 485 times.
✓ Branch 10 → 15 taken 12 times.
✓ Branch 11 → 12 taken 27 times.
✓ Branch 11 → 15 taken 458 times.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 27 times.
✗ Branch 13 → 14 not taken.
✗ Branch 13 → 15 not taken.
✓ Branch 16 → 17 taken 27 times.
✓ Branch 16 → 18 taken 470 times.
497 if (entry.state == QLatin1String("pending") && entry.nextAttempt > now && (next == 0 || entry.nextAttempt < next))
509 461 next = entry.nextAttempt;
510
2/2
✓ Branch 22 → 23 taken 407 times.
✓ Branch 22 → 25 taken 27 times.
434 if (next == 0) {
511 407 m_retryTimer.stop();
512 407 return;
513 }
514
1/1
✓ Branch 26 → 27 taken 27 times.
27 m_retryTimer.start(int(qBound(qint64(1), next - now, qint64(1800)) * 1000));
515 }
516
517
2/2
✓ Branch 2 → 3 taken 128 times.
✓ Branch 3 → 4 taken 128 times.
128 void ContactOutbox::finishInFlight(const ContactOutboxEntry &entry) { m_inFlight.remove(key(entry.bookId, entry.uid)); }
518
519 107 void ContactOutbox::push(const ContactOutboxEntry &entry, CardDavClient *client) {
520 // One client serves every book of the account: name this entry's book.
521
2/2
✓ Branch 3 → 4 taken 13 times.
✓ Branch 3 → 15 taken 94 times.
107 if (m_deps.pushDontNotify) {
522
1/1
✓ Branch 4 → 5 taken 13 times.
13 const auto book = m_deps.store->book(entry.bookId);
523
2/3
✓ Branch 6 → 7 taken 13 times.
✗ Branch 6 → 10 not taken.
✓ Branch 9 → 11 taken 13 times.
13 client->setPushDontNotify(book ? m_deps.pushDontNotify(book->accountId, book->path) : QString());
524 13 }
525
2/2
✓ Branch 17 → 18 taken 17 times.
✓ Branch 17 → 30 taken 90 times.
107 if (entry.operation == QLatin1String("delete")) {
526
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 22 taken 17 times.
17 if (entry.baseEtag.isEmpty()) {
527 ✗ m_deps.store->removeOutbox(entry.bookId, entry.uid, entry.revision);
528 17 return;
529 }
530
2/2
✓ Branch 22 → 23 taken 17 times.
✓ Branch 23 → 24 taken 17 times.
17 m_inFlight.insert(key(entry.bookId, entry.uid));
531
1/1
✓ Branch 25 → 26 taken 17 times.
17 const quint64 request = client->deleteResource(entry.href, entry.baseEtag);
532
1/3
✓ Branch 27 → 28 taken 17 times.
✗ Branch 89 → 90 not taken.
✗ Branch 89 → 91 not taken.
17 onResponse(client, request, this, [this, entry, client](const DavResponse &response) {
533 17 onDeleteFinished(entry, response, client);
534 17 });
535 17 return;
536 }
537
1/1
✓ Branch 30 → 31 taken 90 times.
90 const auto card = m_deps.store->card(entry.bookId, entry.uid);
538
1/2
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 35 taken 90 times.
90 if (!card) {
539 ✗ m_deps.store->removeOutbox(entry.bookId, entry.uid, entry.revision);
540 ✗ return;
541 }
542
2/2
✓ Branch 35 → 36 taken 90 times.
✓ Branch 36 → 37 taken 90 times.
90 m_inFlight.insert(key(entry.bookId, entry.uid));
543 90 const QString body = card->raw;
544
1/2
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 44 taken 90 times.
90 const QString url = entry.href.isEmpty() ? card->href : entry.href;
545
2/2
✓ Branch 47 → 48 taken 90 times.
✓ Branch 48 → 49 taken 90 times.
90 const quint64 request = client->putResource(url, body.toUtf8(), entry.baseEtag, entry.baseEtag.isEmpty());
546
9/9
✓ Branch 50 → 51 taken 90 times.
✓ Branch 54 → 55 taken 90 times.
✓ Branch 55 → 56 taken 90 times.
✓ Branch 56 → 57 taken 90 times.
✓ Branch 58 → 59 taken 21 times.
✓ Branch 58 → 60 taken 69 times.
✓ Branch 61 → 62 taken 90 times.
✓ Branch 65 → 52 taken 90 times.
✓ Branch 65 → 66 taken 90 times.
180 qCInfo(lcContactOutbox) << "PUT card revision" << entry.revision << (entry.baseEtag.isEmpty() ? "create" : "update");
547
1/5
✓ Branch 68 → 69 taken 90 times.
✗ Branch 107 → 108 not taken.
✗ Branch 107 → 109 not taken.
✗ Branch 110 → 111 not taken.
✗ Branch 110 → 112 not taken.
90 onResponse(client, request, this, [this, entry, body, client](const DavResponse &response) {
548
2/2
✓ Branch 2 → 3 taken 31 times.
✓ Branch 2 → 18 taken 55 times.
86 if (response.status == 412) {
549
5/5
✓ Branch 3 → 4 taken 31 times.
✓ Branch 7 → 8 taken 31 times.
✓ Branch 8 → 9 taken 31 times.
✓ Branch 12 → 5 taken 31 times.
✓ Branch 12 → 13 taken 31 times.
62 qCInfo(lcContactOutbox) << "Precondition failed — fetching the server version";
550 31 const quint64 get = client->getResource(response.url);
551
1/1
✓ Branch 15 → 16 taken 31 times.
62 onResponse(client, get, this, [this, entry](const DavResponse &server) { onServerVersion(entry, server); });
552 31 return;
553 }
554 55 onPutFinished(entry, body, response, client);
555 });
556 90 }
557
558 55 void ContactOutbox::onPutFinished(const ContactOutboxEntry &entry, const QString &body, const DavResponse &response,
559 CardDavClient *client) {
560
5/6
✓ Branch 2 → 3 taken 55 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 41 times.
✓ Branch 3 → 5 taken 14 times.
✓ Branch 4 → 5 taken 22 times.
✓ Branch 4 → 25 taken 19 times.
55 if (response.status == 200 || response.status == 201 || response.status == 204) {
561
1/2
✓ Branch 6 → 7 taken 36 times.
✗ Branch 6 → 8 not taken.
36 const QString href = response.location.isEmpty() ? response.url : response.location;
562
2/2
✓ Branch 11 → 12 taken 35 times.
✓ Branch 11 → 16 taken 1 time.
36 if (!response.etag.isEmpty()) {
563
1/1
✓ Branch 13 → 14 taken 35 times.
35 adoptPushed(entry, body, href, response.etag, QString());
564 35 return;
565 }
566 // CB-13: no ETag — the server changed the card while storing it.
567
1/1
✓ Branch 16 → 17 taken 1 time.
1 const quint64 get = client->getResource(href);
568
1/5
✓ Branch 20 → 21 taken 1 time.
✗ Branch 51 → 52 not taken.
✗ Branch 51 → 53 not taken.
✗ Branch 54 → 55 not taken.
✗ Branch 54 → 56 not taken.
1 onResponse(client, get, this, [this, entry, body, href](const DavResponse &server) {
569
3/6
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 6 not taken.
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 6 not taken.
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 12 not taken.
1 if (server.status == 200 && !server.etag.isEmpty())
570
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
1 adoptPushed(entry, body, href, server.etag, QString::fromUtf8(server.body));
571 else
572 ✗ adoptPushed(entry, body, href, QString(), QString());
573 1 });
574 1 return;
575 36 }
576
2/6
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 29 taken 19 times.
✗ Branch 27 → 28 not taken.
✗ Branch 27 → 29 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 36 taken 19 times.
19 if (response.status == 404 && !entry.baseEtag.isEmpty()) {
577 // F108-12: deleted only when a GET agrees (Nextcloud answers 404 to writes it refuses in shared books).
578 ✗ onResponse(client, client->getResource(response.url), this, [this, entry](const DavResponse &server) {
579 ✗ if (server.status == 200) retryLater(entry, tr("The server refuses changes to this card here (it still has it)."), true);
580 ✗ else if (server.status == 404 || server.status == 410) markConflict(entry, QString(), QString(), {QStringLiteral("deleted")});
581 ✗ else retryLater(entry, responseError(server), !retryableStatus(server.status));
582 ✗ });
583 ✗ return;
584 }
585 // S106-059: a local refusal (weak ETag, rejected URL) repeats on every
586 // attempt — it counts as permanent and reaches "failed" instead of being
587 // retried forever.
588
2/2
✓ Branch 41 → 42 taken 19 times.
✓ Branch 42 → 43 taken 19 times.
19 retryLater(entry, responseError(response),
589
4/4
✓ Branch 36 → 37 taken 16 times.
✓ Branch 36 → 39 taken 3 times.
✓ Branch 38 → 39 taken 13 times.
✓ Branch 38 → 40 taken 3 times.
19 response.refusedLocally || !retryableStatus(response.status));
590 }
591
592 // The pushed (or the server's repaired) card becomes the merge base.
593 36 void ContactOutbox::adoptPushed(const ContactOutboxEntry &entry, const QString &body, const QString &href,
594 const QString &etag, const QString &serverRaw) {
595 36 ContactStore *store = m_deps.store;
596
1/1
✓ Branch 2 → 3 taken 36 times.
36 finishInFlight(entry);
597
1/1
✓ Branch 3 → 4 taken 36 times.
36 auto card = store->card(entry.bookId, entry.uid);
598
1/1
✓ Branch 4 → 5 taken 36 times.
36 const bool current = store->removeOutbox(entry.bookId, entry.uid, entry.revision);
599
1/2
✓ Branch 6 → 7 taken 36 times.
✗ Branch 6 → 26 not taken.
36 if (card) {
600 36 card->href = href;
601 36 card->etag = etag;
602
2/2
✓ Branch 12 → 13 taken 35 times.
✓ Branch 12 → 14 taken 1 time.
36 card->baseRaw = serverRaw.isEmpty() ? body : serverRaw;
603
1/2
✓ Branch 17 → 18 taken 36 times.
✗ Branch 17 → 24 not taken.
36 if (current) {
604 36 card->syncState.clear();
605
2/2
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 24 taken 35 times.
36 if (!serverRaw.isEmpty())
606 1 card->raw = serverRaw; // what the server actually stores
607 }
608
1/1
✓ Branch 25 → 26 taken 36 times.
36 store->putCard(*card);
609 }
610
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 38 taken 36 times.
36 if (!current) {
611 // A newer local change arrived during the push: rebase it.
612 ✗ if (auto newer = store->outboxEntry(entry.bookId, entry.uid)) {
613 ✗ newer->baseEtag = etag;
614 ✗ newer->href = href;
615 ✗ store->updateOutbox(*newer);
616 ✗ }
617 }
618
8/9
✓ Branch 38 → 39 taken 36 times.
✓ Branch 42 → 43 taken 36 times.
✓ Branch 43 → 44 taken 36 times.
✓ Branch 44 → 45 taken 36 times.
✗ Branch 46 → 47 not taken.
✓ Branch 46 → 48 taken 36 times.
✓ Branch 49 → 50 taken 36 times.
✓ Branch 53 → 40 taken 36 times.
✓ Branch 53 → 54 taken 36 times.
72 qCInfo(lcContactOutbox) << "Pushed a card of book" << entry.bookId << (etag.isEmpty() ? "(ETag unknown)" : "");
619
3/5
✓ Branch 56 → 57 taken 36 times.
✓ Branch 59 → 60 taken 36 times.
✓ Branch 59 → 61 taken 36 times.
✗ Branch 75 → 76 not taken.
✗ Branch 75 → 77 not taken.
72 changed({key(entry.bookId, entry.uid)});
620
1/1
✓ Branch 61 → 62 taken 36 times.
36 flush(false);
621 72 }
622
623 17 void ContactOutbox::onDeleteFinished(const ContactOutboxEntry &entry, const DavResponse &response,
624 CardDavClient *client) {
625 17 ContactStore *store = m_deps.store;
626
6/6
✓ Branch 2 → 3 taken 15 times.
✓ Branch 2 → 5 taken 2 times.
✓ Branch 3 → 4 taken 9 times.
✓ Branch 3 → 5 taken 6 times.
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 38 taken 8 times.
17 if (response.status == 200 || response.status == 204 || response.status == 404) {
627
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 16 taken 9 times.
9 if (!store->removeOutbox(entry.bookId, entry.uid, entry.revision)) {
628 // Restored meanwhile: it no longer exists on the server — create it.
629 ✗ if (auto current = store->outboxEntry(entry.bookId, entry.uid)) {
630 ✗ current->baseEtag.clear();
631 ✗ store->updateOutbox(*current);
632 ✗ }
633 }
634
7/7
✓ Branch 16 → 17 taken 9 times.
✓ Branch 20 → 21 taken 9 times.
✓ Branch 21 → 22 taken 9 times.
✓ Branch 22 → 23 taken 9 times.
✓ Branch 23 → 24 taken 9 times.
✓ Branch 27 → 18 taken 9 times.
✓ Branch 27 → 28 taken 9 times.
18 qCInfo(lcContactOutbox) << "Deleted a card of book" << entry.bookId << "on the server";
635 9 finishInFlight(entry);
636
3/5
✓ Branch 31 → 32 taken 9 times.
✓ Branch 34 → 35 taken 9 times.
✓ Branch 34 → 36 taken 9 times.
✗ Branch 61 → 62 not taken.
✗ Branch 61 → 63 not taken.
18 changed({key(entry.bookId, entry.uid)});
637 9 flush(false);
638 9 return;
639 }
640
1/2
✓ Branch 38 → 39 taken 8 times.
✗ Branch 38 → 44 not taken.
8 if (response.status == 412) {
641 8 const quint64 get = client->getResource(response.url);
642
1/1
✓ Branch 41 → 42 taken 8 times.
8 onResponse(client, get, this, [this, entry](const DavResponse &server) {
643
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 5 taken 8 times.
8 if (server.status == 404) {
644 ✗ onDeleteFinished(entry, server, nullptr);
645 ✗ return;
646 }
647
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 11 taken 8 times.
8 if (server.status != 200) {
648 ✗ retryLater(entry, responseError(server), !retryableStatus(server.status));
649 ✗ return;
650 }
651 // D-22 (Sprint 107): the source of a move changed on the server — its
652 // changes travel into the moved card instead of a question.
653
2/2
✓ Branch 11 → 12 taken 4 times.
✓ Branch 11 → 16 taken 4 times.
8 if (entry.dependsOn != 0) {
654
2/2
✓ Branch 12 → 13 taken 4 times.
✓ Branch 13 → 14 taken 4 times.
4 mergeMovedCard(entry, QString::fromUtf8(server.body), server.etag);
655 4 return;
656 }
657
4/6
✓ Branch 20 → 21 taken 4 times.
✓ Branch 21 → 22 taken 4 times.
✓ Branch 25 → 26 taken 4 times.
✓ Branch 25 → 27 taken 4 times.
✗ Branch 42 → 43 not taken.
✗ Branch 42 → 44 not taken.
8 markConflict(entry, QString::fromUtf8(server.body), server.etag, {QStringLiteral("changed")});
658
1/5
✓ Branch 19 → 20 taken 4 times.
✗ Branch 48 → 49 not taken.
✗ Branch 48 → 52 not taken.
✗ Branch 50 → 51 not taken.
✗ Branch 50 → 52 not taken.
8 });
659 8 return;
660 }
661 ✗ retryLater(entry, responseError(response), !retryableStatus(response.status));
662 9 }
663
664 32 void ContactOutbox::onServerVersion(const ContactOutboxEntry &entry, const DavResponse &response) {
665 32 ContactStore *store = m_deps.store;
666
2/2
✓ Branch 2 → 3 taken 3 times.
✓ Branch 2 → 18 taken 29 times.
32 if (response.status == 404) {
667
3/5
✓ Branch 9 → 10 taken 3 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 14 → 16 taken 3 times.
✗ Branch 133 → 134 not taken.
✗ Branch 133 → 135 not taken.
6 markConflict(entry, QString(), QString(), {QStringLiteral("deleted")});
668 29 return;
669 }
670
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 24 taken 29 times.
29 if (response.status != 200) {
671 ✗ retryLater(entry, responseError(response), !retryableStatus(response.status));
672 ✗ return;
673 }
674
1/1
✓ Branch 24 → 25 taken 29 times.
29 const QString theirs = QString::fromUtf8(response.body);
675
1/1
✓ Branch 25 → 26 taken 29 times.
29 const auto card = store->card(entry.bookId, entry.uid);
676
3/6
✓ Branch 27 → 28 taken 29 times.
✗ Branch 27 → 31 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 29 times.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 45 taken 29 times.
29 if (!card || card->baseRaw.isEmpty()) {
677 ✗ markConflict(entry, theirs, response.etag, {QStringLiteral("created")});
678 ✗ return;
679 }
680
1/1
✓ Branch 47 → 48 taken 29 times.
29 const VCardMerge::Result merged = VCardMerge::merge(card->baseRaw, card->raw, theirs);
681
2/2
✓ Branch 48 → 49 taken 26 times.
✓ Branch 48 → 65 taken 3 times.
29 if (!merged.ok) {
682
1/1
✓ Branch 56 → 57 taken 26 times.
26 markConflict(entry, theirs, response.etag,
683
3/14
✗ Branch 50 → 51 not taken.
✓ Branch 50 → 55 taken 26 times.
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 62 taken 26 times.
✗ Branch 60 → 61 not taken.
✗ Branch 60 → 62 not taken.
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 64 taken 26 times.
✗ Branch 167 → 168 not taken.
✗ Branch 167 → 171 not taken.
✗ Branch 169 → 170 not taken.
✗ Branch 169 → 171 not taken.
✗ Branch 173 → 174 not taken.
✗ Branch 173 → 175 not taken.
52 merged.conflicts.isEmpty() ? QStringList{QStringLiteral("unreadable")} : merged.conflicts);
684 26 return;
685 }
686 // The merge becomes the local state; the next push sends it.
687 3 StoredCard rebased = *card;
688 3 rebased.raw = merged.merged;
689 3 rebased.baseRaw = theirs;
690 3 rebased.etag = response.etag;
691
1/1
✓ Branch 70 → 71 taken 3 times.
3 store->putCard(rebased);
692
1/1
✓ Branch 74 → 75 taken 3 times.
6 store->enqueueOutbox(entry.bookId, entry.uid, QStringLiteral("put"), entry.href, response.etag);
693
2/3
✓ Branch 77 → 78 taken 3 times.
✓ Branch 79 → 80 taken 3 times.
✗ Branch 79 → 84 not taken.
3 if (auto current = store->outboxEntry(entry.bookId, entry.uid)) {
694 3 current->baseEtag = response.etag;
695
1/1
✓ Branch 83 → 84 taken 3 times.
3 store->updateOutbox(*current);
696 3 }
697
1/1
✓ Branch 85 → 86 taken 3 times.
3 finishInFlight(entry);
698
6/6
✓ Branch 86 → 87 taken 3 times.
✓ Branch 90 → 91 taken 3 times.
✓ Branch 91 → 92 taken 3 times.
✓ Branch 92 → 93 taken 3 times.
✓ Branch 96 → 88 taken 3 times.
✓ Branch 96 → 97 taken 3 times.
6 qCInfo(lcContactOutbox) << "Merged a server change into a card of book" << entry.bookId;
699
3/5
✓ Branch 99 → 100 taken 3 times.
✓ Branch 102 → 103 taken 3 times.
✓ Branch 102 → 104 taken 3 times.
✗ Branch 201 → 202 not taken.
✗ Branch 201 → 203 not taken.
6 changed({key(entry.bookId, entry.uid)});
700
2/2
✓ Branch 104 → 105 taken 3 times.
✓ Branch 105 → 106 taken 3 times.
3 emit statusMessage(tr("Your change was combined with a change made on another device."));
701
1/1
✓ Branch 107 → 108 taken 3 times.
3 flush(true);
702 90 }
703
704 // ─── outcomes ──────────────────────────────────────────────
705
706 19 void ContactOutbox::retryLater(const ContactOutboxEntry &entry, const QString &error, bool permanent) {
707 19 ContactStore *store = m_deps.store;
708
1/1
✓ Branch 2 → 3 taken 19 times.
19 finishInFlight(entry);
709
1/1
✓ Branch 3 → 4 taken 19 times.
19 auto current = store->outboxEntry(entry.bookId, entry.uid);
710
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 19 times.
19 if (!current)
711 ✗ return;
712 19 current->attempts += 1;
713 // F-29 (SPRINT-104): the same bounded-retry family as the calendar
714 // outbox — a permanent rejection stops after kMaxPermanentAttempts
715 // with a visible "failed" state instead of retrying forever.
716
6/6
✓ Branch 8 → 9 taken 16 times.
✓ Branch 8 → 12 taken 3 times.
✓ Branch 10 → 11 taken 5 times.
✓ Branch 10 → 12 taken 11 times.
✓ Branch 13 → 14 taken 5 times.
✓ Branch 13 → 51 taken 14 times.
19 if (permanent && current->attempts >= kMaxPermanentAttempts) {
717 5 current->state = QStringLiteral("failed");
718 5 current->nextAttempt = 0;
719 5 current->lastError = error;
720
1/1
✓ Branch 25 → 26 taken 5 times.
5 store->updateOutbox(*current);
721
8/8
✓ Branch 26 → 27 taken 5 times.
✓ Branch 30 → 31 taken 5 times.
✓ Branch 31 → 32 taken 5 times.
✓ Branch 33 → 34 taken 5 times.
✓ Branch 34 → 35 taken 5 times.
✓ Branch 35 → 36 taken 5 times.
✓ Branch 39 → 28 taken 5 times.
✓ Branch 39 → 40 taken 5 times.
10 qCWarning(lcContactOutbox) << "Push gave up after" << current->attempts << "attempts:" << error;
722
3/5
✓ Branch 42 → 43 taken 5 times.
✓ Branch 45 → 46 taken 5 times.
✓ Branch 45 → 47 taken 5 times.
✗ Branch 104 → 105 not taken.
✗ Branch 104 → 106 not taken.
10 changed({key(entry.bookId, entry.uid)});
723
2/2
✓ Branch 47 → 48 taken 5 times.
✓ Branch 48 → 49 taken 5 times.
5 emit statusMessage(tr("The server keeps refusing this change. It stays in this address book as not yet on the server."));
724 5 return;
725 }
726
4/4
✓ Branch 51 → 52 taken 14 times.
✓ Branch 52 → 53 taken 11 times.
✓ Branch 52 → 54 taken 3 times.
✓ Branch 55 → 56 taken 3 times.
14 current->nextAttempt = m_deps.now() + (permanent ? 1800 : backoffSeconds(current->attempts));
727 14 current->lastError = error;
728
1/1
✓ Branch 61 → 62 taken 14 times.
14 store->updateOutbox(*current);
729
8/8
✓ Branch 62 → 63 taken 14 times.
✓ Branch 66 → 67 taken 14 times.
✓ Branch 67 → 68 taken 14 times.
✓ Branch 69 → 70 taken 14 times.
✓ Branch 70 → 71 taken 14 times.
✓ Branch 71 → 72 taken 14 times.
✓ Branch 75 → 64 taken 14 times.
✓ Branch 75 → 76 taken 14 times.
28 qCWarning(lcContactOutbox) << "Push failed (attempt" << current->attempts << "):" << error;
730
3/5
✓ Branch 78 → 79 taken 14 times.
✓ Branch 81 → 82 taken 14 times.
✓ Branch 81 → 83 taken 14 times.
✗ Branch 125 → 126 not taken.
✗ Branch 125 → 127 not taken.
28 changed({key(entry.bookId, entry.uid)});
731
5/5
✓ Branch 83 → 84 taken 11 times.
✓ Branch 83 → 85 taken 3 times.
✓ Branch 84 → 86 taken 11 times.
✓ Branch 85 → 86 taken 3 times.
✓ Branch 86 → 87 taken 14 times.
14 emit statusMessage(permanent ? tr("The server did not accept the change. MailJD will try again later.")
732 : tr("Not yet on the server. MailJD retries automatically."));
733
1/1
✓ Branch 88 → 89 taken 14 times.
14 scheduleRetry();
734 38 }
735
736 35 void ContactOutbox::markConflict(const ContactOutboxEntry &entry, const QString &serverRaw, const QString &serverEtag,
737 const QStringList &fields) {
738 35 ContactStore *store = m_deps.store;
739
1/1
✓ Branch 2 → 3 taken 35 times.
35 finishInFlight(entry);
740
1/1
✓ Branch 3 → 4 taken 35 times.
35 auto current = store->outboxEntry(entry.bookId, entry.uid);
741
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 35 times.
35 if (!current)
742 ✗ return;
743 35 current->state = QStringLiteral("conflict");
744 35 current->serverRaw = serverRaw;
745 35 current->serverEtag = serverEtag;
746 35 current->conflictFields = fields;
747
1/1
✓ Branch 21 → 22 taken 35 times.
35 store->updateOutbox(*current);
748
1/1
✓ Branch 25 → 26 taken 35 times.
70 store->setSyncState(entry.bookId, entry.uid, QStringLiteral("conflict"));
749
7/7
✓ Branch 28 → 29 taken 35 times.
✓ Branch 32 → 33 taken 35 times.
✓ Branch 33 → 34 taken 35 times.
✓ Branch 34 → 35 taken 35 times.
✓ Branch 36 → 37 taken 35 times.
✓ Branch 42 → 30 taken 35 times.
✓ Branch 42 → 43 taken 35 times.
70 qCWarning(lcContactOutbox) << "Conflict on a card of book" << entry.bookId << fields;
750
3/5
✓ Branch 45 → 46 taken 35 times.
✓ Branch 48 → 49 taken 35 times.
✓ Branch 48 → 50 taken 35 times.
✗ Branch 78 → 79 not taken.
✗ Branch 78 → 80 not taken.
70 changed({key(entry.bookId, entry.uid)});
751
2/2
✓ Branch 50 → 51 taken 35 times.
✓ Branch 51 → 52 taken 35 times.
35 emit statusMessage(tr("This contact was changed on another device. Open it to choose which version to keep."));
752 70 }
753
754 9 bool ContactOutbox::adoptServerVersion(const ContactOutboxEntry &entry, const QString &raw, const QString &etag) {
755 9 ContactStore *store = m_deps.store;
756
1/1
✓ Branch 2 → 3 taken 9 times.
9 auto card = store->card(entry.bookId, entry.uid);
757
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 9 times.
9 if (raw.isEmpty()) {
758 ✗ store->removeCard(entry.bookId, entry.uid);
759 } else {
760 9 StoredCard adopted = card.value_or(StoredCard{});
761 9 adopted.bookId = entry.bookId;
762 9 adopted.uid = entry.uid;
763
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 9 times.
9 adopted.href = entry.href.isEmpty() ? adopted.href : entry.href;
764 9 adopted.raw = raw;
765 9 adopted.baseRaw = raw;
766 9 adopted.etag = etag;
767 9 adopted.syncState.clear();
768
2/3
✓ Branch 18 → 19 taken 9 times.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 9 times.
9 if (!store->putCard(adopted))
769 ✗ return false;
770 9 }
771
1/1
✓ Branch 27 → 28 taken 9 times.
9 store->removeOutbox(entry.bookId, entry.uid);
772
1/1
✓ Branch 28 → 29 taken 9 times.
9 finishInFlight(entry);
773
3/5
✓ Branch 31 → 32 taken 9 times.
✓ Branch 34 → 35 taken 9 times.
✓ Branch 34 → 36 taken 9 times.
✗ Branch 46 → 47 not taken.
✗ Branch 46 → 48 not taken.
18 changed({key(entry.bookId, entry.uid)});
774 9 return true;
775 18 }
776