LCOV - code coverage report
Current view: top level - tests/unit - test_local_store.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 96.8 % 594 575
Test Date: 2026-05-07 15:53:07 Functions: 100.0 % 25 25

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

Generated by: LCOV version 2.0-1