Line data Source code
1 : #include "test_helpers.h"
2 : #include "local_store.h"
3 : #include "fs_util.h"
4 : #include "raii.h"
5 : #include <string.h>
6 : #include <strings.h>
7 : #include <stdlib.h>
8 : #include <unistd.h>
9 : #include <stdio.h>
10 :
11 : /* ── Helper to set up a clean test environment ────────────────────────── */
12 :
13 20 : static void setup_test_env(const char *home) {
14 20 : setenv("HOME", home, 1);
15 20 : unsetenv("XDG_DATA_HOME");
16 20 : local_store_init("imaps://test.example.com", "testuser");
17 20 : }
18 :
19 : /* ── Message store tests ─────────────────────────────────────────────── */
20 :
21 1 : void test_local_msg_store(void) {
22 1 : char *old_home = getenv("HOME");
23 1 : setup_test_env("/tmp/email-cli-store-test");
24 :
25 1 : const char *folder = "INBOX";
26 1 : const char *uid = "0000000000000137";
27 :
28 : /* Pre-clean */
29 1 : unlink("/tmp/email-cli-store-test/.local/share/email-cli/accounts/"
30 : "testuser/store/INBOX/7/3/0000000000000137.eml");
31 :
32 : /* 1. Not stored initially */
33 1 : ASSERT(local_msg_exists(folder, uid) == 0,
34 : "local_msg_exists: should be 0 before save");
35 :
36 : /* 2. Save and verify */
37 1 : const char *content = "From: test@example.com\r\nDate: Mon, 30 Mar 2026 12:00:00 +0000\r\n"
38 : "Subject: Test\r\n\r\nHello!";
39 1 : int rc = local_msg_save(folder, uid, content, strlen(content));
40 1 : ASSERT(rc == 0, "local_msg_save: should return 0");
41 1 : ASSERT(local_msg_exists(folder, uid) == 1,
42 : "local_msg_exists: should be 1 after save");
43 :
44 : /* 3. Load and verify content */
45 : {
46 2 : RAII_STRING char *loaded = local_msg_load(folder, uid);
47 1 : ASSERT(loaded != NULL, "local_msg_load: should not be NULL");
48 1 : ASSERT(strcmp(loaded, content) == 0, "local_msg_load: content mismatch");
49 : }
50 :
51 : /* 4. Different UIDs are independent */
52 1 : ASSERT(local_msg_exists(folder, "0000000000000099") == 0,
53 : "local_msg_exists: UID 99 should not exist");
54 :
55 : /* 5. Reverse digit bucketing: UID 42 → 2/4/ */
56 1 : const char *c2 = "Subject: UID 42\r\n\r\nBody";
57 1 : local_msg_save(folder, "0000000000000042", c2, strlen(c2));
58 1 : ASSERT(local_msg_exists(folder, "0000000000000042") == 1,
59 : "local_msg_exists: UID 42 after save (bucket 2/4)");
60 :
61 : /* 6. UID 5 → 5/0/ (single digit pads to 0) */
62 1 : const char *c3 = "Subject: UID 5\r\n\r\nBody";
63 1 : local_msg_save(folder, "0000000000000005", c3, strlen(c3));
64 1 : ASSERT(local_msg_exists(folder, "0000000000000005") == 1,
65 : "local_msg_exists: UID 5 after save (bucket 5/0)");
66 :
67 : /* Cleanup */
68 1 : unlink("/tmp/email-cli-store-test/.local/share/email-cli/accounts/"
69 : "testuser/store/INBOX/7/3/0000000000000137.eml");
70 1 : unlink("/tmp/email-cli-store-test/.local/share/email-cli/accounts/"
71 : "testuser/store/INBOX/2/4/0000000000000042.eml");
72 1 : unlink("/tmp/email-cli-store-test/.local/share/email-cli/accounts/"
73 : "testuser/store/INBOX/5/0/0000000000000005.eml");
74 :
75 1 : if (old_home) setenv("HOME", old_home, 1);
76 0 : else unsetenv("HOME");
77 : }
78 :
79 : /* ── Header eviction tests ───────────────────────────────────────────── */
80 :
81 1 : void test_local_hdr_evict(void) {
82 1 : char *old_home = getenv("HOME");
83 1 : setup_test_env("/tmp/email-cli-hdr-evict-test");
84 :
85 1 : const char *folder = "INBOX";
86 :
87 1 : local_hdr_save(folder, "0000000000000010", "header-10", 9);
88 1 : local_hdr_save(folder, "0000000000000020", "header-20", 9);
89 :
90 1 : ASSERT(local_hdr_exists(folder, "0000000000000010") == 1, "hdr_evict: UID 10 before");
91 1 : ASSERT(local_hdr_exists(folder, "0000000000000020") == 1, "hdr_evict: UID 20 before");
92 :
93 1 : char keep[][17] = {"0000000000000020"};
94 1 : local_hdr_evict_stale(folder, (const char (*)[17])keep, 1);
95 :
96 1 : ASSERT(local_hdr_exists(folder, "0000000000000010") == 0, "hdr_evict: UID 10 evicted");
97 1 : ASSERT(local_hdr_exists(folder, "0000000000000020") == 1, "hdr_evict: UID 20 kept");
98 :
99 : /* Cleanup */
100 1 : local_hdr_evict_stale(folder, NULL, 0);
101 :
102 1 : if (old_home) setenv("HOME", old_home, 1);
103 0 : else unsetenv("HOME");
104 : }
105 :
106 : /* ── Index tests ─────────────────────────────────────────────────────── */
107 :
108 1 : void test_local_index(void) {
109 1 : char *old_home = getenv("HOME");
110 1 : setup_test_env("/tmp/email-cli-index-test");
111 :
112 1 : const char *msg =
113 : "From: noreply@github.com\r\n"
114 : "Date: Tue, 15 Mar 2026 10:30:00 +0100\r\n"
115 : "Subject: Test\r\n\r\nBody";
116 :
117 1 : int rc = local_index_update("INBOX", "0000000000000042", msg);
118 1 : ASSERT(rc == 0, "local_index_update: should return 0");
119 :
120 : /* Verify from index exists */
121 1 : const char *from_path =
122 : "/tmp/email-cli-index-test/.local/share/email-cli/accounts/"
123 : "testuser/index/from/github.com/noreply";
124 : {
125 2 : RAII_FILE FILE *fp = fopen(from_path, "r");
126 1 : ASSERT(fp != NULL, "from index file should exist");
127 1 : if (fp) {
128 : char line[256];
129 1 : ASSERT(fgets(line, sizeof(line), fp) != NULL,
130 : "from index should have a line");
131 1 : ASSERT(strstr(line, "INBOX/0000000000000042") != NULL,
132 : "from index should contain INBOX/0000000000000042");
133 : }
134 : }
135 :
136 : /* Verify date index exists */
137 1 : const char *date_path =
138 : "/tmp/email-cli-index-test/.local/share/email-cli/accounts/"
139 : "testuser/index/date/2026/03/15";
140 : {
141 2 : RAII_FILE FILE *fp = fopen(date_path, "r");
142 1 : ASSERT(fp != NULL, "date index file should exist");
143 1 : if (fp) {
144 : char line[256];
145 1 : ASSERT(fgets(line, sizeof(line), fp) != NULL,
146 : "date index should have a line");
147 1 : ASSERT(strstr(line, "INBOX/0000000000000042") != NULL,
148 : "date index should contain INBOX/0000000000000042");
149 : }
150 : }
151 :
152 : /* Duplicate should not be added */
153 1 : local_index_update("INBOX", "0000000000000042", msg);
154 : {
155 2 : RAII_FILE FILE *fp = fopen(from_path, "r");
156 1 : int count = 0;
157 : char line[256];
158 2 : while (fp && fgets(line, sizeof(line), fp)) count++;
159 1 : ASSERT(count == 1, "from index should have exactly 1 entry (no dupes)");
160 : }
161 :
162 : /* Cleanup */
163 1 : if (from_path) unlink(from_path);
164 1 : if (date_path) unlink(date_path);
165 :
166 1 : if (old_home) setenv("HOME", old_home, 1);
167 0 : else unsetenv("HOME");
168 : }
169 :
170 : /* ── Manifest tests ──────────────────────────────────────────────────── */
171 :
172 1 : void test_manifest(void) {
173 1 : char *old_home = getenv("HOME");
174 1 : setup_test_env("/tmp/email-cli-manifest-test");
175 :
176 : /* Pre-clean */
177 1 : unlink("/tmp/email-cli-manifest-test/.local/share/email-cli/"
178 : "accounts/testuser/manifests/INBOX.tsv");
179 :
180 : /* 1. Load non-existent manifest returns NULL */
181 1 : Manifest *m = manifest_load("INBOX");
182 1 : ASSERT(m == NULL, "manifest_load: NULL for missing file");
183 :
184 : /* 2. Create manifest, add entries, save */
185 1 : m = calloc(1, sizeof(Manifest));
186 1 : ASSERT(m != NULL, "manifest: calloc");
187 1 : manifest_upsert(m, "0000000000000042", strdup("Alice <alice@example.com>"),
188 : strdup("Hello World"),
189 : strdup("2024-03-15 10:00"), MSG_FLAG_UNSEEN);
190 1 : manifest_upsert(m, "0000000000000137", strdup("Bob <bob@test.org>"),
191 : strdup("Re: Meeting"),
192 : strdup("2024-03-16 14:30"), 0);
193 1 : ASSERT(m->count == 2, "manifest: 2 entries after upsert");
194 :
195 1 : int rc = manifest_save("INBOX", m);
196 1 : ASSERT(rc == 0, "manifest_save: returns 0");
197 :
198 : /* 3. Load back and verify */
199 1 : manifest_free(m);
200 1 : m = manifest_load("INBOX");
201 1 : ASSERT(m != NULL, "manifest_load: not NULL after save");
202 1 : ASSERT(m->count == 2, "manifest_load: 2 entries");
203 :
204 1 : ManifestEntry *e42 = manifest_find(m, "0000000000000042");
205 1 : ASSERT(e42 != NULL, "manifest_find: UID 42 found");
206 1 : ASSERT(strcmp(e42->from, "Alice <alice@example.com>") == 0,
207 : "manifest: UID 42 from correct");
208 1 : ASSERT(strcmp(e42->subject, "Hello World") == 0,
209 : "manifest: UID 42 subject correct");
210 1 : ASSERT(strcmp(e42->date, "2024-03-15 10:00") == 0,
211 : "manifest: UID 42 date correct");
212 :
213 1 : ManifestEntry *e137 = manifest_find(m, "0000000000000137");
214 1 : ASSERT(e137 != NULL, "manifest_find: UID 137 found");
215 1 : ASSERT(strcmp(e137->subject, "Re: Meeting") == 0,
216 : "manifest: UID 137 subject correct");
217 :
218 : /* 4. Upsert updates existing entry */
219 1 : manifest_upsert(m, "0000000000000042", strdup("Alice Updated"),
220 : strdup("Updated Subject"),
221 : strdup("2024-03-15 11:00"), 0 /* no flags */);
222 1 : ASSERT(m->count == 2, "manifest: still 2 after upsert-update");
223 1 : e42 = manifest_find(m, "0000000000000042");
224 1 : ASSERT(strcmp(e42->subject, "Updated Subject") == 0,
225 : "manifest: upsert updated subject");
226 :
227 : /* 5. manifest_find returns NULL for missing UID */
228 1 : ASSERT(manifest_find(m, "0000000000000999") == NULL,
229 : "manifest_find: NULL for missing UID");
230 :
231 : /* 6. manifest_retain keeps only specified UIDs */
232 1 : char keep[][17] = {"0000000000000137"};
233 1 : manifest_retain(m, (const char (*)[17])keep, 1);
234 1 : ASSERT(m->count == 1, "manifest_retain: 1 entry after retain");
235 1 : ASSERT(manifest_find(m, "0000000000000137") != NULL, "manifest_retain: UID 137 kept");
236 1 : ASSERT(manifest_find(m, "0000000000000042") == NULL, "manifest_retain: UID 42 removed");
237 :
238 : /* 7. Nested folder manifest */
239 1 : Manifest *m2 = calloc(1, sizeof(Manifest));
240 1 : manifest_upsert(m2, "0000000000000001", strdup("Test"), strdup("Nested"), strdup("2024-01-01 00:00"), 0 /* no flags */);
241 1 : ASSERT(manifest_save("munka/ai", m2) == 0, "manifest_save: nested folder");
242 1 : manifest_free(m2);
243 1 : m2 = manifest_load("munka/ai");
244 1 : ASSERT(m2 != NULL, "manifest_load: nested folder");
245 1 : ASSERT(m2->count == 1, "manifest: nested has 1 entry");
246 1 : manifest_free(m2);
247 :
248 : /* Cleanup */
249 1 : manifest_free(m);
250 1 : if (old_home) setenv("HOME", old_home, 1);
251 0 : else unsetenv("HOME");
252 : }
253 :
254 : /* ── UI preferences tests ────────────────────────────────────────────── */
255 :
256 1 : void test_ui_prefs(void) {
257 1 : char *old_home = getenv("HOME");
258 1 : setenv("HOME", "/tmp/email-cli-ui-pref-test-home", 1);
259 1 : unsetenv("XDG_DATA_HOME");
260 1 : unlink("/tmp/email-cli-ui-pref-test-home/.local/share/email-cli/ui.ini");
261 :
262 1 : ASSERT(ui_pref_get_int("folder_view_mode", 1) == 1,
263 : "ui_pref_get_int: missing key should return default 1");
264 1 : ASSERT(ui_pref_get_int("folder_view_mode", 0) == 0,
265 : "ui_pref_get_int: missing key should return default 0");
266 :
267 1 : ASSERT(ui_pref_set_int("folder_view_mode", 0) == 0,
268 : "ui_pref_set_int: should return 0");
269 1 : ASSERT(ui_pref_get_int("folder_view_mode", 1) == 0,
270 : "ui_pref_get_int: should return stored value 0");
271 :
272 1 : ASSERT(ui_pref_set_int("folder_view_mode", 1) == 0,
273 : "ui_pref_set_int: overwrite should return 0");
274 1 : ASSERT(ui_pref_get_int("folder_view_mode", 0) == 1,
275 : "ui_pref_get_int: should return updated value 1");
276 :
277 1 : ASSERT(ui_pref_set_int("other_pref", 42) == 0,
278 : "ui_pref_set_int: second key should return 0");
279 1 : ASSERT(ui_pref_get_int("folder_view_mode", 0) == 1,
280 : "ui_pref_get_int: first key intact");
281 1 : ASSERT(ui_pref_get_int("other_pref", 0) == 42,
282 : "ui_pref_get_int: second key should return 42");
283 :
284 1 : ASSERT(ui_pref_get_int("no_such_key", 7) == 7,
285 : "ui_pref_get_int: unknown key should return default");
286 :
287 1 : unlink("/tmp/email-cli-ui-pref-test-home/.local/share/email-cli/ui.ini");
288 :
289 : /* ui_pref_get_str / ui_pref_set_str */
290 1 : ASSERT(ui_pref_get_str("str_key") == NULL,
291 : "ui_pref_get_str: missing file returns NULL");
292 :
293 1 : ASSERT(ui_pref_set_str("str_key", "hello") == 0,
294 : "ui_pref_set_str: returns 0 on first write");
295 : {
296 1 : char *v = ui_pref_get_str("str_key");
297 1 : ASSERT(v != NULL, "ui_pref_get_str: returns non-NULL after set");
298 1 : ASSERT(strcmp(v, "hello") == 0, "ui_pref_get_str: value matches");
299 1 : free(v);
300 : }
301 :
302 1 : ASSERT(ui_pref_set_str("str_key", "world") == 0,
303 : "ui_pref_set_str: overwrite returns 0");
304 : {
305 1 : char *v = ui_pref_get_str("str_key");
306 1 : ASSERT(v != NULL, "ui_pref_get_str: overwrite non-NULL");
307 1 : ASSERT(strcmp(v, "world") == 0, "ui_pref_get_str: overwrite value matches");
308 1 : free(v);
309 : }
310 :
311 1 : ASSERT(ui_pref_set_str("another_key", "value2") == 0,
312 : "ui_pref_set_str: second key returns 0");
313 : {
314 1 : char *v1 = ui_pref_get_str("str_key");
315 1 : char *v2 = ui_pref_get_str("another_key");
316 1 : ASSERT(v1 && strcmp(v1, "world") == 0, "ui_pref_get_str: first key intact");
317 1 : ASSERT(v2 && strcmp(v2, "value2") == 0, "ui_pref_get_str: second key correct");
318 1 : free(v1); free(v2);
319 : }
320 :
321 1 : ASSERT(ui_pref_get_str("no_such_str_key") == NULL,
322 : "ui_pref_get_str: unknown key returns NULL");
323 :
324 1 : unlink("/tmp/email-cli-ui-pref-test-home/.local/share/email-cli/ui.ini");
325 :
326 1 : if (old_home) setenv("HOME", old_home, 1);
327 0 : else unsetenv("HOME");
328 : }
329 :
330 : /* ── msg delete tests ────────────────────────────────────────────────── */
331 :
332 1 : void test_local_msg_delete(void) {
333 1 : char *old_home = getenv("HOME");
334 1 : setup_test_env("/tmp/email-cli-delete-test");
335 :
336 1 : const char *folder = "INBOX";
337 1 : const char *uid = "0000000000001000";
338 :
339 : /* Save then delete */
340 1 : int rc = local_msg_save(folder, uid, "test body", 9);
341 1 : ASSERT(rc == 0, "delete: save succeeded");
342 1 : ASSERT(local_msg_exists(folder, uid) == 1, "delete: exists before delete");
343 :
344 1 : int del = local_msg_delete(folder, uid);
345 1 : ASSERT(del == 0, "delete: returns 0");
346 1 : ASSERT(local_msg_exists(folder, uid) == 0, "delete: does not exist after delete");
347 :
348 : /* Deleting a non-existent message should not crash */
349 1 : int del2 = local_msg_delete(folder, "0000000000001001");
350 1 : ASSERT(del2 == 0, "delete: non-existent msg returns 0");
351 :
352 : /* Delete also removes .hdr if it exists */
353 1 : const char *uid2 = "0000000000001002";
354 1 : local_hdr_save(folder, uid2, "from\tsubject\tdate\t\t0", 20);
355 1 : ASSERT(local_hdr_exists(folder, uid2) == 1, "delete: hdr exists before delete");
356 1 : local_msg_delete(folder, uid2);
357 1 : ASSERT(local_hdr_exists(folder, uid2) == 0, "delete: hdr removed by delete");
358 :
359 1 : if (old_home) setenv("HOME", old_home, 1);
360 0 : else unsetenv("HOME");
361 : }
362 :
363 : /* ── index email extraction tests ────────────────────────────────────── */
364 :
365 1 : void test_local_index_email_extraction(void) {
366 1 : char *old_home = getenv("HOME");
367 1 : setup_test_env("/tmp/email-cli-email-extract-test");
368 :
369 : /* "Name <user@domain>" format */
370 : {
371 1 : const char *msg =
372 : "From: Alice <alice@example.com>\r\n"
373 : "Date: Mon, 01 Jan 2024 10:00:00 +0000\r\n"
374 : "Subject: Test\r\n\r\nBody";
375 1 : int rc = local_index_update("INBOX", "0000000000000201", msg);
376 1 : ASSERT(rc == 0, "email_extraction: Name<email> index_update returns 0");
377 :
378 1 : const char *from_path =
379 : "/tmp/email-cli-email-extract-test/.local/share/email-cli/accounts/"
380 : "testuser/index/from/example.com/alice";
381 2 : RAII_FILE FILE *fp = fopen(from_path, "r");
382 1 : ASSERT(fp != NULL, "email_extraction: Name<email> from index created");
383 1 : if (fp) {
384 : char line[256];
385 1 : ASSERT(fgets(line, sizeof(line), fp) != NULL,
386 : "email_extraction: from index has a line");
387 1 : ASSERT(strstr(line, "INBOX/0000000000000201") != NULL,
388 : "email_extraction: from index contains correct ref");
389 : }
390 : }
391 :
392 : /* "<user@domain>" format without display name */
393 : {
394 1 : const char *msg2 =
395 : "From: <bob@test.org>\r\n"
396 : "Date: Tue, 02 Jan 2024 11:00:00 +0000\r\n"
397 : "Subject: Test2\r\n\r\nBody";
398 1 : int rc = local_index_update("INBOX", "0000000000000202", msg2);
399 1 : ASSERT(rc == 0, "email_extraction: <email> index_update returns 0");
400 :
401 1 : const char *from_path2 =
402 : "/tmp/email-cli-email-extract-test/.local/share/email-cli/accounts/"
403 : "testuser/index/from/test.org/bob";
404 2 : RAII_FILE FILE *fp2 = fopen(from_path2, "r");
405 1 : ASSERT(fp2 != NULL, "email_extraction: <email> from index created");
406 : }
407 :
408 : /* Plain "user@domain" format */
409 : {
410 1 : const char *msg3 =
411 : "From: carol@sample.net\r\n"
412 : "Date: Wed, 03 Jan 2024 12:00:00 +0000\r\n"
413 : "Subject: Test3\r\n\r\nBody";
414 1 : int rc = local_index_update("INBOX", "0000000000000203", msg3);
415 1 : ASSERT(rc == 0, "email_extraction: plain email index_update returns 0");
416 :
417 1 : const char *from_path3 =
418 : "/tmp/email-cli-email-extract-test/.local/share/email-cli/accounts/"
419 : "testuser/index/from/sample.net/carol";
420 2 : RAII_FILE FILE *fp3 = fopen(from_path3, "r");
421 1 : ASSERT(fp3 != NULL, "email_extraction: plain email from index created");
422 : }
423 :
424 : /* From header with no @ sign: no from index entry created (graceful skip) */
425 : {
426 1 : const char *msg4 =
427 : "From: noemail\r\n"
428 : "Date: Thu, 04 Jan 2024 13:00:00 +0000\r\n"
429 : "Subject: Test4\r\n\r\nBody";
430 1 : int rc = local_index_update("INBOX", "0000000000000204", msg4);
431 1 : ASSERT(rc == 0, "email_extraction: no-@ returns 0 (graceful)");
432 : }
433 :
434 1 : if (old_home) setenv("HOME", old_home, 1);
435 0 : else unsetenv("HOME");
436 : }
437 :
438 : /* ── trash labels tests ──────────────────────────────────────────────── */
439 :
440 1 : void test_local_trash_labels(void) {
441 1 : char *old_home = getenv("HOME");
442 1 : setup_test_env("/tmp/email-cli-trash-labels-test");
443 :
444 1 : const char *uid = "0000000000002000";
445 1 : const char *labels = "INBOX,Work,Important";
446 :
447 : /* Save and load */
448 1 : int rc = local_trash_labels_save(uid, labels);
449 1 : ASSERT(rc == 0, "trash_labels: save returns 0");
450 :
451 2 : RAII_STRING char *loaded = local_trash_labels_load(uid);
452 1 : ASSERT(loaded != NULL, "trash_labels: load returns non-NULL");
453 1 : ASSERT(strcmp(loaded, labels) == 0, "trash_labels: loaded value matches saved");
454 :
455 : /* Remove */
456 1 : local_trash_labels_remove(uid);
457 2 : RAII_STRING char *after_remove = local_trash_labels_load(uid);
458 1 : ASSERT(after_remove == NULL, "trash_labels: NULL after remove");
459 :
460 : /* Load non-existent returns NULL */
461 2 : RAII_STRING char *missing = local_trash_labels_load("0000000000009999");
462 1 : ASSERT(missing == NULL, "trash_labels: missing uid returns NULL");
463 :
464 : /* Remove non-existent should not crash */
465 1 : local_trash_labels_remove("0000000000009998");
466 :
467 1 : if (old_home) setenv("HOME", old_home, 1);
468 0 : else unsetenv("HOME");
469 : }
470 :
471 : /* ── gmail history id tests ──────────────────────────────────────────── */
472 :
473 1 : void test_local_gmail_history(void) {
474 1 : char *old_home = getenv("HOME");
475 1 : setup_test_env("/tmp/email-cli-gmail-history-test");
476 :
477 : /* Pre-clean any leftover history file from previous runs */
478 1 : unlink("/tmp/email-cli-gmail-history-test/.local/share/email-cli/accounts/"
479 : "testuser/gmail_history_id");
480 :
481 : /* Load when missing returns NULL */
482 2 : RAII_STRING char *none = local_gmail_history_load();
483 1 : ASSERT(none == NULL, "gmail_history: missing returns NULL");
484 :
485 : /* Save and load */
486 1 : int rc = local_gmail_history_save("12345678");
487 1 : ASSERT(rc == 0, "gmail_history: save returns 0");
488 :
489 2 : RAII_STRING char *loaded = local_gmail_history_load();
490 1 : ASSERT(loaded != NULL, "gmail_history: load returns non-NULL");
491 1 : ASSERT(strcmp(loaded, "12345678") == 0, "gmail_history: loaded value matches");
492 :
493 : /* Overwrite with new value */
494 1 : int rc2 = local_gmail_history_save("99999999");
495 1 : ASSERT(rc2 == 0, "gmail_history: overwrite returns 0");
496 :
497 2 : RAII_STRING char *loaded2 = local_gmail_history_load();
498 1 : ASSERT(loaded2 != NULL, "gmail_history: overwrite load returns non-NULL");
499 1 : ASSERT(strcmp(loaded2, "99999999") == 0, "gmail_history: overwrite value correct");
500 :
501 : /* Null history id returns -1 */
502 1 : int rc3 = local_gmail_history_save(NULL);
503 1 : ASSERT(rc3 == -1, "gmail_history: NULL id returns -1");
504 :
505 1 : if (old_home) setenv("HOME", old_home, 1);
506 0 : else unsetenv("HOME");
507 : }
508 :
509 : /* ── local_hdr_get_labels tests ──────────────────────────────────────── */
510 :
511 1 : void test_local_hdr_get_labels(void) {
512 1 : char *old_home = getenv("HOME");
513 1 : setup_test_env("/tmp/email-cli-hdr-labels-test");
514 :
515 1 : const char *folder = ""; /* Gmail flat store uses empty folder */
516 1 : const char *uid = "0000000000003000";
517 :
518 : /* Gmail .hdr format: from\tsubject\tdate\tlabels\tflags */
519 1 : const char *hdr_content = "Alice <alice@example.com>\tHello\t2024-01-01 10:00\tINBOX,Work\t1";
520 1 : int rc = local_hdr_save(folder, uid, hdr_content, strlen(hdr_content));
521 1 : ASSERT(rc == 0, "hdr_labels: save returns 0");
522 :
523 2 : RAII_STRING char *labels = local_hdr_get_labels(folder, uid);
524 1 : ASSERT(labels != NULL, "hdr_labels: get_labels returns non-NULL");
525 1 : ASSERT(strcmp(labels, "INBOX,Work") == 0, "hdr_labels: labels value correct");
526 :
527 : /* Non-Gmail .hdr (no labels field) → NULL */
528 1 : const char *uid2 = "0000000000003001";
529 1 : const char *hdr2 = "Bob\tSubject\tDate"; /* only 3 fields, no 4th tab */
530 1 : local_hdr_save(folder, uid2, hdr2, strlen(hdr2));
531 2 : RAII_STRING char *labels2 = local_hdr_get_labels(folder, uid2);
532 1 : ASSERT(labels2 == NULL, "hdr_labels: non-Gmail hdr returns NULL");
533 :
534 : /* Non-existent uid → NULL */
535 2 : RAII_STRING char *labels3 = local_hdr_get_labels(folder, "0000000000009997");
536 1 : ASSERT(labels3 == NULL, "hdr_labels: missing uid returns NULL");
537 :
538 1 : if (old_home) setenv("HOME", old_home, 1);
539 0 : else unsetenv("HOME");
540 : }
541 :
542 : /* ── local_flag_search tests ────────────────────────────────────────────── */
543 :
544 1 : void test_local_flag_search(void) {
545 1 : char *old_home = getenv("HOME");
546 1 : setup_test_env("/tmp/email-cli-flag-search-test");
547 :
548 : /* Pre-clean */
549 1 : unlink("/tmp/email-cli-flag-search-test/.local/share/email-cli/"
550 : "accounts/testuser/manifests/INBOX.tsv");
551 1 : unlink("/tmp/email-cli-flag-search-test/.local/share/email-cli/"
552 : "accounts/testuser/manifests/Sent.tsv");
553 :
554 : /* Build two manifests across two folders */
555 1 : Manifest *inbox = calloc(1, sizeof(Manifest));
556 1 : manifest_upsert(inbox, "0000000000000001", strdup("Alice"), strdup("Unread in INBOX"),
557 : strdup("2024-03-01 10:00"), MSG_FLAG_UNSEEN);
558 1 : manifest_upsert(inbox, "0000000000000002", strdup("Bob"), strdup("Read in INBOX"),
559 : strdup("2024-03-02 11:00"), 0);
560 1 : manifest_save("INBOX", inbox);
561 1 : manifest_free(inbox);
562 :
563 1 : Manifest *sent = calloc(1, sizeof(Manifest));
564 1 : manifest_upsert(sent, "0000000000000003", strdup("Carol"), strdup("Flagged in Sent"),
565 : strdup("2024-03-03 12:00"), MSG_FLAG_FLAGGED);
566 1 : manifest_upsert(sent, "0000000000000004", strdup("Dave"), strdup("Unread in Sent"),
567 : strdup("2024-03-04 13:00"), MSG_FLAG_UNSEEN);
568 1 : manifest_save("Sent", sent);
569 1 : manifest_free(sent);
570 :
571 : /* Test 1: flag_search for UNSEEN finds UID 1 (INBOX) and UID 4 (Sent) */
572 1 : SearchResult *res = NULL;
573 1 : int cnt = 0;
574 1 : int rc = local_flag_search(MSG_FLAG_UNSEEN, &res, &cnt);
575 1 : ASSERT(rc == 0, "flag_search: returns 0");
576 1 : ASSERT(cnt == 2, "flag_search UNSEEN: 2 results");
577 1 : int found1 = 0, found4 = 0;
578 3 : for (int i = 0; i < cnt; i++) {
579 2 : if (strcmp(res[i].uid, "0000000000000001") == 0) {
580 1 : found1 = 1;
581 1 : ASSERT(strcmp(res[i].folder, "INBOX") == 0, "flag_search: UID1 folder is INBOX");
582 1 : ASSERT(res[i].flags & MSG_FLAG_UNSEEN, "flag_search: UID1 has UNSEEN");
583 : }
584 2 : if (strcmp(res[i].uid, "0000000000000004") == 0) {
585 1 : found4 = 1;
586 1 : ASSERT(strcmp(res[i].folder, "Sent") == 0, "flag_search: UID4 folder is Sent");
587 : }
588 : }
589 1 : ASSERT(found1, "flag_search UNSEEN: UID1 found");
590 1 : ASSERT(found4, "flag_search UNSEEN: UID4 found");
591 1 : local_search_free(res, cnt);
592 :
593 : /* Test 2: flag_search for FLAGGED finds only UID 3 (Sent) */
594 1 : res = NULL; cnt = 0;
595 1 : local_flag_search(MSG_FLAG_FLAGGED, &res, &cnt);
596 1 : ASSERT(cnt == 1, "flag_search FLAGGED: 1 result");
597 1 : ASSERT(strcmp(res[0].uid, "0000000000000003") == 0, "flag_search FLAGGED: UID3");
598 1 : ASSERT(strcmp(res[0].folder, "Sent") == 0, "flag_search FLAGGED: Sent folder");
599 1 : local_search_free(res, cnt);
600 :
601 : /* Test 3: flag_search for both bits returns union (UID1, UID3, UID4) */
602 1 : res = NULL; cnt = 0;
603 1 : local_flag_search(MSG_FLAG_UNSEEN | MSG_FLAG_FLAGGED, &res, &cnt);
604 1 : ASSERT(cnt == 3, "flag_search UNSEEN|FLAGGED: 3 results");
605 1 : local_search_free(res, cnt);
606 :
607 : /* Test 4: UID2 (read, no flags) is never returned */
608 1 : res = NULL; cnt = 0;
609 1 : local_flag_search(MSG_FLAG_UNSEEN, &res, &cnt);
610 1 : int found2 = 0;
611 3 : for (int i = 0; i < cnt; i++)
612 2 : if (strcmp(res[i].uid, "0000000000000002") == 0) found2 = 1;
613 1 : ASSERT(!found2, "flag_search: read UID2 not in UNSEEN results");
614 1 : local_search_free(res, cnt);
615 :
616 : /* Test 5: mask 0 means "no flag requirement", i.e. every cached message
617 : * across every folder — this is what the __all__ view is built on. A
618 : * literal reading of `flags & 0` would return nothing, so the special
619 : * case has to be explicit. */
620 1 : res = NULL; cnt = 0;
621 1 : int rc0 = local_flag_search(0, &res, &cnt);
622 1 : ASSERT(rc0 == 0, "flag_search mask 0: returns 0");
623 1 : ASSERT(cnt == 4, "flag_search mask 0: all four messages");
624 1 : int seen_read = 0, seen_folders = 0;
625 5 : for (int i = 0; i < cnt; i++) {
626 4 : if (strcmp(res[i].uid, "0000000000000002") == 0) seen_read = 1;
627 4 : if (strcmp(res[i].folder, "Sent") == 0) seen_folders = 1;
628 : }
629 1 : ASSERT(seen_read, "flag_search mask 0: includes the flagless message");
630 1 : ASSERT(seen_folders, "flag_search mask 0: spans folders");
631 1 : local_search_free(res, cnt);
632 :
633 : /* Test 6: the aggregate manifest honours the same convention. */
634 1 : Manifest *all = manifest_load_all_with_flag(0);
635 1 : ASSERT(all != NULL, "manifest_load_all_with_flag(0): returns a manifest");
636 1 : ASSERT(all && all->count == 4,
637 : "manifest_load_all_with_flag(0): every message, not none");
638 1 : manifest_free(all);
639 :
640 1 : Manifest *only_unread = manifest_load_all_with_flag(MSG_FLAG_UNSEEN);
641 1 : ASSERT(only_unread && only_unread->count == 2,
642 : "manifest_load_all_with_flag(UNSEEN): still filters");
643 1 : manifest_free(only_unread);
644 :
645 1 : res = NULL; cnt = 0;
646 1 : local_flag_search(MSG_FLAG_UNSEEN, &res, &cnt);
647 1 : local_search_free(res, cnt);
648 :
649 1 : if (old_home) setenv("HOME", old_home, 1);
650 0 : else unsetenv("HOME");
651 : }
652 :
653 1 : void test_manifest_count_after_flag_update(void) {
654 1 : char *old_home = getenv("HOME");
655 1 : setup_test_env("/tmp/email-cli-count-update-test");
656 :
657 : /* Pre-clean */
658 1 : unlink("/tmp/email-cli-count-update-test/.local/share/email-cli/"
659 : "accounts/testuser/manifests/INBOX.tsv");
660 :
661 : /* Build a manifest with 3 unread messages */
662 1 : Manifest *m = calloc(1, sizeof(Manifest));
663 1 : manifest_upsert(m, "0000000000000010", strdup("A"), strdup("Msg1"),
664 : strdup("2024-01-01 08:00"), MSG_FLAG_UNSEEN);
665 1 : manifest_upsert(m, "0000000000000020", strdup("B"), strdup("Msg2"),
666 : strdup("2024-01-02 09:00"), MSG_FLAG_UNSEEN);
667 1 : manifest_upsert(m, "0000000000000030", strdup("C"), strdup("Msg3"),
668 : strdup("2024-01-03 10:00"), 0 /* read */);
669 1 : manifest_save("INBOX", m);
670 :
671 : /* Initial count: 2 unread, 0 flagged */
672 1 : int unread = -1, flagged = -1;
673 1 : manifest_count_all_flags(&unread, &flagged, NULL, NULL, NULL, NULL);
674 1 : ASSERT(unread == 2, "count_after_update: initial unread is 2");
675 1 : ASSERT(flagged == 0, "count_after_update: initial flagged is 0");
676 :
677 : /* Simulate user pressing 'n' on UID 10 — mark as read */
678 1 : ManifestEntry *e = manifest_find(m, "0000000000000010");
679 1 : ASSERT(e != NULL, "count_after_update: UID10 found");
680 1 : e->flags &= ~MSG_FLAG_UNSEEN;
681 1 : manifest_save("INBOX", m);
682 :
683 : /* Count should now be 1 */
684 1 : manifest_count_all_flags(&unread, &flagged, NULL, NULL, NULL, NULL);
685 1 : ASSERT(unread == 1, "count_after_update: unread drops to 1 after save");
686 :
687 : /* Mark UID 20 as read too */
688 1 : e = manifest_find(m, "0000000000000020");
689 1 : e->flags &= ~MSG_FLAG_UNSEEN;
690 1 : manifest_save("INBOX", m);
691 :
692 1 : manifest_count_all_flags(&unread, &flagged, NULL, NULL, NULL, NULL);
693 1 : ASSERT(unread == 0, "count_after_update: unread drops to 0");
694 :
695 : /* Flag UID 30 */
696 1 : e = manifest_find(m, "0000000000000030");
697 1 : e->flags |= MSG_FLAG_FLAGGED;
698 1 : manifest_save("INBOX", m);
699 :
700 1 : manifest_count_all_flags(&unread, &flagged, NULL, NULL, NULL, NULL);
701 1 : ASSERT(flagged == 1, "count_after_update: flagged becomes 1");
702 :
703 1 : manifest_free(m);
704 1 : if (old_home) setenv("HOME", old_home, 1);
705 0 : else unsetenv("HOME");
706 : }
707 :
708 1 : void test_flag_search_folder_isolation(void) {
709 1 : char *old_home = getenv("HOME");
710 1 : setup_test_env("/tmp/email-cli-flag-iso-test");
711 :
712 : /* Pre-clean: remove all TSV files (stale files from other test runs
713 : * would otherwise inflate cnt and make the count assertion fragile) */
714 1 : int _clean_rc = system(
715 : "find '/tmp/email-cli-flag-iso-test/.local/share/email-cli/"
716 : "accounts/testuser/manifests' -name '*.tsv' -delete 2>/dev/null");
717 : (void)_clean_rc; /* best-effort pre-clean; the directory may not exist yet */
718 :
719 : /* Same UID in two different folders: verify that dedup returns it only once */
720 1 : Manifest *inbox = calloc(1, sizeof(Manifest));
721 1 : manifest_upsert(inbox, "0000000000000099", strdup("X"), strdup("INBOX copy"),
722 : strdup("2024-06-01 00:00"), MSG_FLAG_UNSEEN);
723 1 : manifest_save("INBOX", inbox);
724 1 : manifest_free(inbox);
725 :
726 1 : Manifest *spam = calloc(1, sizeof(Manifest));
727 1 : manifest_upsert(spam, "0000000000000099", strdup("X"), strdup("Spam copy"),
728 : strdup("2024-06-01 00:00"), MSG_FLAG_UNSEEN);
729 1 : manifest_save("Spam", spam);
730 1 : manifest_free(spam);
731 :
732 1 : SearchResult *res = NULL; int cnt = 0;
733 1 : local_flag_search(MSG_FLAG_UNSEEN, &res, &cnt);
734 : /* Dedup: same UID across two non-empty folders → only one entry kept */
735 1 : int found99 = 0;
736 1 : const char *folder99 = NULL;
737 2 : for (int i = 0; i < cnt; i++) {
738 1 : if (strcmp(res[i].uid, "0000000000000099") == 0) {
739 1 : found99++;
740 1 : folder99 = res[i].folder;
741 : }
742 : }
743 1 : ASSERT(found99 == 1, "flag_search dedup: UID 99 appears exactly once");
744 1 : ASSERT(folder99 && (strcmp(folder99, "INBOX") == 0 || strcmp(folder99, "Spam") == 0),
745 : "flag_search dedup: UID 99 folder is INBOX or Spam");
746 1 : local_search_free(res, cnt);
747 :
748 1 : if (old_home) setenv("HOME", old_home, 1);
749 0 : else unsetenv("HOME");
750 : }
751 :
752 : /* ── local_contacts_update tests ─────────────────────────────────────── */
753 :
754 : #define CONTACTS_PATH \
755 : "/tmp/email-cli-contacts-test/.local/share/email-cli/accounts/testuser/contacts.tsv"
756 :
757 10 : static void contacts_cleanup(void) { unlink(CONTACTS_PATH); }
758 :
759 2 : static int contacts_count_lines(void) {
760 2 : FILE *f = fopen(CONTACTS_PATH, "r");
761 2 : if (!f) return 0;
762 2 : int n = 0; char line[512];
763 5 : while (fgets(line, sizeof(line), f)) n++;
764 2 : fclose(f);
765 2 : return n;
766 : }
767 :
768 7 : static int contacts_has_addr(const char *addr) {
769 7 : FILE *f = fopen(CONTACTS_PATH, "r");
770 7 : if (!f) return 0;
771 7 : char line[512]; int found = 0;
772 9 : while (fgets(line, sizeof(line), f)) {
773 9 : char *tab = strchr(line, '\t');
774 9 : if (tab) *tab = '\0';
775 9 : char *nl = strchr(line, '\n'); if (nl) *nl = '\0';
776 9 : if (strcasecmp(line, addr) == 0) { found = 1; break; }
777 : }
778 7 : fclose(f);
779 7 : return found;
780 : }
781 :
782 3 : static int contacts_get_freq(const char *addr) {
783 3 : FILE *f = fopen(CONTACTS_PATH, "r");
784 3 : if (!f) return -1;
785 3 : char line[512]; int freq = -1;
786 3 : while (fgets(line, sizeof(line), f)) {
787 3 : char copy[512]; snprintf(copy, sizeof(copy), "%s", line);
788 3 : char *t1 = strchr(copy, '\t');
789 3 : if (!t1) continue;
790 3 : *t1 = '\0';
791 3 : if (strcasecmp(copy, addr) != 0) continue;
792 3 : char *t2 = strchr(t1 + 1, '\t');
793 3 : if (t2) freq = atoi(t2 + 1);
794 3 : break;
795 : }
796 3 : fclose(f);
797 3 : return freq;
798 : }
799 :
800 2 : static char *contacts_get_name(const char *addr) {
801 2 : FILE *f = fopen(CONTACTS_PATH, "r");
802 2 : if (!f) return NULL;
803 2 : char line[512]; static char cname[256]; cname[0] = '\0';
804 2 : while (fgets(line, sizeof(line), f)) {
805 2 : char *t1 = strchr(line, '\t');
806 2 : if (!t1) continue;
807 2 : *t1 = '\0';
808 2 : if (strcasecmp(line, addr) != 0) continue;
809 2 : char *t2 = strchr(t1 + 1, '\t');
810 2 : if (t2) { *t2 = '\0'; snprintf(cname, sizeof(cname), "%s", t1 + 1); }
811 2 : break;
812 : }
813 2 : fclose(f);
814 2 : return cname[0] ? cname : NULL;
815 : }
816 :
817 1 : void test_local_contacts_update(void) {
818 1 : char *old_home = getenv("HOME");
819 1 : setup_test_env("/tmp/email-cli-contacts-test");
820 1 : contacts_cleanup();
821 :
822 : /* 1. Bare address in From creates entry with freq=1 */
823 1 : local_contacts_update("alice@example.com", NULL, NULL);
824 1 : ASSERT(contacts_has_addr("alice@example.com"),
825 : "contacts: bare From address added");
826 1 : ASSERT(contacts_get_freq("alice@example.com") == 1,
827 : "contacts: initial frequency is 1");
828 :
829 : /* 2. Display-name form: addr extracted, name stored */
830 1 : contacts_cleanup();
831 1 : local_contacts_update("Alice Smith <alice@example.com>", NULL, NULL);
832 1 : ASSERT(contacts_has_addr("alice@example.com"),
833 : "contacts: addr extracted from display-name form");
834 1 : char *cn = contacts_get_name("alice@example.com");
835 1 : ASSERT(cn && strcmp(cn, "Alice Smith") == 0,
836 : "contacts: display name stored correctly");
837 :
838 : /* 3. Multiple comma-separated addresses in To */
839 1 : contacts_cleanup();
840 1 : local_contacts_update(NULL, "alice@example.com, bob@example.com", NULL);
841 1 : ASSERT(contacts_has_addr("alice@example.com"), "contacts: To addr1 added");
842 1 : ASSERT(contacts_has_addr("bob@example.com"), "contacts: To addr2 added");
843 1 : ASSERT(contacts_count_lines() == 2, "contacts: 2 entries total");
844 :
845 : /* 4. Cc addresses are also collected */
846 1 : contacts_cleanup();
847 1 : local_contacts_update(NULL, NULL, "carol@example.com");
848 1 : ASSERT(contacts_has_addr("carol@example.com"), "contacts: Cc addr added");
849 :
850 : /* 5. Frequency increments on repeated calls */
851 1 : contacts_cleanup();
852 1 : local_contacts_update("alice@example.com", NULL, NULL);
853 1 : local_contacts_update("alice@example.com", NULL, NULL);
854 1 : local_contacts_update("alice@example.com", NULL, NULL);
855 1 : ASSERT(contacts_get_freq("alice@example.com") == 3,
856 : "contacts: frequency increments to 3 on 3 calls");
857 :
858 : /* 6. Case-insensitive deduplication */
859 1 : contacts_cleanup();
860 1 : local_contacts_update("ALICE@EXAMPLE.COM", NULL, NULL);
861 1 : local_contacts_update("alice@example.com", NULL, NULL);
862 1 : ASSERT(contacts_count_lines() == 1,
863 : "contacts: case-insensitive dedup — only 1 entry");
864 1 : ASSERT(contacts_get_freq("alice@example.com") == 2,
865 : "contacts: case-insensitive freq increment");
866 :
867 : /* 7. Name updated when initially absent */
868 1 : contacts_cleanup();
869 1 : local_contacts_update("alice@example.com", NULL, NULL);
870 1 : local_contacts_update("Alice Smith <alice@example.com>", NULL, NULL);
871 1 : char *cn2 = contacts_get_name("alice@example.com");
872 1 : ASSERT(cn2 && strcmp(cn2, "Alice Smith") == 0,
873 : "contacts: name updated when initially missing");
874 :
875 : /* 8. Most frequent addr appears first in file */
876 1 : contacts_cleanup();
877 1 : local_contacts_update("bob@example.com", NULL, NULL);
878 1 : local_contacts_update("alice@example.com", NULL, NULL);
879 1 : local_contacts_update("alice@example.com", NULL, NULL);
880 : {
881 1 : FILE *cf = fopen(CONTACTS_PATH, "r");
882 1 : ASSERT(cf != NULL, "contacts: file exists after updates");
883 1 : if (cf) {
884 1 : char fline[256]; fline[0] = '\0';
885 1 : if (fgets(fline, sizeof(fline), cf) == NULL) fline[0] = '\0';
886 1 : fclose(cf);
887 1 : char *tab = strchr(fline, '\t'); if (tab) *tab = '\0';
888 1 : ASSERT(strcasecmp(fline, "alice@example.com") == 0,
889 : "contacts: most frequent addr is first");
890 : }
891 : }
892 :
893 : /* 9. NULL headers are safe (no crash) */
894 1 : contacts_cleanup();
895 1 : local_contacts_update(NULL, NULL, NULL);
896 1 : ASSERT(1, "contacts: all-NULL headers do not crash");
897 :
898 : /* 10. Semicolon-separated addresses parsed */
899 1 : contacts_cleanup();
900 1 : local_contacts_update(NULL, "dave@example.com; eve@example.com", NULL);
901 1 : ASSERT(contacts_has_addr("dave@example.com"), "contacts: semicolon sep addr1");
902 1 : ASSERT(contacts_has_addr("eve@example.com"), "contacts: semicolon sep addr2");
903 :
904 1 : if (old_home) setenv("HOME", old_home, 1);
905 0 : else unsetenv("HOME");
906 : }
907 :
908 : /* ── local_search (text/subject/from search) ──────────────────────────── */
909 :
910 1 : void test_local_search(void) {
911 1 : char *old_home = getenv("HOME");
912 1 : setup_test_env("/tmp/email-cli-search-test");
913 :
914 : /* Build a manifest with two messages */
915 1 : Manifest *m = calloc(1, sizeof(Manifest));
916 1 : manifest_upsert(m, "0000000000000001", strdup("alice@example.com"),
917 : strdup("Hello World subject"), strdup("2024-01-01 10:00"), 0);
918 1 : manifest_upsert(m, "0000000000000002", strdup("bob@other.org"),
919 : strdup("Different topic"), strdup("2024-01-02 11:00"), MSG_FLAG_UNSEEN);
920 1 : manifest_save("INBOX", m);
921 1 : manifest_free(m);
922 :
923 1 : SearchResult *res = NULL;
924 1 : int cnt = 0;
925 :
926 : /* scope=0: subject search — "Hello" matches UID1 */
927 1 : int rc = local_search("Hello", 0, &res, &cnt);
928 1 : ASSERT(rc == 0, "local_search subject: returns 0");
929 1 : ASSERT(cnt == 1, "local_search subject: 1 result");
930 1 : if (cnt == 1) {
931 1 : ASSERT(strcmp(res[0].uid, "0000000000000001") == 0, "local_search subject: UID1");
932 1 : ASSERT(strcmp(res[0].folder, "INBOX") == 0, "local_search subject: INBOX");
933 : }
934 1 : local_search_free(res, cnt);
935 :
936 : /* scope=0: case-insensitive */
937 1 : res = NULL; cnt = 0;
938 1 : local_search("hello", 0, &res, &cnt);
939 1 : ASSERT(cnt == 1, "local_search case-insensitive: 1 result");
940 1 : local_search_free(res, cnt);
941 :
942 : /* scope=1: from search — "alice" matches UID1 */
943 1 : res = NULL; cnt = 0;
944 1 : local_search("alice", 1, &res, &cnt);
945 1 : ASSERT(cnt == 1, "local_search from: 1 result");
946 1 : if (cnt == 1)
947 1 : ASSERT(strcmp(res[0].uid, "0000000000000001") == 0, "local_search from: UID1");
948 1 : local_search_free(res, cnt);
949 :
950 : /* scope=0: no match → 0 results */
951 1 : res = NULL; cnt = 0;
952 1 : local_search("ZZZNOMATCH99", 0, &res, &cnt);
953 1 : ASSERT(cnt == 0, "local_search no match: 0 results");
954 1 : local_search_free(res, cnt);
955 :
956 : /* NULL / empty query → 0 results, no crash */
957 1 : res = NULL; cnt = 0;
958 1 : local_search(NULL, 0, &res, &cnt);
959 1 : ASSERT(cnt == 0, "local_search NULL query: safe");
960 1 : res = NULL; cnt = 0;
961 1 : local_search("", 0, &res, &cnt);
962 1 : ASSERT(cnt == 0, "local_search empty query: safe");
963 :
964 1 : if (old_home) setenv("HOME", old_home, 1);
965 0 : else unsetenv("HOME");
966 : }
967 :
968 : /* ── local_contacts_rebuild ───────────────────────────────────────────── */
969 :
970 1 : void test_local_contacts_rebuild(void) {
971 1 : char *old_home = getenv("HOME");
972 1 : setup_test_env("/tmp/email-cli-rebuild-test");
973 :
974 : /* Save a message header with From/To so rebuild can extract contacts */
975 1 : const char *hdr = "From: Carol <carol@rebuild.test>\r\nTo: dave@rebuild.test\r\n"
976 : "Subject: Test\r\nDate: Mon, 1 Jan 2024 10:00:00 +0000\r\n";
977 1 : local_hdr_save("INBOX", "0000000000000001", hdr, strlen(hdr));
978 :
979 : /* Save folder list so local_contacts_rebuild can find INBOX */
980 1 : const char *flist[] = { "INBOX", NULL };
981 1 : local_folder_list_save(flist, 1, '/');
982 :
983 : /* Run rebuild — should not crash, creates contacts file */
984 1 : local_contacts_rebuild();
985 :
986 : /* Check the contacts file at the correct path for this test (not CONTACTS_PATH
987 : * which is hardcoded to the contacts-update test directory). */
988 1 : const char *ctacts_file =
989 : "/tmp/email-cli-rebuild-test/.local/share/email-cli/accounts/testuser/contacts.tsv";
990 1 : FILE *cf = fopen(ctacts_file, "r");
991 1 : ASSERT(cf != NULL, "contacts_rebuild: contacts file created");
992 1 : int found = 0;
993 : char cline[256];
994 1 : while (fgets(cline, sizeof(cline), cf)) {
995 1 : if (strstr(cline, "carol@rebuild.test")) { found = 1; break; }
996 : }
997 1 : fclose(cf);
998 1 : ASSERT(found, "contacts_rebuild: From addr present");
999 :
1000 1 : if (old_home) setenv("HOME", old_home, 1);
1001 0 : else unsetenv("HOME");
1002 : }
1003 :
1004 : /* ── local_pending_append_* ───────────────────────────────────────────── */
1005 :
1006 1 : void test_local_pending_append(void) {
1007 1 : char *old_home = getenv("HOME");
1008 1 : setup_test_env("/tmp/email-cli-pending-append-test");
1009 : /* Remove leftover from previous test runs */
1010 1 : unlink("/tmp/email-cli-pending-append-test/.local/share/email-cli/accounts/testuser/pending_appends.tsv");
1011 :
1012 1 : int rc1 = local_pending_append_add("Sent", "0000000000000042");
1013 1 : int rc2 = local_pending_append_add("Drafts", "0000000000000043");
1014 1 : ASSERT(rc1 == 0, "pending_append_add: first entry ok");
1015 1 : ASSERT(rc2 == 0, "pending_append_add: second entry ok");
1016 :
1017 1 : int cnt = 0;
1018 1 : PendingAppend *pa = local_pending_append_load(&cnt);
1019 1 : ASSERT(cnt == 2, "pending_append_load: 2 entries");
1020 1 : if (cnt >= 2) {
1021 1 : ASSERT(strcmp(pa[0].folder, "Sent") == 0, "pending_append: first folder");
1022 1 : ASSERT(strcmp(pa[0].uid, "0000000000000042") == 0, "pending_append: first uid");
1023 1 : ASSERT(strcmp(pa[1].folder, "Drafts") == 0, "pending_append: second folder");
1024 : }
1025 1 : free(pa);
1026 :
1027 1 : local_pending_append_remove("Sent", "0000000000000042");
1028 1 : cnt = 0;
1029 1 : pa = local_pending_append_load(&cnt);
1030 1 : ASSERT(cnt == 1, "pending_append_remove: 1 entry left");
1031 1 : if (cnt == 1)
1032 1 : ASSERT(strcmp(pa[0].folder, "Drafts") == 0, "pending_append_remove: Drafts left");
1033 1 : free(pa);
1034 :
1035 : /* no crash on removing non-existent entry */
1036 1 : local_pending_append_remove("INBOX", "0000000000000099");
1037 :
1038 1 : if (old_home) setenv("HOME", old_home, 1);
1039 0 : else unsetenv("HOME");
1040 : }
1041 :
1042 : /* ── local_pending_fetch_* ────────────────────────────────────────────── */
1043 :
1044 1 : void test_local_pending_fetch(void) {
1045 1 : char *old_home = getenv("HOME");
1046 1 : setup_test_env("/tmp/email-cli-pending-fetch-test");
1047 :
1048 1 : ASSERT(local_pending_fetch_count() == 0, "pending_fetch: initially 0");
1049 :
1050 1 : local_pending_fetch_add("0000000000000010");
1051 1 : local_pending_fetch_add("0000000000000011");
1052 1 : ASSERT(local_pending_fetch_count() == 2, "pending_fetch_add: count 2");
1053 :
1054 1 : int cnt = 0;
1055 1 : char (*uids)[17] = local_pending_fetch_load(&cnt);
1056 1 : ASSERT(cnt == 2, "pending_fetch_load: 2 entries");
1057 1 : free(uids);
1058 :
1059 1 : local_pending_fetch_remove("0000000000000010");
1060 1 : ASSERT(local_pending_fetch_count() == 1, "pending_fetch_remove: count 1");
1061 :
1062 1 : local_pending_fetch_clear();
1063 1 : ASSERT(local_pending_fetch_count() == 0, "pending_fetch_clear: count 0");
1064 :
1065 1 : local_pending_fetch_add(NULL);
1066 1 : ASSERT(local_pending_fetch_count() == 0, "pending_fetch_add NULL: safe");
1067 :
1068 1 : if (old_home) setenv("HOME", old_home, 1);
1069 0 : else unsetenv("HOME");
1070 : }
1071 :
1072 : /* ── local_save_outgoing ──────────────────────────────────────────────── */
1073 :
1074 1 : void test_local_save_outgoing(void) {
1075 1 : char *old_home = getenv("HOME");
1076 1 : setup_test_env("/tmp/email-cli-outgoing-test");
1077 : /* Remove leftover from previous test runs */
1078 1 : unlink("/tmp/email-cli-outgoing-test/.local/share/email-cli/accounts/testuser/pending_appends.tsv");
1079 :
1080 1 : const char *msg = "From: me@test.local\r\nTo: you@test.local\r\n"
1081 : "Subject: test outgoing\r\n\r\nHello!\r\n";
1082 1 : size_t mlen = strlen(msg);
1083 :
1084 1 : int rc = local_save_outgoing("Sent", msg, mlen);
1085 1 : ASSERT(rc == 0, "local_save_outgoing: returns 0");
1086 :
1087 1 : int cnt = 0;
1088 1 : PendingAppend *pa = local_pending_append_load(&cnt);
1089 1 : ASSERT(cnt == 1, "local_save_outgoing: pending append queued");
1090 1 : if (cnt == 1)
1091 1 : ASSERT(strcmp(pa[0].folder, "Sent") == 0, "local_save_outgoing: Sent folder");
1092 1 : free(pa);
1093 :
1094 : /* NULL folder should not crash */
1095 1 : local_save_outgoing(NULL, msg, mlen);
1096 :
1097 1 : if (old_home) setenv("HOME", old_home, 1);
1098 0 : else unsetenv("HOME");
1099 : }
1100 :
1101 1 : void test_manifest_dmarc_flags(void) {
1102 1 : char *old_home = getenv("HOME");
1103 1 : setup_test_env("/tmp/email-cli-dmarc-manifest-test");
1104 :
1105 1 : unlink("/tmp/email-cli-dmarc-manifest-test/.local/share/email-cli/"
1106 : "accounts/testuser/manifests/INBOX.tsv");
1107 :
1108 1 : Manifest *m = calloc(1, sizeof(Manifest));
1109 1 : ASSERT(m != NULL, "dmarc manifest: calloc");
1110 :
1111 : /* 1. DMARC pass + checked */
1112 1 : manifest_upsert(m, "0000000000000001", strdup("Alice"), strdup("Pass msg"),
1113 : strdup("2025-01-01 10:00"),
1114 : MSG_FLAG_DMARC_PASS | MSG_FLAG_DMARC_CHECKED);
1115 : /* 2. DMARC fail + checked */
1116 1 : manifest_upsert(m, "0000000000000002", strdup("Bob"), strdup("Fail msg"),
1117 : strdup("2025-01-02 10:00"),
1118 : MSG_FLAG_DMARC_FAIL | MSG_FLAG_DMARC_CHECKED);
1119 : /* 3. Checked but no pass/fail (no Authentication-Results in header) */
1120 1 : manifest_upsert(m, "0000000000000003", strdup("Carol"), strdup("No AR msg"),
1121 : strdup("2025-01-03 10:00"),
1122 : MSG_FLAG_DMARC_CHECKED);
1123 : /* 4. Combined: UNSEEN + DMARC_PASS + DMARC_CHECKED */
1124 1 : manifest_upsert(m, "0000000000000004", strdup("Dave"), strdup("Unread pass"),
1125 : strdup("2025-01-04 10:00"),
1126 : MSG_FLAG_UNSEEN | MSG_FLAG_DMARC_PASS | MSG_FLAG_DMARC_CHECKED);
1127 : /* 5. PASS and FAIL not set simultaneously: only PASS wins */
1128 1 : manifest_upsert(m, "0000000000000005", strdup("Eve"), strdup("Pass checked"),
1129 : strdup("2025-01-05 10:00"),
1130 : MSG_FLAG_DMARC_PASS | MSG_FLAG_DMARC_CHECKED | MSG_FLAG_FLAGGED);
1131 :
1132 1 : ASSERT(manifest_save("INBOX", m) == 0, "dmarc manifest: save succeeds");
1133 1 : manifest_free(m);
1134 :
1135 1 : m = manifest_load("INBOX");
1136 1 : ASSERT(m != NULL, "dmarc manifest: load not NULL");
1137 1 : ASSERT(m->count == 5, "dmarc manifest: 5 entries");
1138 :
1139 1 : ManifestEntry *e1 = manifest_find(m, "0000000000000001");
1140 1 : ASSERT(e1 != NULL, "dmarc manifest: UID1 found");
1141 1 : ASSERT( (e1->flags & MSG_FLAG_DMARC_PASS), "dmarc manifest: UID1 DMARC_PASS set");
1142 1 : ASSERT( (e1->flags & MSG_FLAG_DMARC_CHECKED), "dmarc manifest: UID1 DMARC_CHECKED set");
1143 1 : ASSERT(!(e1->flags & MSG_FLAG_DMARC_FAIL), "dmarc manifest: UID1 DMARC_FAIL clear");
1144 :
1145 1 : ManifestEntry *e2 = manifest_find(m, "0000000000000002");
1146 1 : ASSERT(e2 != NULL, "dmarc manifest: UID2 found");
1147 1 : ASSERT( (e2->flags & MSG_FLAG_DMARC_FAIL), "dmarc manifest: UID2 DMARC_FAIL set");
1148 1 : ASSERT( (e2->flags & MSG_FLAG_DMARC_CHECKED), "dmarc manifest: UID2 DMARC_CHECKED set");
1149 1 : ASSERT(!(e2->flags & MSG_FLAG_DMARC_PASS), "dmarc manifest: UID2 DMARC_PASS clear");
1150 :
1151 1 : ManifestEntry *e3 = manifest_find(m, "0000000000000003");
1152 1 : ASSERT(e3 != NULL, "dmarc manifest: UID3 found");
1153 1 : ASSERT( (e3->flags & MSG_FLAG_DMARC_CHECKED), "dmarc manifest: UID3 DMARC_CHECKED set");
1154 1 : ASSERT(!(e3->flags & MSG_FLAG_DMARC_PASS), "dmarc manifest: UID3 DMARC_PASS clear");
1155 1 : ASSERT(!(e3->flags & MSG_FLAG_DMARC_FAIL), "dmarc manifest: UID3 DMARC_FAIL clear");
1156 :
1157 1 : ManifestEntry *e4 = manifest_find(m, "0000000000000004");
1158 1 : ASSERT(e4 != NULL, "dmarc manifest: UID4 found");
1159 1 : ASSERT( (e4->flags & MSG_FLAG_UNSEEN), "dmarc manifest: UID4 UNSEEN set");
1160 1 : ASSERT( (e4->flags & MSG_FLAG_DMARC_PASS), "dmarc manifest: UID4 DMARC_PASS set");
1161 1 : ASSERT( (e4->flags & MSG_FLAG_DMARC_CHECKED), "dmarc manifest: UID4 DMARC_CHECKED set");
1162 :
1163 1 : ManifestEntry *e5 = manifest_find(m, "0000000000000005");
1164 1 : ASSERT(e5 != NULL, "dmarc manifest: UID5 found");
1165 1 : ASSERT( (e5->flags & MSG_FLAG_FLAGGED), "dmarc manifest: UID5 FLAGGED set");
1166 1 : ASSERT( (e5->flags & MSG_FLAG_DMARC_PASS), "dmarc manifest: UID5 DMARC_PASS set");
1167 1 : ASSERT( (e5->flags & MSG_FLAG_DMARC_CHECKED), "dmarc manifest: UID5 DMARC_CHECKED set");
1168 :
1169 1 : manifest_free(m);
1170 :
1171 1 : if (old_home) setenv("HOME", old_home, 1);
1172 0 : else unsetenv("HOME");
1173 : }
1174 :
1175 : /* ── Cross-folder UID lookup ─────────────────────────────────────────── */
1176 :
1177 : /* The lookup enumerates manifests, so a folder only counts once it has one —
1178 : * mirror what a real sync produces. */
1179 5 : static void ff_add(const char *folder, const char *uid, const char *body) {
1180 5 : local_msg_save(folder, uid, body, strlen(body));
1181 5 : const char *base = "/tmp/email-cli-find-folders-test/.local/share/"
1182 : "email-cli/accounts/testuser/manifests";
1183 5 : fs_mkdir_p(base, 0700);
1184 : char path[512];
1185 5 : snprintf(path, sizeof(path), "%s/%s.tsv", base, folder);
1186 5 : FILE *f = fopen(path, "w");
1187 5 : ASSERT(f != NULL, "find_folders fixture: manifest must be writable");
1188 5 : if (f) {
1189 5 : fprintf(f, "%s\ta@x\tS\t2024-01-01 10:00\t0\n", uid);
1190 5 : fclose(f);
1191 : }
1192 : }
1193 :
1194 1 : void test_local_msg_find_folders(void) {
1195 : /* Start from a clean tree: leftovers from an earlier run would change the
1196 : * folder counts asserted below. */
1197 1 : if (system("rm -rf '/tmp/email-cli-find-folders-test'") != 0) { /* best effort */ }
1198 1 : setup_test_env("/tmp/email-cli-find-folders-test");
1199 :
1200 1 : const char *uid = "0000000000000837";
1201 1 : const char *body = "From: a@x\r\nSubject: S\r\n\r\nb";
1202 :
1203 : /* Not stored anywhere yet. */
1204 1 : char **f = NULL; int n = -1;
1205 1 : local_msg_find_folders(uid, &f, &n);
1206 1 : ASSERT(n == 0, "find_folders: 0 when the UID is nowhere");
1207 1 : ASSERT(f == NULL, "find_folders: no list when the UID is nowhere");
1208 :
1209 : /* One folder — the caller needs no --folder. */
1210 1 : ff_add("vasarlas", uid, body);
1211 1 : local_msg_find_folders(uid, &f, &n);
1212 1 : ASSERT(n == 1, "find_folders: 1 for a single-folder UID");
1213 1 : ASSERT(n == 1 && strcmp(f[0], "vasarlas") == 0, "find_folders: names that folder");
1214 1 : local_folder_list_free(f, n); f = NULL;
1215 :
1216 : /* Several folders — every candidate is reported, sorted, so the message
1217 : * and the caller's choice do not depend on readdir order. */
1218 1 : ff_add("Trash", uid, body);
1219 1 : ff_add("Archive", uid, body);
1220 1 : local_msg_find_folders(uid, &f, &n);
1221 1 : ASSERT(n == 3, "find_folders: 3 when three folders hold the UID");
1222 1 : ASSERT(n == 3 && strcmp(f[0], "Archive") == 0, "find_folders: sorted, Archive first");
1223 1 : ASSERT(n == 3 && strcmp(f[1], "Trash") == 0, "find_folders: sorted, Trash second");
1224 1 : ASSERT(n == 3 && strcmp(f[2], "vasarlas") == 0, "find_folders: sorted, vasarlas last");
1225 1 : local_folder_list_free(f, n); f = NULL;
1226 :
1227 : /* RFC 3501: INBOX is case-insensitive, so "Inbox" and "INBOX" are one
1228 : * mailbox and must not look like two conflicting locations. */
1229 1 : ff_add("INBOX", uid, body);
1230 1 : ff_add("Inbox", uid, body);
1231 1 : local_msg_find_folders(uid, &f, &n);
1232 1 : ASSERT(n == 4, "find_folders: Inbox/INBOX counted once, not twice");
1233 1 : int inbox_seen = 0;
1234 5 : for (int i = 0; i < n; i++)
1235 4 : if (strcasecmp(f[i], "INBOX") == 0) inbox_seen++;
1236 1 : ASSERT(inbox_seen == 1, "find_folders: exactly one INBOX entry");
1237 1 : local_folder_list_free(f, n); f = NULL;
1238 :
1239 : /* A NULL/empty UID must not report matches. */
1240 1 : local_msg_find_folders("", &f, &n);
1241 1 : ASSERT(n == 0, "find_folders: empty UID yields nothing");
1242 1 : local_msg_find_folders(NULL, &f, &n);
1243 1 : ASSERT(n == 0, "find_folders: NULL UID yields nothing");
1244 :
1245 : /* Freeing an empty list is a no-op, not a crash. */
1246 1 : local_folder_list_free(NULL, 0);
1247 : }
|