LCOV - code coverage report
Current view: top level - tests/unit - test_label_idx.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 99.5 % 426 424
Test Date: 2026-05-07 15:53:07 Functions: 100.0 % 28 28

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

Generated by: LCOV version 2.0-1