Line data Source code
1 : #include "test_helpers.h"
2 : #include "local_store.h"
3 : #include <stdlib.h>
4 : #include <string.h>
5 : #include <stdio.h>
6 : #include <unistd.h>
7 : #include <sys/stat.h>
8 :
9 : /*
10 : * These tests use a temporary directory as the account base.
11 : * local_store_init() with a dummy URL sets g_account_base.
12 : */
13 :
14 : /* ── Tests ────────────────────────────────────────────────────────── */
15 :
16 1 : static void test_label_idx_empty(void) {
17 : /* Re-init local store with a test directory */
18 : char url[256];
19 1 : snprintf(url, sizeof(url), "imaps://labelidx-test-%d.example.com", getpid());
20 1 : local_store_init(url, NULL);
21 :
22 : /* No .idx file exists → contains returns 0, count returns 0 */
23 1 : ASSERT(label_idx_contains("INBOX", "18c9b46d67a6123f") == 0,
24 : "empty: contains returns 0");
25 1 : ASSERT(label_idx_count("INBOX") == 0, "empty: count returns 0");
26 :
27 : /* Load empty → returns 0 count, no error */
28 1 : char (*uids)[17] = NULL;
29 1 : int count = 0;
30 1 : ASSERT(label_idx_load("INBOX", &uids, &count) == 0, "empty: load ok");
31 1 : ASSERT(count == 0, "empty: load count=0");
32 1 : free(uids);
33 : }
34 :
35 1 : static void test_label_idx_add_and_contains(void) {
36 : char url[256];
37 1 : snprintf(url, sizeof(url), "imaps://labelidx-add-%d.example.com", getpid());
38 1 : local_store_init(url, NULL);
39 :
40 1 : ASSERT(label_idx_add("INBOX", "0000000000000003") == 0, "add uid3 ok");
41 1 : ASSERT(label_idx_add("INBOX", "0000000000000001") == 0, "add uid1 ok");
42 1 : ASSERT(label_idx_add("INBOX", "0000000000000005") == 0, "add uid5 ok");
43 1 : ASSERT(label_idx_add("INBOX", "0000000000000002") == 0, "add uid2 ok");
44 1 : ASSERT(label_idx_add("INBOX", "0000000000000004") == 0, "add uid4 ok");
45 :
46 : /* Duplicate add should be a no-op */
47 1 : ASSERT(label_idx_add("INBOX", "0000000000000003") == 0, "add dup ok");
48 :
49 1 : ASSERT(label_idx_count("INBOX") == 5, "count=5");
50 1 : ASSERT(label_idx_contains("INBOX", "0000000000000001") == 1, "contains 1");
51 1 : ASSERT(label_idx_contains("INBOX", "0000000000000003") == 1, "contains 3");
52 1 : ASSERT(label_idx_contains("INBOX", "0000000000000005") == 1, "contains 5");
53 1 : ASSERT(label_idx_contains("INBOX", "0000000000000006") == 0, "not contains 6");
54 : }
55 :
56 1 : static void test_label_idx_remove(void) {
57 : char url[256];
58 1 : snprintf(url, sizeof(url), "imaps://labelidx-rm-%d.example.com", getpid());
59 1 : local_store_init(url, NULL);
60 :
61 1 : label_idx_add("TEST", "aaaa000000000001");
62 1 : label_idx_add("TEST", "aaaa000000000002");
63 1 : label_idx_add("TEST", "aaaa000000000003");
64 :
65 1 : ASSERT(label_idx_count("TEST") == 3, "before remove: 3");
66 :
67 1 : ASSERT(label_idx_remove("TEST", "aaaa000000000002") == 0, "remove mid ok");
68 1 : ASSERT(label_idx_count("TEST") == 2, "after remove: 2");
69 1 : ASSERT(label_idx_contains("TEST", "aaaa000000000002") == 0, "removed uid gone");
70 1 : ASSERT(label_idx_contains("TEST", "aaaa000000000001") == 1, "uid1 still there");
71 1 : ASSERT(label_idx_contains("TEST", "aaaa000000000003") == 1, "uid3 still there");
72 :
73 : /* Remove non-existent → no-op */
74 1 : ASSERT(label_idx_remove("TEST", "aaaa000000000009") == 0, "remove nonexist ok");
75 1 : ASSERT(label_idx_count("TEST") == 2, "still 2 after noop remove");
76 : }
77 :
78 1 : static void test_label_idx_load(void) {
79 : char url[256];
80 1 : snprintf(url, sizeof(url), "imaps://labelidx-load-%d.example.com", getpid());
81 1 : local_store_init(url, NULL);
82 :
83 1 : label_idx_add("STARRED", "bbbb000000000010");
84 1 : label_idx_add("STARRED", "bbbb000000000005");
85 1 : label_idx_add("STARRED", "bbbb000000000020");
86 :
87 1 : char (*uids)[17] = NULL;
88 1 : int count = 0;
89 1 : ASSERT(label_idx_load("STARRED", &uids, &count) == 0, "load ok");
90 1 : ASSERT(count == 3, "load count=3");
91 :
92 : /* Verify sorted order */
93 1 : ASSERT(strcmp(uids[0], "bbbb000000000005") == 0, "sorted[0]=05");
94 1 : ASSERT(strcmp(uids[1], "bbbb000000000010") == 0, "sorted[1]=10");
95 1 : ASSERT(strcmp(uids[2], "bbbb000000000020") == 0, "sorted[2]=20");
96 1 : free(uids);
97 : }
98 :
99 1 : static void test_label_idx_write_bulk(void) {
100 : char url[256];
101 1 : snprintf(url, sizeof(url), "imaps://labelidx-bulk-%d.example.com", getpid());
102 1 : local_store_init(url, NULL);
103 :
104 1 : char uids[4][17] = {
105 : "cccc000000000001",
106 : "cccc000000000002",
107 : "cccc000000000003",
108 : "cccc000000000004"
109 : };
110 1 : ASSERT(label_idx_write("BULK", (const char (*)[17])uids, 4) == 0, "write bulk ok");
111 1 : ASSERT(label_idx_count("BULK") == 4, "bulk count=4");
112 1 : ASSERT(label_idx_contains("BULK", "cccc000000000003") == 1, "bulk contains 3");
113 : }
114 :
115 1 : static void test_label_idx_hex_uids(void) {
116 : char url[256];
117 1 : snprintf(url, sizeof(url), "imaps://labelidx-hex-%d.example.com", getpid());
118 1 : local_store_init(url, NULL);
119 :
120 : /* Gmail-style hex UIDs */
121 1 : label_idx_add("Work", "18c9b46d67a6123f");
122 1 : label_idx_add("Work", "18c9b46d67a60001");
123 1 : label_idx_add("Work", "18c9b46d67a6ffff");
124 :
125 1 : ASSERT(label_idx_count("Work") == 3, "hex count=3");
126 1 : ASSERT(label_idx_contains("Work", "18c9b46d67a6123f") == 1, "hex contains");
127 1 : ASSERT(label_idx_contains("Work", "18c9b46d67a60001") == 1, "hex contains min");
128 1 : ASSERT(label_idx_contains("Work", "18c9b46d67a6ffff") == 1, "hex contains max");
129 1 : ASSERT(label_idx_contains("Work", "18c9b46d67a60000") == 0, "hex not contains");
130 :
131 : /* Verify sorted order */
132 1 : char (*uids)[17] = NULL;
133 1 : int count = 0;
134 1 : label_idx_load("Work", &uids, &count);
135 1 : ASSERT(count == 3, "hex load 3");
136 1 : ASSERT(strcmp(uids[0], "18c9b46d67a60001") == 0, "hex sorted[0]");
137 1 : ASSERT(strcmp(uids[1], "18c9b46d67a6123f") == 0, "hex sorted[1]");
138 1 : ASSERT(strcmp(uids[2], "18c9b46d67a6ffff") == 0, "hex sorted[2]");
139 1 : free(uids);
140 : }
141 :
142 1 : static void test_gmail_history_id(void) {
143 : char url[256];
144 1 : snprintf(url, sizeof(url), "imaps://labelidx-hist-%d.example.com", getpid());
145 1 : local_store_init(url, NULL);
146 :
147 1 : ASSERT(local_gmail_history_load() == NULL, "history: initially NULL");
148 :
149 1 : ASSERT(local_gmail_history_save("12345678") == 0, "history: save ok");
150 1 : char *hid = local_gmail_history_load();
151 1 : ASSERT(hid != NULL, "history: load not NULL");
152 1 : ASSERT(strcmp(hid, "12345678") == 0, "history: value matches");
153 1 : free(hid);
154 :
155 : /* Overwrite */
156 1 : ASSERT(local_gmail_history_save("99999999") == 0, "history: overwrite ok");
157 1 : hid = local_gmail_history_load();
158 1 : ASSERT(hid != NULL && strcmp(hid, "99999999") == 0, "history: overwritten value");
159 1 : free(hid);
160 : }
161 :
162 1 : static void test_label_idx_list(void) {
163 : char url[256];
164 1 : snprintf(url, sizeof(url), "imaps://labelidx-list-%d.example.com", getpid());
165 1 : local_store_init(url, NULL);
166 :
167 : /* Create a few label indexes */
168 1 : label_idx_add("INBOX", "0000000000000001");
169 1 : label_idx_add("SENT", "0000000000000002");
170 1 : label_idx_add("Work", "0000000000000003");
171 1 : label_idx_add("_nolabel","0000000000000004");
172 :
173 1 : char **labels = NULL;
174 1 : int count = 0;
175 1 : int rc = label_idx_list(&labels, &count);
176 1 : ASSERT(rc == 0, "label_idx_list: rc=0");
177 1 : ASSERT(count == 4, "label_idx_list: 4 labels");
178 :
179 : /* Check that all expected labels are present (order not guaranteed by readdir) */
180 1 : int found_inbox = 0, found_sent = 0, found_work = 0, found_nolabel = 0;
181 5 : for (int i = 0; i < count; i++) {
182 4 : if (strcmp(labels[i], "INBOX") == 0) found_inbox = 1;
183 4 : if (strcmp(labels[i], "SENT") == 0) found_sent = 1;
184 4 : if (strcmp(labels[i], "Work") == 0) found_work = 1;
185 4 : if (strcmp(labels[i], "_nolabel") == 0) found_nolabel = 1;
186 4 : free(labels[i]);
187 : }
188 1 : free(labels);
189 1 : ASSERT(found_inbox && found_sent && found_work && found_nolabel,
190 : "label_idx_list: all labels found");
191 : }
192 :
193 : /* ── local_hdr_get_labels tests (#26) ─────────────────────────────── */
194 :
195 1 : static void test_hdr_get_labels_normal(void) {
196 : char url[256];
197 1 : snprintf(url, sizeof(url), "imaps://hdr-labels-%d.example.com", getpid());
198 1 : local_store_init(url, NULL);
199 :
200 : /* Save a Gmail-style .hdr: from\tsubject\tdate\tlabels\tflags */
201 1 : const char *hdr = "Alice\tHello\t2026-04-17 10:00\tINBOX,STARRED,Work\t3";
202 1 : local_hdr_save("", "18c9b46d67a60001", hdr, strlen(hdr));
203 :
204 1 : char *labels = local_hdr_get_labels("", "18c9b46d67a60001");
205 1 : ASSERT(labels != NULL, "hdr_get_labels: not NULL");
206 1 : ASSERT(strcmp(labels, "INBOX,STARRED,Work") == 0, "hdr_get_labels: correct value");
207 1 : free(labels);
208 : }
209 :
210 1 : static void test_hdr_get_labels_missing(void) {
211 : char url[256];
212 1 : snprintf(url, sizeof(url), "imaps://hdr-labels-miss-%d.example.com", getpid());
213 1 : local_store_init(url, NULL);
214 :
215 1 : char *labels = local_hdr_get_labels("", "0000000000000099");
216 1 : ASSERT(labels == NULL, "hdr_get_labels missing: NULL");
217 : }
218 :
219 1 : static void test_hdr_get_labels_empty(void) {
220 : char url[256];
221 1 : snprintf(url, sizeof(url), "imaps://hdr-labels-empty-%d.example.com", getpid());
222 1 : local_store_init(url, NULL);
223 :
224 : /* Empty labels field */
225 1 : const char *hdr = "Bob\tSubj\t2026-04-17\t\t0";
226 1 : local_hdr_save("", "18c9b46d67a60002", hdr, strlen(hdr));
227 :
228 1 : char *labels = local_hdr_get_labels("", "18c9b46d67a60002");
229 1 : ASSERT(labels != NULL, "hdr_get_labels empty: not NULL");
230 1 : ASSERT(labels[0] == '\0', "hdr_get_labels empty: empty string");
231 1 : free(labels);
232 : }
233 :
234 1 : static void test_hdr_get_labels_single(void) {
235 : char url[256];
236 1 : snprintf(url, sizeof(url), "imaps://hdr-labels-single-%d.example.com", getpid());
237 1 : local_store_init(url, NULL);
238 :
239 1 : const char *hdr = "Carol\tTest\t2026-04-17\tINBOX\t1";
240 1 : local_hdr_save("", "18c9b46d67a60003", hdr, strlen(hdr));
241 :
242 1 : char *labels = local_hdr_get_labels("", "18c9b46d67a60003");
243 1 : ASSERT(labels != NULL && strcmp(labels, "INBOX") == 0,
244 : "hdr_get_labels single: INBOX");
245 1 : free(labels);
246 : }
247 :
248 1 : static void test_hdr_get_labels_many(void) {
249 : char url[256];
250 1 : snprintf(url, sizeof(url), "imaps://hdr-labels-many-%d.example.com", getpid());
251 1 : local_store_init(url, NULL);
252 :
253 1 : const char *hdr = "Dave\tMulti\t2026-04-17\tINBOX,UNREAD,STARRED,Work,Personal\t3";
254 1 : local_hdr_save("", "18c9b46d67a60004", hdr, strlen(hdr));
255 :
256 1 : char *labels = local_hdr_get_labels("", "18c9b46d67a60004");
257 1 : ASSERT(labels != NULL, "hdr_get_labels many: not NULL");
258 1 : ASSERT(strstr(labels, "INBOX") != NULL, "hdr_get_labels many: has INBOX");
259 1 : ASSERT(strstr(labels, "Work") != NULL, "hdr_get_labels many: has Work");
260 1 : ASSERT(strstr(labels, "Personal") != NULL, "hdr_get_labels many: has Personal");
261 1 : free(labels);
262 : }
263 :
264 : /* ── Archive / Trash label operations (#30) ──────────────────────── */
265 :
266 1 : static void test_archive_removes_inbox(void) {
267 : char url[256];
268 1 : snprintf(url, sizeof(url), "imaps://archive-test-%d.example.com", getpid());
269 1 : local_store_init(url, NULL);
270 :
271 1 : const char *uid = "18c9b46d67a6a001";
272 1 : label_idx_add("INBOX", uid);
273 1 : label_idx_add("Work", uid);
274 1 : ASSERT(label_idx_contains("INBOX", uid) == 1, "archive pre: in INBOX");
275 :
276 : /* Simulate archive: remove INBOX */
277 1 : label_idx_remove("INBOX", uid);
278 1 : ASSERT(label_idx_contains("INBOX", uid) == 0, "archive: removed from INBOX");
279 1 : ASSERT(label_idx_contains("Work", uid) == 1, "archive: Work preserved");
280 : }
281 :
282 1 : static void test_archive_nolabel_when_no_labels(void) {
283 : char url[256];
284 1 : snprintf(url, sizeof(url), "imaps://archive-nolabel-%d.example.com", getpid());
285 1 : local_store_init(url, NULL);
286 :
287 1 : const char *uid = "18c9b46d67a6a002";
288 1 : label_idx_add("INBOX", uid);
289 :
290 : /* Archive: remove INBOX, no other labels → should go to _nolabel */
291 1 : label_idx_remove("INBOX", uid);
292 : /* Check: not in INBOX or any other label → add to _nolabel */
293 1 : label_idx_add("_nolabel", uid);
294 1 : ASSERT(label_idx_contains("_nolabel", uid) == 1, "archive nolabel: in _nolabel");
295 : }
296 :
297 1 : static void test_trash_removes_all_labels(void) {
298 : char url[256];
299 1 : snprintf(url, sizeof(url), "imaps://trash-test-%d.example.com", getpid());
300 1 : local_store_init(url, NULL);
301 :
302 1 : const char *uid = "18c9b46d67a6a003";
303 1 : label_idx_add("INBOX", uid);
304 1 : label_idx_add("Work", uid);
305 1 : label_idx_add("STARRED", uid);
306 :
307 : /* Simulate trash: remove from all, add to _trash */
308 1 : char **all_labels = NULL;
309 1 : int all_count = 0;
310 1 : label_idx_list(&all_labels, &all_count);
311 4 : for (int i = 0; i < all_count; i++) {
312 3 : label_idx_remove(all_labels[i], uid);
313 3 : free(all_labels[i]);
314 : }
315 1 : free(all_labels);
316 1 : label_idx_add("_trash", uid);
317 :
318 1 : ASSERT(label_idx_contains("INBOX", uid) == 0, "trash: removed from INBOX");
319 1 : ASSERT(label_idx_contains("Work", uid) == 0, "trash: removed from Work");
320 1 : ASSERT(label_idx_contains("STARRED", uid) == 0, "trash: removed from STARRED");
321 1 : ASSERT(label_idx_contains("_trash", uid) == 1, "trash: in _trash");
322 : }
323 :
324 : /* ── Label picker toggle logic (#31) ─────────────────────────────── */
325 :
326 1 : static void test_label_toggle_add_remove(void) {
327 : char url[256];
328 1 : snprintf(url, sizeof(url), "imaps://label-toggle-%d.example.com", getpid());
329 1 : local_store_init(url, NULL);
330 :
331 1 : const char *uid = "18c9b46d67a6b001";
332 :
333 : /* Toggle ON: add label */
334 1 : label_idx_add("Work", uid);
335 1 : ASSERT(label_idx_contains("Work", uid) == 1, "toggle on: added");
336 :
337 : /* Toggle OFF: remove label */
338 1 : label_idx_remove("Work", uid);
339 1 : ASSERT(label_idx_contains("Work", uid) == 0, "toggle off: removed");
340 :
341 : /* Double add is no-op */
342 1 : label_idx_add("Work", uid);
343 1 : label_idx_add("Work", uid);
344 1 : ASSERT(label_idx_count("Work") == 1, "toggle: double add → count=1");
345 :
346 : /* Double remove is no-op */
347 1 : label_idx_remove("Work", uid);
348 1 : label_idx_remove("Work", uid);
349 1 : ASSERT(label_idx_count("Work") == 0, "toggle: double remove → count=0");
350 : }
351 :
352 : /* ── Trash label backup/restore (#25) ────────────────────────────── */
353 :
354 1 : static void test_trash_labels_save_load(void) {
355 : char url[256];
356 1 : snprintf(url, sizeof(url), "imaps://trash-lbl-%d.example.com", getpid());
357 1 : local_store_init(url, NULL);
358 :
359 1 : const char *uid = "18c9b46d67a6c001";
360 1 : ASSERT(local_trash_labels_load(uid) == NULL, "trash labels: initially NULL");
361 :
362 1 : ASSERT(local_trash_labels_save(uid, "INBOX,Work,STARRED") == 0,
363 : "trash labels: save ok");
364 1 : char *loaded = local_trash_labels_load(uid);
365 1 : ASSERT(loaded != NULL, "trash labels: load not NULL");
366 1 : ASSERT(strcmp(loaded, "INBOX,Work,STARRED") == 0, "trash labels: content matches");
367 1 : free(loaded);
368 :
369 : /* Remove */
370 1 : local_trash_labels_remove(uid);
371 1 : ASSERT(local_trash_labels_load(uid) == NULL, "trash labels: removed");
372 : }
373 :
374 1 : static void test_trash_restore_flow(void) {
375 : char url[256];
376 1 : snprintf(url, sizeof(url), "imaps://trash-flow-%d.example.com", getpid());
377 1 : local_store_init(url, NULL);
378 :
379 1 : const char *uid = "18c9b46d67a6c002";
380 :
381 : /* Pre-trash state: message in INBOX + Work */
382 1 : label_idx_add("INBOX", uid);
383 1 : label_idx_add("Work", uid);
384 :
385 : /* Save labels before trash */
386 1 : local_trash_labels_save(uid, "INBOX,Work,UNREAD");
387 :
388 : /* Trash: remove all labels, add _trash */
389 1 : label_idx_remove("INBOX", uid);
390 1 : label_idx_remove("Work", uid);
391 1 : label_idx_add("_trash", uid);
392 :
393 1 : ASSERT(label_idx_contains("INBOX", uid) == 0, "trash flow: not in INBOX");
394 1 : ASSERT(label_idx_contains("_trash", uid) == 1, "trash flow: in _trash");
395 :
396 : /* Untrash: restore saved labels (skip UNREAD) */
397 1 : label_idx_remove("_trash", uid);
398 1 : char *saved = local_trash_labels_load(uid);
399 1 : ASSERT(saved != NULL, "trash flow: saved labels exist");
400 : /* Parse and restore */
401 1 : char *tok = saved, *sep;
402 4 : while (tok && *tok) {
403 3 : sep = strchr(tok, ',');
404 3 : size_t tl = sep ? (size_t)(sep - tok) : strlen(tok);
405 : char lb[64];
406 3 : if (tl >= sizeof(lb)) tl = sizeof(lb) - 1;
407 3 : memcpy(lb, tok, tl); lb[tl] = '\0';
408 3 : if (strcmp(lb, "UNREAD") != 0)
409 2 : label_idx_add(lb, uid);
410 3 : tok = sep ? sep + 1 : NULL;
411 : }
412 1 : free(saved);
413 1 : local_trash_labels_remove(uid);
414 :
415 1 : ASSERT(label_idx_contains("INBOX", uid) == 1, "untrash: back in INBOX");
416 1 : ASSERT(label_idx_contains("Work", uid) == 1, "untrash: back in Work");
417 1 : ASSERT(label_idx_contains("_trash", uid) == 0, "untrash: not in _trash");
418 1 : ASSERT(local_trash_labels_load(uid) == NULL, "untrash: backup removed");
419 : }
420 :
421 : /* ── local_hdr_update_flags (#37) ─────────────────────────────────── */
422 :
423 1 : static void test_hdr_update_flags_basic(void) {
424 : char url[256];
425 1 : snprintf(url, sizeof(url), "imaps://hdr-updflags-%d.example.com", getpid());
426 1 : local_store_init(url, NULL);
427 :
428 : /* Save a .hdr with flags=3 (UNSEEN|FLAGGED) */
429 1 : const char *hdr = "Alice\tHello\t2026-04-18\tINBOX,UNREAD,STARRED\t3";
430 1 : local_hdr_save("", "18c9b46d67a6f001", hdr, strlen(hdr));
431 :
432 : /* Update flags to 0 (mark as read + unstar) */
433 1 : ASSERT(local_hdr_update_flags("", "18c9b46d67a6f001", 0) == 0,
434 : "hdr_update_flags: rc=0");
435 :
436 1 : char *loaded = local_hdr_load("", "18c9b46d67a6f001");
437 1 : ASSERT(loaded != NULL, "hdr_update_flags: load ok");
438 :
439 : /* Verify the last tab field is now "0" */
440 1 : char *last_tab = strrchr(loaded, '\t');
441 1 : ASSERT(last_tab != NULL, "hdr_update_flags: has tab");
442 1 : ASSERT(atoi(last_tab + 1) == 0, "hdr_update_flags: flags=0");
443 :
444 : /* Verify the rest is unchanged */
445 1 : ASSERT(strstr(loaded, "Alice") != NULL, "hdr_update_flags: from preserved");
446 1 : ASSERT(strstr(loaded, "Hello") != NULL, "hdr_update_flags: subject preserved");
447 1 : free(loaded);
448 : }
449 :
450 1 : static void test_hdr_update_flags_toggle_unseen(void) {
451 : char url[256];
452 1 : snprintf(url, sizeof(url), "imaps://hdr-toggle-%d.example.com", getpid());
453 1 : local_store_init(url, NULL);
454 :
455 1 : const char *uid = "18c9b46d67a6f002";
456 1 : const char *hdr = "Bob\tTest\t2026-04-18\tINBOX,UNREAD\t1";
457 1 : local_hdr_save("", uid, hdr, strlen(hdr));
458 :
459 : /* Mark as read: flags 1→0 */
460 1 : local_hdr_update_flags("", uid, 0);
461 1 : char *h1 = local_hdr_load("", uid);
462 1 : char *t1 = strrchr(h1, '\t');
463 1 : ASSERT(atoi(t1 + 1) == 0, "toggle unseen: now read (flags=0)");
464 1 : free(h1);
465 :
466 : /* Mark as unread again: flags 0→1 */
467 1 : local_hdr_update_flags("", uid, 1);
468 1 : char *h2 = local_hdr_load("", uid);
469 1 : char *t2 = strrchr(h2, '\t');
470 1 : ASSERT(atoi(t2 + 1) == 1, "toggle unseen: back to unread (flags=1)");
471 1 : free(h2);
472 : }
473 :
474 1 : static void test_hdr_update_flags_nonexistent(void) {
475 : char url[256];
476 1 : snprintf(url, sizeof(url), "imaps://hdr-noexist-%d.example.com", getpid());
477 1 : local_store_init(url, NULL);
478 :
479 1 : ASSERT(local_hdr_update_flags("", "0000000000000000", 5) == -1,
480 : "hdr_update_flags nonexistent: returns -1");
481 : }
482 :
483 : /* ── Gmail flag toggle end-to-end (#37) ──────────────────────────── */
484 :
485 1 : static void test_gmail_flag_toggle_unread(void) {
486 : /* Simulates what email_service does when user presses 'n' on Gmail */
487 : char url[256];
488 1 : snprintf(url, sizeof(url), "imaps://gmail-ftoggle-%d.example.com", getpid());
489 1 : local_store_init(url, NULL);
490 :
491 1 : const char *uid = "18c9b46d67a6e001";
492 :
493 : /* Initial state: message is in INBOX + UNREAD, flags=1 (UNSEEN) */
494 1 : const char *hdr = "Alice\tHello\t2026-04-18\tINBOX,UNREAD\t1";
495 1 : local_hdr_save("", uid, hdr, strlen(hdr));
496 1 : label_idx_add("INBOX", uid);
497 1 : label_idx_add("UNREAD", uid);
498 :
499 1 : ASSERT(label_idx_count("UNREAD") == 1, "toggle pre: UNREAD count=1");
500 :
501 : /* === User presses 'n' → mark as read === */
502 : /* 1. Update label index */
503 1 : label_idx_remove("UNREAD", uid);
504 : /* 2. Update .hdr flags (remove UNSEEN bit) */
505 1 : local_hdr_update_flags("", uid, 0);
506 :
507 : /* Verify: UNREAD count decreased */
508 1 : ASSERT(label_idx_count("UNREAD") == 0, "toggle read: UNREAD count=0");
509 1 : ASSERT(label_idx_contains("UNREAD", uid) == 0, "toggle read: not in UNREAD");
510 : /* Still in INBOX */
511 1 : ASSERT(label_idx_contains("INBOX", uid) == 1, "toggle read: still in INBOX");
512 : /* .hdr flags updated */
513 1 : char *h = local_hdr_load("", uid);
514 1 : char *lt = strrchr(h, '\t');
515 1 : ASSERT(atoi(lt + 1) == 0, "toggle read: .hdr flags=0");
516 1 : free(h);
517 :
518 : /* === User presses 'n' again → mark as unread === */
519 1 : label_idx_add("UNREAD", uid);
520 1 : local_hdr_update_flags("", uid, 1);
521 :
522 1 : ASSERT(label_idx_count("UNREAD") == 1, "toggle unread: UNREAD count=1");
523 1 : ASSERT(label_idx_contains("UNREAD", uid) == 1, "toggle unread: in UNREAD");
524 1 : h = local_hdr_load("", uid);
525 1 : lt = strrchr(h, '\t');
526 1 : ASSERT(atoi(lt + 1) == 1, "toggle unread: .hdr flags=1");
527 1 : free(h);
528 : }
529 :
530 1 : static void test_gmail_flag_toggle_starred(void) {
531 : char url[256];
532 1 : snprintf(url, sizeof(url), "imaps://gmail-fstar-%d.example.com", getpid());
533 1 : local_store_init(url, NULL);
534 :
535 1 : const char *uid = "18c9b46d67a6e002";
536 :
537 : /* Initial: not starred, flags=0 */
538 1 : const char *hdr = "Bob\tTest\t2026-04-18\tINBOX\t0";
539 1 : local_hdr_save("", uid, hdr, strlen(hdr));
540 1 : label_idx_add("INBOX", uid);
541 :
542 1 : ASSERT(label_idx_count("STARRED") == 0, "star pre: STARRED count=0");
543 :
544 : /* User presses 'f' → add star */
545 1 : label_idx_add("STARRED", uid);
546 1 : local_hdr_update_flags("", uid, 2); /* MSG_FLAG_FLAGGED = 2 */
547 :
548 1 : ASSERT(label_idx_count("STARRED") == 1, "star on: STARRED count=1");
549 1 : ASSERT(label_idx_contains("STARRED", uid) == 1, "star on: in STARRED");
550 :
551 : /* User presses 'f' again → remove star */
552 1 : label_idx_remove("STARRED", uid);
553 1 : local_hdr_update_flags("", uid, 0);
554 :
555 1 : ASSERT(label_idx_count("STARRED") == 0, "star off: STARRED count=0");
556 1 : ASSERT(label_idx_contains("STARRED", uid) == 0, "star off: not in STARRED");
557 : }
558 :
559 1 : static void test_gmail_flag_toggle_multiple_msgs(void) {
560 : char url[256];
561 1 : snprintf(url, sizeof(url), "imaps://gmail-fmulti-%d.example.com", getpid());
562 1 : local_store_init(url, NULL);
563 :
564 : /* 3 unread messages */
565 4 : for (int i = 1; i <= 3; i++) {
566 : char uid[17];
567 3 : snprintf(uid, sizeof(uid), "18c9b46d67a6d%03d", i);
568 : char hdr[128];
569 3 : snprintf(hdr, sizeof(hdr), "User%d\tMsg%d\t2026-04-18\tINBOX,UNREAD\t1", i, i);
570 3 : local_hdr_save("", uid, hdr, strlen(hdr));
571 3 : label_idx_add("INBOX", uid);
572 3 : label_idx_add("UNREAD", uid);
573 : }
574 :
575 1 : ASSERT(label_idx_count("UNREAD") == 3, "multi pre: UNREAD=3");
576 :
577 : /* Mark first message as read */
578 1 : label_idx_remove("UNREAD", "18c9b46d67a6d001");
579 1 : local_hdr_update_flags("", "18c9b46d67a6d001", 0);
580 1 : ASSERT(label_idx_count("UNREAD") == 2, "multi: UNREAD=2 after one read");
581 :
582 : /* Mark second as read */
583 1 : label_idx_remove("UNREAD", "18c9b46d67a6d002");
584 1 : local_hdr_update_flags("", "18c9b46d67a6d002", 0);
585 1 : ASSERT(label_idx_count("UNREAD") == 1, "multi: UNREAD=1 after two read");
586 :
587 : /* Mark first as unread again */
588 1 : label_idx_add("UNREAD", "18c9b46d67a6d001");
589 1 : local_hdr_update_flags("", "18c9b46d67a6d001", 1);
590 1 : ASSERT(label_idx_count("UNREAD") == 2, "multi: UNREAD=2 after re-unread");
591 : }
592 :
593 : /* ── Short UID regression (GML-short-id) ─────────────────────────── */
594 :
595 : /*
596 : * Regression test: Gmail message IDs can be shorter than 16 characters
597 : * (e.g. 13, 14, 15 hex chars). The old label_idx_write used "%.16s\n"
598 : * which produced variable-length records; the fixed-size fread then read
599 : * the newline into the UID, embedding "\n" in the string and breaking
600 : * URL construction.
601 : *
602 : * The fix: label_idx_write always writes exactly 16 NUL-padded bytes +
603 : * '\n' = 17 bytes per record; label_idx_load uses fgets which handles
604 : * both old (variable) and new (fixed) formats transparently.
605 : */
606 1 : static void test_label_idx_short_ids(void) {
607 : char url[256];
608 1 : snprintf(url, sizeof(url), "imaps://labelidx-short-%d.example.com", getpid());
609 1 : local_store_init(url, NULL);
610 :
611 : /* IDs from real-world Gmail error report (13–15 chars) */
612 1 : ASSERT(label_idx_add("INBOX", "d99192cdf1df3") == 0, "add 13-char id");
613 1 : ASSERT(label_idx_add("INBOX", "db185943f7560a") == 0, "add 14-char id");
614 1 : ASSERT(label_idx_add("INBOX", "e169e066dd2f3ee") == 0, "add 15-char id");
615 1 : ASSERT(label_idx_add("INBOX", "18c9b46d67a61234")== 0, "add 16-char id");
616 :
617 1 : ASSERT(label_idx_count("INBOX") == 4, "short ids: count=4");
618 :
619 : /* contains() must find each ID — no embedded newlines in stored keys */
620 1 : ASSERT(label_idx_contains("INBOX", "d99192cdf1df3") == 1, "contains 13-char");
621 1 : ASSERT(label_idx_contains("INBOX", "db185943f7560a") == 1, "contains 14-char");
622 1 : ASSERT(label_idx_contains("INBOX", "e169e066dd2f3ee") == 1, "contains 15-char");
623 1 : ASSERT(label_idx_contains("INBOX", "18c9b46d67a61234")== 1, "contains 16-char");
624 :
625 : /* Load and verify no embedded newlines or CR in any stored UID */
626 1 : char (*uids)[17] = NULL;
627 1 : int count = 0;
628 1 : ASSERT(label_idx_load("INBOX", &uids, &count) == 0, "load short ids ok");
629 1 : ASSERT(count == 4, "load count=4");
630 1 : int clean = 1;
631 5 : for (int i = 0; i < count; i++) {
632 4 : if (strchr(uids[i], '\n') || strchr(uids[i], '\r'))
633 0 : clean = 0;
634 : }
635 1 : ASSERT(clean, "no embedded newlines in loaded short IDs");
636 1 : free(uids);
637 :
638 : /* Remove and re-add cycle must work for short IDs */
639 1 : ASSERT(label_idx_remove("INBOX", "db185943f7560a") == 0, "remove 14-char ok");
640 1 : ASSERT(label_idx_contains("INBOX", "db185943f7560a") == 0, "14-char gone");
641 1 : ASSERT(label_idx_count("INBOX") == 3, "count=3 after remove");
642 :
643 1 : ASSERT(label_idx_add("INBOX", "db185943f7560a") == 0, "re-add 14-char ok");
644 1 : ASSERT(label_idx_contains("INBOX", "db185943f7560a") == 1, "14-char back");
645 1 : ASSERT(label_idx_count("INBOX") == 4, "count=4 after re-add");
646 : }
647 :
648 : /*
649 : * Verify that a label index file written in the OLD variable-length format
650 : * (where short IDs produced records shorter than 17 bytes) is read back
651 : * correctly by the new fgets-based loader.
652 : */
653 1 : static void test_label_idx_old_format_compat(void) {
654 : char url[256];
655 1 : snprintf(url, sizeof(url), "imaps://labelidx-compat-%d.example.com", getpid());
656 1 : local_store_init(url, NULL);
657 :
658 : /* Seed two 16-char IDs in new format first (establishes the labels/ dir) */
659 1 : label_idx_add("MIGR", "aaaa000000000001");
660 1 : label_idx_add("MIGR", "aaaa000000000002");
661 :
662 : /* Overwrite with an old-format file: variable-length lines (no NUL padding).
663 : * We do this by locating the .idx file via the account base.
664 : * label_idx_write path: <data>/email-cli/accounts/imap.<host>/labels/MIGR.idx */
665 1 : const char *home = getenv("HOME");
666 1 : ASSERT(home != NULL, "compat: HOME env set");
667 : char idxpath[2048];
668 1 : snprintf(idxpath, sizeof(idxpath),
669 : "%s/.local/share/email-cli/accounts/"
670 : "imap.labelidx-compat-%d.example.com/labels/MIGR.idx",
671 : home, getpid());
672 :
673 : /* Write old-format file manually: variable-length lines (no NUL padding),
674 : * in sorted order (old code also kept files sorted via label_idx_add). */
675 1 : FILE *fp = fopen(idxpath, "w");
676 1 : ASSERT(fp != NULL, "compat: open idx for write");
677 : /* 16-char ID: 16 bytes + '\n' = 17 bytes (same as new format for full IDs) */
678 1 : fputs("18c9b46d67a61234\n", fp);
679 : /* 13-char ID: 13 bytes + '\n' = 14 bytes (old broken record) */
680 1 : fputs("d99192cdf1df3\n", fp);
681 : /* 15-char ID: 15 bytes + '\n' = 16 bytes (old broken record) */
682 1 : fputs("e169e066dd2f3ee\n", fp);
683 1 : fclose(fp);
684 :
685 : /* Load must produce 3 clean UIDs */
686 1 : char (*uids)[17] = NULL;
687 1 : int count = 0;
688 1 : ASSERT(label_idx_load("MIGR", &uids, &count) == 0, "compat: load ok");
689 1 : ASSERT(count == 3, "compat: count=3");
690 1 : int clean = 1;
691 4 : for (int i = 0; i < count; i++) {
692 3 : if (strchr(uids[i], '\n') || strchr(uids[i], '\r'))
693 0 : clean = 0;
694 : }
695 1 : ASSERT(clean, "compat: no embedded newlines after migration");
696 1 : free(uids);
697 :
698 : /* contains() must work after migration */
699 1 : ASSERT(label_idx_contains("MIGR", "e169e066dd2f3ee") == 1, "compat: 15-char found");
700 1 : ASSERT(label_idx_contains("MIGR", "d99192cdf1df3") == 1, "compat: 13-char found");
701 1 : ASSERT(label_idx_contains("MIGR", "18c9b46d67a61234")== 1, "compat: 16-char found");
702 : }
703 :
704 : /* ── Registration ─────────────────────────────────────────────────── */
705 :
706 1 : void test_label_idx(void) {
707 1 : RUN_TEST(test_label_idx_empty);
708 1 : RUN_TEST(test_label_idx_add_and_contains);
709 1 : RUN_TEST(test_label_idx_remove);
710 1 : RUN_TEST(test_label_idx_load);
711 1 : RUN_TEST(test_label_idx_write_bulk);
712 1 : RUN_TEST(test_label_idx_hex_uids);
713 1 : RUN_TEST(test_gmail_history_id);
714 1 : RUN_TEST(test_label_idx_list);
715 1 : RUN_TEST(test_hdr_get_labels_normal);
716 1 : RUN_TEST(test_hdr_get_labels_missing);
717 1 : RUN_TEST(test_hdr_get_labels_empty);
718 1 : RUN_TEST(test_hdr_get_labels_single);
719 1 : RUN_TEST(test_hdr_get_labels_many);
720 1 : RUN_TEST(test_archive_removes_inbox);
721 1 : RUN_TEST(test_archive_nolabel_when_no_labels);
722 1 : RUN_TEST(test_trash_removes_all_labels);
723 1 : RUN_TEST(test_label_toggle_add_remove);
724 1 : RUN_TEST(test_trash_labels_save_load);
725 1 : RUN_TEST(test_trash_restore_flow);
726 1 : RUN_TEST(test_hdr_update_flags_basic);
727 1 : RUN_TEST(test_hdr_update_flags_toggle_unseen);
728 1 : RUN_TEST(test_hdr_update_flags_nonexistent);
729 1 : RUN_TEST(test_gmail_flag_toggle_unread);
730 1 : RUN_TEST(test_gmail_flag_toggle_starred);
731 1 : RUN_TEST(test_gmail_flag_toggle_multiple_msgs);
732 1 : RUN_TEST(test_label_idx_short_ids);
733 1 : RUN_TEST(test_label_idx_old_format_compat);
734 1 : }
|