LCOV - code coverage report
Current view: top level - tests/unit - test_gmail_sync.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 97.7 % 858 838
Test Date: 2026-08-21 10:11:28 Functions: 100.0 % 58 58

            Line data    Source code
       1              : #include "test_helpers.h"
       2              : #include "gmail_sync.h"
       3              : #include "gmail_client.h"
       4              : #include "local_store.h"
       5              : #include "config.h"
       6              : #include <stdlib.h>
       7              : #include <string.h>
       8              : #include <stdio.h>
       9              : #include <unistd.h>
      10              : #include <sys/socket.h>
      11              : #include <sys/wait.h>
      12              : #include <netinet/in.h>
      13              : #include <arpa/inet.h>
      14              : #ifdef ENABLE_GCOV
      15              : extern void __gcov_dump(void);
      16              : #  define GCOV_FLUSH() __gcov_dump()
      17              : #else
      18              : #  define GCOV_FLUSH() ((void)0)
      19              : #endif
      20              : 
      21              : /* ── Mock HTTP server (reused from test_gmail_client.c pattern) ─────── */
      22              : 
      23           11 : static int gs_make_listener(int *port_out) {
      24           11 :     int fd = socket(AF_INET, SOCK_STREAM, 0);
      25           11 :     if (fd < 0) return -1;
      26           11 :     int one = 1;
      27           11 :     setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
      28              :     /* 2-second accept() timeout so server children exit cleanly when the
      29              :      * test exhausts its expected connections — prevents gs_wait_child() hang */
      30           11 :     struct timeval acc_tv = {.tv_sec = 2, .tv_usec = 0};
      31           11 :     setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &acc_tv, sizeof(acc_tv));
      32           11 :     struct sockaddr_in addr = {0};
      33           11 :     addr.sin_family      = AF_INET;
      34           11 :     addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
      35           11 :     addr.sin_port        = 0;
      36           22 :     if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0 ||
      37           11 :         listen(fd, 8) < 0) {
      38            0 :         close(fd);
      39            0 :         return -1;
      40              :     }
      41           11 :     socklen_t len = sizeof(addr);
      42           11 :     getsockname(fd, (struct sockaddr *)&addr, &len);
      43           11 :     *port_out = ntohs(addr.sin_port);
      44           11 :     return fd;
      45              : }
      46              : 
      47           24 : static void gs_send_json(int fd, int code, const char *body) {
      48           24 :     const char *reason = (code == 200) ? "OK" :
      49              :                          (code == 204) ? "No Content" :
      50              :                          (code == 404) ? "Not Found" : "Error";
      51              :     char hdr[512];
      52           24 :     size_t blen = body ? strlen(body) : 0;
      53           24 :     snprintf(hdr, sizeof(hdr),
      54              :              "HTTP/1.1 %d %s\r\n"
      55              :              "Content-Type: application/json\r\n"
      56              :              "Content-Length: %zu\r\n"
      57              :              "Connection: close\r\n\r\n",
      58              :              code, reason, blen);
      59              :     ssize_t r;
      60           24 :     r = write(fd, hdr, strlen(hdr)); (void)r;
      61           24 :     if (body && blen > 0) { r = write(fd, body, blen); (void)r; }
      62           24 : }
      63              : 
      64           24 : static int gs_read_req(int fd, char *buf, int bufsz) {
      65           24 :     int total = 0;
      66           24 :     while (total < bufsz - 1) {
      67           24 :         ssize_t n = read(fd, buf + total, (size_t)(bufsz - total - 1));
      68           24 :         if (n <= 0) break;
      69           24 :         total += (int)n;
      70           24 :         buf[total] = '\0';
      71           24 :         if (strstr(buf, "\r\n\r\n")) break;
      72              :     }
      73           24 :     buf[total] = '\0';
      74           24 :     return total;
      75              : }
      76              : 
      77              : /* base64url encode for mock raw message */
      78              : static const char gs_b64_chars[] =
      79              :     "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
      80              : 
      81            7 : static char *gs_b64encode(const char *data, size_t len) {
      82            7 :     size_t alloc = ((len + 2) / 3) * 4 + 1;
      83            7 :     char *out = malloc(alloc);
      84            7 :     if (!out) return NULL;
      85            7 :     size_t o = 0;
      86          299 :     for (size_t i = 0; i < len; i += 3) {
      87          292 :         unsigned int n = ((unsigned int)(unsigned char)data[i]) << 16;
      88          292 :         if (i + 1 < len) n |= ((unsigned int)(unsigned char)data[i+1]) << 8;
      89          292 :         if (i + 2 < len) n |= ((unsigned int)(unsigned char)data[i+2]);
      90          292 :         out[o++] = gs_b64_chars[(n >> 18) & 0x3F];
      91          292 :         out[o++] = gs_b64_chars[(n >> 12) & 0x3F];
      92          292 :         if (i + 1 < len) out[o++] = gs_b64_chars[(n >> 6) & 0x3F];
      93          292 :         if (i + 2 < len) out[o++] = gs_b64_chars[n & 0x3F];
      94              :     }
      95            7 :     out[o] = '\0';
      96            7 :     return out;
      97              : }
      98              : 
      99              : /* Build a GmailClient pointing at a mock server on loopback */
     100           11 : static GmailClient *gs_make_client(int port) {
     101              :     char api_base[128];
     102           11 :     snprintf(api_base, sizeof(api_base),
     103              :              "http://127.0.0.1:%d/gmail/v1/users/me", port);
     104           11 :     setenv("GMAIL_TEST_TOKEN", "test_access_token", 1);
     105           11 :     setenv("GMAIL_API_BASE_URL", api_base, 1);
     106              : 
     107           11 :     Config cfg = {0};
     108           11 :     cfg.gmail_mode = 1;
     109           11 :     cfg.gmail_refresh_token = "fake";
     110           11 :     return gmail_connect(&cfg);
     111              : }
     112              : 
     113           11 : static void gs_wait_child(pid_t pid) {
     114           11 :     if (pid > 0) { int st; waitpid(pid, &st, 0); }
     115           11 : }
     116              : 
     117              : /* ── is_filtered_label ───────────────────────────────────────────────── */
     118              : 
     119            1 : static void test_filtered_null(void) {
     120            1 :     ASSERT(gmail_sync_is_filtered_label(NULL) == 1, "filtered: NULL → filtered");
     121              : }
     122              : 
     123            1 : static void test_filtered_category(void) {
     124              :     /* CATEGORY_* labels are now indexed (not filtered) — they appear in a
     125              :      * dedicated section in the TUI label list. */
     126            1 :     ASSERT(gmail_sync_is_filtered_label("CATEGORY_PERSONAL") == 0,
     127              :            "not filtered: CATEGORY_PERSONAL (indexed as category)");
     128            1 :     ASSERT(gmail_sync_is_filtered_label("CATEGORY_SOCIAL") == 0,
     129              :            "not filtered: CATEGORY_SOCIAL (indexed as category)");
     130            1 :     ASSERT(gmail_sync_is_filtered_label("CATEGORY_PROMOTIONS") == 0,
     131              :            "not filtered: CATEGORY_PROMOTIONS (indexed as category)");
     132            1 :     ASSERT(gmail_sync_is_filtered_label("CATEGORY_UPDATES") == 0,
     133              :            "not filtered: CATEGORY_UPDATES (indexed as category)");
     134            1 :     ASSERT(gmail_sync_is_filtered_label("CATEGORY_FORUMS") == 0,
     135              :            "not filtered: CATEGORY_FORUMS (indexed as category)");
     136              : }
     137              : 
     138            1 : static void test_filtered_important(void) {
     139            1 :     ASSERT(gmail_sync_is_filtered_label("IMPORTANT") == 1,
     140              :            "filtered: IMPORTANT");
     141              : }
     142              : 
     143            1 : static void test_not_filtered_system(void) {
     144            1 :     ASSERT(gmail_sync_is_filtered_label("INBOX") == 0, "not filtered: INBOX");
     145            1 :     ASSERT(gmail_sync_is_filtered_label("SENT") == 0, "not filtered: SENT");
     146            1 :     ASSERT(gmail_sync_is_filtered_label("TRASH") == 0, "not filtered: TRASH");
     147            1 :     ASSERT(gmail_sync_is_filtered_label("SPAM") == 0, "not filtered: SPAM");
     148            1 :     ASSERT(gmail_sync_is_filtered_label("STARRED") == 0, "not filtered: STARRED");
     149            1 :     ASSERT(gmail_sync_is_filtered_label("UNREAD") == 0, "not filtered: UNREAD");
     150            1 :     ASSERT(gmail_sync_is_filtered_label("DRAFT") == 0, "not filtered: DRAFT");
     151              : }
     152              : 
     153            1 : static void test_not_filtered_user(void) {
     154            1 :     ASSERT(gmail_sync_is_filtered_label("Work") == 0, "not filtered: Work");
     155            1 :     ASSERT(gmail_sync_is_filtered_label("Personal") == 0, "not filtered: Personal");
     156            1 :     ASSERT(gmail_sync_is_filtered_label("Projects/Alpha") == 0,
     157              :            "not filtered: nested label");
     158              : }
     159              : 
     160            1 : static void test_filtered_edge_cases(void) {
     161            1 :     ASSERT(gmail_sync_is_filtered_label("") == 0, "not filtered: empty string");
     162            1 :     ASSERT(gmail_sync_is_filtered_label("CATEGORY_") == 0,
     163              :            "not filtered: bare CATEGORY_ prefix (indexed as category)");
     164            1 :     ASSERT(gmail_sync_is_filtered_label("CATEGORY_X") == 0,
     165              :            "not filtered: unknown CATEGORY_ suffix (indexed as category)");
     166              : }
     167              : 
     168              : /* ── build_hdr ───────────────────────────────────────────────────────── */
     169              : 
     170            1 : static void test_build_hdr_basic(void) {
     171            1 :     const char *raw = "From: Alice <alice@example.com>\r\n"
     172              :                       "Subject: Hello\r\n"
     173              :                       "Date: Wed, 16 Apr 2026 09:30:00 +0000\r\n"
     174              :                       "\r\n"
     175              :                       "Body text\r\n";
     176            1 :     char *labels[] = {"INBOX", "UNREAD"};
     177            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 2);
     178            1 :     ASSERT(hdr != NULL, "build_hdr basic: not NULL");
     179              : 
     180              :     /* Verify tab-separated format: from\tsubject\tdate\tlabels\tflags */
     181            1 :     int tabs = 0;
     182           67 :     for (const char *p = hdr; *p; p++)
     183           66 :         if (*p == '\t') tabs++;
     184            1 :     ASSERT(tabs == 4, "build_hdr basic: 4 tab separators");
     185              : 
     186              :     /* Verify label string contains both labels */
     187            1 :     ASSERT(strstr(hdr, "INBOX") != NULL, "build_hdr basic: has INBOX");
     188            1 :     ASSERT(strstr(hdr, "UNREAD") != NULL, "build_hdr basic: has UNREAD");
     189              : 
     190              :     /* Verify UNREAD sets MSG_FLAG_UNSEEN bit (value 1) */
     191            1 :     const char *last_tab = strrchr(hdr, '\t');
     192            1 :     ASSERT(last_tab != NULL, "build_hdr basic: has last tab");
     193            1 :     int flags = atoi(last_tab + 1);
     194            1 :     ASSERT((flags & 1) != 0, "build_hdr basic: UNSEEN flag set");
     195              : 
     196            1 :     free(hdr);
     197              : }
     198              : 
     199            1 : static void test_build_hdr_starred(void) {
     200            1 :     const char *raw = "From: Bob\r\nSubject: Star me\r\nDate: Thu, 17 Apr 2026 10:00:00 +0000\r\n\r\n";
     201            1 :     char *labels[] = {"STARRED"};
     202            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 1);
     203            1 :     ASSERT(hdr != NULL, "build_hdr starred: not NULL");
     204              : 
     205              :     /* STARRED sets MSG_FLAG_FLAGGED (value 2) */
     206            1 :     const char *last_tab = strrchr(hdr, '\t');
     207            1 :     int flags = atoi(last_tab + 1);
     208            1 :     ASSERT((flags & 2) != 0, "build_hdr starred: FLAGGED flag set");
     209              : 
     210            1 :     free(hdr);
     211              : }
     212              : 
     213            1 : static void test_build_hdr_no_labels(void) {
     214            1 :     const char *raw = "From: Nobody\r\nSubject: Archived\r\nDate: Mon, 14 Apr 2026 08:00:00 +0000\r\n\r\n";
     215            1 :     char *hdr = gmail_sync_build_hdr(raw, NULL, 0);
     216            1 :     ASSERT(hdr != NULL, "build_hdr no labels: not NULL");
     217              : 
     218              :     /* No UNREAD, no STARRED — but ATTACH_CHECKED/DMARC_CHECKED are always set */
     219            1 :     const char *last_tab = strrchr(hdr, '\t');
     220            1 :     int flags = atoi(last_tab + 1);
     221            1 :     ASSERT((flags & MSG_FLAG_UNSEEN)         == 0, "build_hdr no labels: UNSEEN not set");
     222            1 :     ASSERT((flags & MSG_FLAG_FLAGGED)        == 0, "build_hdr no labels: FLAGGED not set");
     223            1 :     ASSERT((flags & MSG_FLAG_ATTACH_CHECKED) != 0, "build_hdr no labels: ATTACH_CHECKED set");
     224              : 
     225            1 :     free(hdr);
     226              : }
     227              : 
     228            1 : static void test_build_hdr_missing_headers(void) {
     229              :     /* Message with no From/Subject/Date headers */
     230            1 :     const char *raw = "\r\nJust a body.\r\n";
     231            1 :     char *labels[] = {"INBOX"};
     232            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 1);
     233            1 :     ASSERT(hdr != NULL, "build_hdr missing headers: not NULL");
     234              : 
     235              :     /* Should have empty fields but not crash */
     236            1 :     ASSERT(hdr[0] == '\t', "build_hdr missing headers: from is empty");
     237              : 
     238            1 :     free(hdr);
     239              : }
     240              : 
     241            1 : static void test_build_hdr_combined_flags(void) {
     242            1 :     const char *raw = "From: X\r\nSubject: Y\r\nDate: Mon, 14 Apr 2026 08:00:00 +0000\r\n\r\n";
     243            1 :     char *labels[] = {"UNREAD", "STARRED", "INBOX"};
     244            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 3);
     245            1 :     ASSERT(hdr != NULL, "build_hdr combined: not NULL");
     246              : 
     247            1 :     const char *last_tab = strrchr(hdr, '\t');
     248            1 :     int flags = atoi(last_tab + 1);
     249            1 :     ASSERT((flags & 1) != 0, "build_hdr combined: UNSEEN set");
     250            1 :     ASSERT((flags & 2) != 0, "build_hdr combined: FLAGGED set");
     251              : 
     252            1 :     free(hdr);
     253              : }
     254              : 
     255              : /* ── incremental sync — no history ───────────────────────────────────── */
     256              : 
     257            1 : static void test_incremental_no_history(void) {
     258              :     /* Without local_store_init, local_gmail_history_load returns NULL → -2 */
     259            1 :     int rc = gmail_sync_incremental(NULL);
     260            1 :     ASSERT(rc == -2, "incremental: no historyId → returns -2");
     261              : }
     262              : 
     263              : /* ── repair_archive_flags ────────────────────────────────────────────── */
     264              : 
     265              : /* Helper: sets HOME to a temp dir and inits local store for Gmail. */
     266           29 : static void setup_gmail_test_env(const char *home) {
     267           29 :     setenv("HOME", home, 1);
     268           29 :     unsetenv("XDG_DATA_HOME");
     269           29 :     local_store_init("gmail://csjpeterjaket@gmail.com", "csjpeterjaket@gmail.com");
     270           29 : }
     271              : 
     272              : /* Helper: wipe the test home dir and reinitialise store.
     273              :  * Used by network-based tests that need a clean message store to avoid
     274              :  * interference from messages written by earlier tests. */
     275           13 : static void reset_gmail_test_env(void) {
     276           13 :     int _sr = system("rm -rf '/tmp/email-cli-gmail-sync-test'"); (void)_sr;
     277           13 :     setup_gmail_test_env("/tmp/email-cli-gmail-sync-test");
     278           13 : }
     279              : 
     280            1 : static void test_repair_archive_flags_clears_unseen(void) {
     281              :     /* A message synced to _nolabel while still having UNREAD label → UNSEEN
     282              :      * flag should be cleared by repair_archive_flags(). */
     283            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     284            1 :     setup_gmail_test_env(home);
     285              : 
     286            1 :     const char *uid = "0000000000aabbcc";
     287              : 
     288              :     /* Write a .hdr with UNSEEN bit set (flags = 1) */
     289            1 :     const char *hdr = "Sender\tArchived msg\t2026-04-20\tUNREAD\t1";
     290            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     291              : 
     292              :     /* Add to _nolabel index */
     293            1 :     label_idx_add("_nolabel", uid);
     294              : 
     295              :     /* Run repair */
     296            1 :     gmail_sync_repair_archive_flags();
     297              : 
     298              :     /* Load and verify UNSEEN was cleared */
     299            1 :     char *loaded = local_hdr_load("", uid);
     300            1 :     ASSERT(loaded != NULL, "repair: .hdr still exists");
     301            1 :     const char *last_tab = strrchr(loaded, '\t');
     302            1 :     ASSERT(last_tab != NULL, "repair: flags tab present");
     303            1 :     int flags = atoi(last_tab + 1);
     304            1 :     ASSERT((flags & 1) == 0, "repair: UNSEEN bit cleared for archived message");
     305            1 :     free(loaded);
     306              : }
     307              : 
     308            1 : static void test_repair_archive_flags_preserves_flagged(void) {
     309              :     /* STARRED (FLAGGED bit) must survive the repair. */
     310            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     311            1 :     setup_gmail_test_env(home);
     312              : 
     313            1 :     const char *uid = "0000000000aabbdd";
     314              : 
     315              :     /* flags = 3 = UNSEEN | FLAGGED */
     316            1 :     const char *hdr = "Sender\tStarred archived\t2026-04-20\tUNREAD,STARRED\t3";
     317            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     318            1 :     label_idx_add("_nolabel", uid);
     319              : 
     320            1 :     gmail_sync_repair_archive_flags();
     321              : 
     322            1 :     char *loaded = local_hdr_load("", uid);
     323            1 :     ASSERT(loaded != NULL, "repair flagged: .hdr exists");
     324            1 :     const char *last_tab = strrchr(loaded, '\t');
     325            1 :     int flags = atoi(last_tab + 1);
     326            1 :     ASSERT((flags & 1) == 0, "repair flagged: UNSEEN cleared");
     327            1 :     ASSERT((flags & 2) != 0, "repair flagged: FLAGGED preserved");
     328            1 :     free(loaded);
     329              : }
     330              : 
     331            1 : static void test_repair_archive_flags_noop_when_already_read(void) {
     332              :     /* If UNSEEN is already 0, the .hdr should not be rewritten (flags stay). */
     333            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     334            1 :     setup_gmail_test_env(home);
     335              : 
     336            1 :     const char *uid = "0000000000aabbee";
     337              : 
     338              :     /* flags = 0, no UNREAD */
     339            1 :     const char *hdr = "Sender\tAlready read\t2026-04-20\t\t0";
     340            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     341            1 :     label_idx_add("_nolabel", uid);
     342              : 
     343            1 :     gmail_sync_repair_archive_flags();
     344              : 
     345            1 :     char *loaded = local_hdr_load("", uid);
     346            1 :     ASSERT(loaded != NULL, "repair noop: .hdr exists");
     347            1 :     const char *last_tab = strrchr(loaded, '\t');
     348            1 :     int flags = atoi(last_tab + 1);
     349            1 :     ASSERT(flags == 0, "repair noop: flags remain 0");
     350            1 :     free(loaded);
     351              : }
     352              : 
     353            1 : static void test_build_hdr_archive_unread_flags(void) {
     354              :     /* build_hdr with UNREAD but no real label → flags still has UNSEEN.
     355              :      * The caller (sync loop) is responsible for clearing it when assigning
     356              :      * to _nolabel.  Verify build_hdr itself does not silently drop the flag. */
     357            1 :     const char *raw = "From: X\r\nSubject: Archived\r\nDate: Mon, 14 Apr 2026 08:00:00 +0000\r\n\r\n";
     358            1 :     char *labels[] = {"UNREAD", "CATEGORY_PROMOTIONS"};
     359            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 2);
     360            1 :     ASSERT(hdr != NULL, "build_hdr archive+unread: not NULL");
     361              : 
     362            1 :     const char *last_tab = strrchr(hdr, '\t');
     363            1 :     int flags = atoi(last_tab + 1);
     364              :     /* build_hdr sets UNSEEN; the caller must clear it for _nolabel messages */
     365            1 :     ASSERT((flags & 1) != 0, "build_hdr archive+unread: UNSEEN set by build_hdr (caller clears it)");
     366              : 
     367            1 :     free(hdr);
     368              : }
     369              : 
     370              : /* ── pending_fetch queue (local_store) ───────────────────────────────── */
     371              : 
     372            1 : static void test_pending_fetch_empty_initially(void) {
     373            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     374            1 :     setup_gmail_test_env(home);
     375              : 
     376            1 :     local_pending_fetch_clear();
     377            1 :     ASSERT(local_pending_fetch_count() == 0, "pending_fetch: empty initially");
     378            1 :     int count = -1;
     379            1 :     char (*uids)[17] = local_pending_fetch_load(&count);
     380            1 :     ASSERT(count == 0, "pending_fetch: load count is 0");
     381            1 :     free(uids);
     382              : }
     383              : 
     384            1 : static void test_pending_fetch_add_and_load(void) {
     385            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     386            1 :     setup_gmail_test_env(home);
     387            1 :     local_pending_fetch_clear();
     388              : 
     389            1 :     const char *uid1 = "aaaa000000000001";
     390            1 :     const char *uid2 = "aaaa000000000002";
     391            1 :     ASSERT(local_pending_fetch_add(uid1) == 0, "pending_fetch: add uid1");
     392            1 :     ASSERT(local_pending_fetch_add(uid2) == 0, "pending_fetch: add uid2");
     393              : 
     394            1 :     ASSERT(local_pending_fetch_count() == 2, "pending_fetch: count == 2");
     395              : 
     396            1 :     int count = 0;
     397            1 :     char (*uids)[17] = local_pending_fetch_load(&count);
     398            1 :     ASSERT(count == 2, "pending_fetch: load returns 2");
     399            1 :     ASSERT(uids != NULL, "pending_fetch: uids not NULL");
     400            1 :     ASSERT(strcmp(uids[0], uid1) == 0 || strcmp(uids[1], uid1) == 0,
     401              :            "pending_fetch: uid1 present");
     402            1 :     ASSERT(strcmp(uids[0], uid2) == 0 || strcmp(uids[1], uid2) == 0,
     403              :            "pending_fetch: uid2 present");
     404            1 :     free(uids);
     405              : }
     406              : 
     407            1 : static void test_pending_fetch_remove(void) {
     408            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     409            1 :     setup_gmail_test_env(home);
     410            1 :     local_pending_fetch_clear();
     411              : 
     412            1 :     local_pending_fetch_add("bbbb000000000001");
     413            1 :     local_pending_fetch_add("bbbb000000000002");
     414            1 :     local_pending_fetch_add("bbbb000000000003");
     415              : 
     416            1 :     local_pending_fetch_remove("bbbb000000000002");
     417              : 
     418            1 :     int count = 0;
     419            1 :     char (*uids)[17] = local_pending_fetch_load(&count);
     420            1 :     ASSERT(count == 2, "pending_fetch remove: 2 entries remain");
     421            1 :     int found2 = 0;
     422            3 :     for (int i = 0; i < count; i++)
     423            2 :         if (strcmp(uids[i], "bbbb000000000002") == 0) found2 = 1;
     424            1 :     ASSERT(!found2, "pending_fetch remove: uid2 gone");
     425            1 :     free(uids);
     426              : }
     427              : 
     428            1 : static void test_pending_fetch_clear(void) {
     429            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     430            1 :     setup_gmail_test_env(home);
     431              : 
     432            1 :     local_pending_fetch_add("cccc000000000001");
     433            1 :     local_pending_fetch_add("cccc000000000002");
     434            1 :     ASSERT(local_pending_fetch_count() >= 2, "pending_fetch clear: non-zero before clear");
     435              : 
     436            1 :     local_pending_fetch_clear();
     437            1 :     ASSERT(local_pending_fetch_count() == 0, "pending_fetch clear: zero after clear");
     438              : }
     439              : 
     440            1 : static void test_pending_fetch_count_matches_load(void) {
     441            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     442            1 :     setup_gmail_test_env(home);
     443            1 :     local_pending_fetch_clear();
     444              : 
     445            6 :     for (int i = 0; i < 5; i++) {
     446              :         char uid[17];
     447            5 :         snprintf(uid, sizeof(uid), "dddd%012d", i);
     448            5 :         local_pending_fetch_add(uid);
     449              :     }
     450              : 
     451            1 :     int cnt_fast = local_pending_fetch_count();
     452            1 :     int cnt_load = 0;
     453            1 :     char (*uids)[17] = local_pending_fetch_load(&cnt_load);
     454            1 :     ASSERT(cnt_fast == cnt_load,
     455              :            "pending_fetch: count() matches load() count");
     456            1 :     free(uids);
     457              : }
     458              : 
     459              : /* ── gmail_sync_rebuild_indexes ──────────────────────────────────────────── */
     460              : 
     461              : /*
     462              :  * Exercise the full rebuild_label_indexes + gmail_sync_rebuild_indexes code
     463              :  * path by populating a set of .hdr files then calling the public function.
     464              :  *
     465              :  * Coverage goal: lines 192-338 (rebuild_label_indexes) + 346-357
     466              :  * (gmail_sync_rebuild_indexes).
     467              :  */
     468            1 : static void test_rebuild_indexes_empty_store(void) {
     469              :     /* With no messages, rebuild_indexes is a no-op but must not crash. */
     470            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     471            1 :     setup_gmail_test_env(home);
     472              : 
     473            1 :     int rc = gmail_sync_rebuild_indexes();
     474            1 :     ASSERT(rc == 0, "rebuild_indexes empty: returns 0");
     475              : }
     476              : 
     477            1 : static void test_rebuild_indexes_basic(void) {
     478            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     479            1 :     setup_gmail_test_env(home);
     480              : 
     481              :     /* Write a few .hdr files with known labels */
     482            1 :     const char *uid1 = "1100000000000001";
     483            1 :     const char *uid2 = "1100000000000002";
     484            1 :     const char *uid3 = "1100000000000003";
     485              : 
     486              :     /* uid1: INBOX + UNREAD (flags=1) */
     487            1 :     const char *hdr1 = "Alice\tHello\t2026-04-01\tINBOX,UNREAD\t1";
     488            1 :     local_hdr_save("", uid1, hdr1, strlen(hdr1));
     489              : 
     490              :     /* uid2: STARRED only (flags=2) */
     491            1 :     const char *hdr2 = "Bob\tStarred\t2026-04-02\tSTARRED\t2";
     492            1 :     local_hdr_save("", uid2, hdr2, strlen(hdr2));
     493              : 
     494              :     /* uid3: SPAM (flags=0) — should be indexed as _spam */
     495            1 :     const char *hdr3 = "Eve\tSpam\t2026-04-03\tSPAM\t0";
     496            1 :     local_hdr_save("", uid3, hdr3, strlen(hdr3));
     497              : 
     498            1 :     int rc = gmail_sync_rebuild_indexes();
     499            1 :     ASSERT(rc == 0, "rebuild_indexes basic: returns 0");
     500              : 
     501              :     /* Verify INBOX index contains uid1 */
     502            1 :     char (*idx_uids)[17] = NULL;
     503            1 :     int idx_count = 0;
     504            1 :     int load_rc = label_idx_load("INBOX", &idx_uids, &idx_count);
     505            1 :     ASSERT(load_rc == 0, "rebuild_indexes: INBOX index loaded");
     506            1 :     int found1 = 0;
     507            2 :     for (int i = 0; i < idx_count; i++)
     508            1 :         if (strcmp(idx_uids[i], uid1) == 0) found1 = 1;
     509            1 :     free(idx_uids);
     510            1 :     ASSERT(found1, "rebuild_indexes: uid1 in INBOX index");
     511              : 
     512              :     /* Verify _spam index contains uid3 */
     513            1 :     idx_uids = NULL; idx_count = 0;
     514            1 :     load_rc = label_idx_load("_spam", &idx_uids, &idx_count);
     515            1 :     ASSERT(load_rc == 0, "rebuild_indexes: _spam index loaded");
     516            1 :     int found3 = 0;
     517            2 :     for (int i = 0; i < idx_count; i++)
     518            1 :         if (strcmp(idx_uids[i], uid3) == 0) found3 = 1;
     519            1 :     free(idx_uids);
     520            1 :     ASSERT(found3, "rebuild_indexes: uid3 in _spam index");
     521              : }
     522              : 
     523            1 : static void test_rebuild_indexes_nolabel(void) {
     524              :     /* A message with only CATEGORY_ labels and no real labels → goes to _nolabel.
     525              :      * Note: UNREAD is a real (non-CATEGORY_) label, so we must NOT include it
     526              :      * here — otherwise has_real=1 and the message won't go to _nolabel. */
     527            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     528            1 :     setup_gmail_test_env(home);
     529              : 
     530            1 :     const char *uid = "2200000000000001";
     531              :     /* Only category label, no UNREAD (flags=0) → should go to _nolabel */
     532            1 :     const char *hdr = "Cat\tCategory mail\t2026-04-04\tCATEGORY_PROMOTIONS\t0";
     533            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     534              : 
     535            1 :     int rc = gmail_sync_rebuild_indexes();
     536            1 :     ASSERT(rc == 0, "rebuild_indexes nolabel: returns 0");
     537              : 
     538              :     /* _nolabel index should contain this uid */
     539            1 :     char (*idx_uids)[17] = NULL;
     540            1 :     int idx_count = 0;
     541            1 :     int load_rc = label_idx_load("_nolabel", &idx_uids, &idx_count);
     542            1 :     ASSERT(load_rc == 0, "rebuild_indexes nolabel: _nolabel index loaded");
     543            1 :     int found = 0;
     544            3 :     for (int i = 0; i < idx_count; i++)
     545            2 :         if (strcmp(idx_uids[i], uid) == 0) found = 1;
     546            1 :     free(idx_uids);
     547            1 :     ASSERT(found, "rebuild_indexes nolabel: uid in _nolabel");
     548              : 
     549              :     /* The flags field should remain 0 (no UNSEEN to clear) */
     550            1 :     char *loaded = local_hdr_load("", uid);
     551            1 :     ASSERT(loaded != NULL, "rebuild_indexes nolabel: hdr still exists");
     552            1 :     const char *last_tab = strrchr(loaded, '\t');
     553            1 :     ASSERT(last_tab != NULL, "rebuild_indexes nolabel: flags tab present");
     554            1 :     int flags = atoi(last_tab + 1);
     555            1 :     ASSERT((flags & 1) == 0, "rebuild_indexes nolabel: UNSEEN clear for archived msg");
     556            1 :     free(loaded);
     557              : }
     558              : 
     559            1 : static void test_rebuild_indexes_many_labels(void) {
     560              :     /* Write many messages to force realloc in rebuild_label_indexes.
     561              :      * Each message has 6 labels → ~cap*5 pairs initial cap, then grows. */
     562            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     563            1 :     setup_gmail_test_env(home);
     564              : 
     565              :     /* Write 12 messages, each with multiple labels, to force the realloc path */
     566           13 :     for (int i = 0; i < 12; i++) {
     567              :         char uid[17];
     568           12 :         snprintf(uid, sizeof(uid), "3300%012d", i);
     569              :         char hdr[256];
     570           12 :         snprintf(hdr, sizeof(hdr),
     571              :                  "Sender%d\tSubject%d\t2026-04-01\tINBOX,UNREAD,STARRED,SENT,DRAFT,Work\t3",
     572              :                  i, i);
     573           12 :         local_hdr_save("", uid, hdr, strlen(hdr));
     574              :     }
     575              : 
     576            1 :     int rc = gmail_sync_rebuild_indexes();
     577            1 :     ASSERT(rc == 0, "rebuild_indexes many: returns 0");
     578              : 
     579              :     /* INBOX should have all 12 */
     580            1 :     char (*idx_uids)[17] = NULL;
     581            1 :     int idx_count = 0;
     582            1 :     label_idx_load("INBOX", &idx_uids, &idx_count);
     583            1 :     ASSERT(idx_count >= 12, "rebuild_indexes many: INBOX has >= 12 entries");
     584            1 :     free(idx_uids);
     585              : }
     586              : 
     587            1 : static void test_rebuild_indexes_trash(void) {
     588              :     /* TRASH label should be indexed as _trash */
     589            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     590            1 :     setup_gmail_test_env(home);
     591              : 
     592            1 :     const char *uid = "4400000000000001";
     593            1 :     const char *hdr = "Trashed\tDeleted\t2026-04-05\tTRASH\t0";
     594            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     595              : 
     596            1 :     gmail_sync_rebuild_indexes();
     597              : 
     598            1 :     char (*idx_uids)[17] = NULL;
     599            1 :     int idx_count = 0;
     600            1 :     label_idx_load("_trash", &idx_uids, &idx_count);
     601            1 :     int found = 0;
     602            2 :     for (int i = 0; i < idx_count; i++)
     603            1 :         if (strcmp(idx_uids[i], uid) == 0) found = 1;
     604            1 :     free(idx_uids);
     605            1 :     ASSERT(found, "rebuild_indexes trash: uid in _trash index");
     606              : }
     607              : 
     608            1 : static void test_rebuild_indexes_flags_sync(void) {
     609              :     /* A message where flags integer is inconsistent with labels:
     610              :      * labels say UNREAD,STARRED but flags field says 0.
     611              :      * rebuild_label_indexes must update the flags field. */
     612            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     613            1 :     setup_gmail_test_env(home);
     614              : 
     615            1 :     const char *uid = "5500000000000001";
     616              :     /* flags=0 but UNREAD and STARRED present → new_flags should be 3 */
     617            1 :     const char *hdr = "X\tFlagsSync\t2026-04-06\tINBOX,UNREAD,STARRED\t0";
     618            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     619              : 
     620            1 :     gmail_sync_rebuild_indexes();
     621              : 
     622            1 :     char *loaded = local_hdr_load("", uid);
     623            1 :     ASSERT(loaded != NULL, "flags_sync: hdr still exists");
     624            1 :     const char *last_tab = strrchr(loaded, '\t');
     625            1 :     int flags = last_tab ? atoi(last_tab + 1) : -1;
     626            1 :     ASSERT((flags & 1) != 0, "flags_sync: UNSEEN bit set after rebuild");
     627            1 :     ASSERT((flags & 2) != 0, "flags_sync: FLAGGED bit set after rebuild");
     628            1 :     free(loaded);
     629              : }
     630              : 
     631            1 : static void test_rebuild_indexes_hdr_no_tabs(void) {
     632              :     /* A malformed .hdr with no tabs should be gracefully skipped. */
     633            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     634            1 :     setup_gmail_test_env(home);
     635              : 
     636            1 :     const char *uid = "6600000000000001";
     637              :     /* No tab separators — rebuild_label_indexes should skip gracefully */
     638            1 :     const char *hdr = "malformed-hdr-no-tabs";
     639            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     640              : 
     641            1 :     int rc = gmail_sync_rebuild_indexes();
     642            1 :     ASSERT(rc == 0, "rebuild_indexes no-tabs: no crash, returns 0");
     643              : }
     644              : 
     645              : /* ── gmail_sync_incremental: re-verify no-history path after store init ─── */
     646              : 
     647            1 : static void test_incremental_with_saved_history_no_server(void) {
     648              :     /* Verify that gmail_sync_incremental returns -2 whenever there is no
     649              :      * saved historyId, regardless of prior store state.  This exercises the
     650              :      * early-return branch in gmail_sync_incremental (lines 794-798). */
     651            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     652            1 :     setup_gmail_test_env(home);
     653              : 
     654              :     /* Ensure no history id is saved (fresh environment) */
     655            1 :     char *hid = local_gmail_history_load();
     656              :     /* If some previous test left a history file, skip gracefully */
     657            1 :     if (hid) {
     658            0 :         free(hid);
     659            0 :         ASSERT(1, "incremental no-server: history present from prev test, skipped");
     660            0 :         return;
     661              :     }
     662              : 
     663            1 :     int rc = gmail_sync_incremental(NULL);
     664            1 :     ASSERT(rc == -2, "incremental no-server: no historyId → -2");
     665              : }
     666              : 
     667              : /* ── build_hdr: SPAM label sets MSG_FLAG_JUNK ─────────────────────────────── */
     668              : 
     669            1 : static void test_build_hdr_spam_flag(void) {
     670            1 :     const char *raw = "From: Spammer\r\nSubject: Buy now\r\nDate: Mon, 14 Apr 2026 08:00:00 +0000\r\n\r\n";
     671            1 :     char *labels[] = {"SPAM"};
     672            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 1);
     673            1 :     ASSERT(hdr != NULL, "build_hdr spam: not NULL");
     674              : 
     675              :     /* SPAM label sets MSG_FLAG_JUNK (1<<6 = 64 per local_store.h) */
     676            1 :     const char *last_tab = strrchr(hdr, '\t');
     677            1 :     ASSERT(last_tab != NULL, "build_hdr spam: flags tab present");
     678            1 :     int flags = atoi(last_tab + 1);
     679            1 :     ASSERT((flags & 64) != 0, "build_hdr spam: JUNK flag set");
     680              : 
     681            1 :     free(hdr);
     682              : }
     683              : 
     684              : /* ── Mock servers for gmail_sync tests ──────────────────────────────── */
     685              : 
     686              : /*
     687              :  * reconcile_server: responds to:
     688              :  *   GET /messages  → 2-message list with historyId
     689              :  *   GET /labels    → label list
     690              :  *   GET /profile   → profile with historyId (fallback)
     691              :  *   GET /messages/{id} → raw message body
     692              :  *   any other      → 404
     693              :  */
     694            5 : static void run_reconcile_server(int lfd, int count) {
     695            5 :     const char *raw_email =
     696              :         "From: alice@example.com\r\n"
     697              :         "To: me@gmail.com\r\n"
     698              :         "Subject: Reconcile Test\r\n"
     699              :         "Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n"
     700              :         "\r\n"
     701              :         "Body here.\r\n";
     702            5 :     char *b64 = gs_b64encode(raw_email, strlen(raw_email));
     703              : 
     704            5 :     struct sockaddr_in cli = {0};
     705            5 :     socklen_t cli_len = sizeof(cli);
     706           15 :     for (int i = 0; i < count; i++) {
     707           10 :         int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
     708           10 :         if (cfd < 0) break;
     709           10 :         struct timeval tv = {.tv_sec = 5, .tv_usec = 0};
     710           10 :         setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
     711              : 
     712              :         char buf[4096];
     713           10 :         if (gs_read_req(cfd, buf, (int)sizeof(buf)) <= 0) { close(cfd); continue; }
     714              : 
     715           10 :         char method[16] = {0}, path[2048] = {0};
     716           10 :         sscanf(buf, "%15s %2047s", method, path);
     717              : 
     718           10 :         if (strstr(path, "/messages") && !strstr(path, "/messages/") &&
     719            3 :             strcmp(method, "GET") == 0) {
     720            3 :             gs_send_json(cfd, 200,
     721              :                 "{\"messages\":["
     722              :                 "{\"id\":\"aabbcc0000000001\",\"threadId\":\"t1\"},"
     723              :                 "{\"id\":\"aabbcc0000000002\",\"threadId\":\"t2\"}"
     724              :                 "],\"resultSizeEstimate\":2,\"historyId\":\"99001\"}");
     725           11 :         } else if (strstr(path, "/messages/") && strcmp(method, "GET") == 0) {
     726              :             char body_buf[2048];
     727            4 :             snprintf(body_buf, sizeof(body_buf),
     728              :                 "{\"id\":\"aabbcc0000000001\","
     729              :                 "\"labelIds\":[\"INBOX\",\"UNREAD\"],"
     730              :                 "\"raw\":\"%s\"}",
     731              :                 b64 ? b64 : "");
     732            4 :             gs_send_json(cfd, 200, body_buf);
     733            3 :         } else if (strstr(path, "/labels") && strcmp(method, "GET") == 0) {
     734            3 :             gs_send_json(cfd, 200,
     735              :                 "{\"labels\":["
     736              :                 "{\"id\":\"INBOX\",\"name\":\"INBOX\"},"
     737              :                 "{\"id\":\"UNREAD\",\"name\":\"UNREAD\"}"
     738              :                 "]}");
     739            0 :         } else if (strstr(path, "/profile")) {
     740            0 :             gs_send_json(cfd, 200,
     741              :                 "{\"historyId\":\"99001\","
     742              :                 "\"emailAddress\":\"test@gmail.com\"}");
     743              :         } else {
     744            0 :             gs_send_json(cfd, 404, "{}");
     745              :         }
     746           10 :         close(cfd);
     747              :     }
     748            5 :     free(b64);
     749            5 :     close(lfd);
     750            5 :     GCOV_FLUSH();
     751            0 :     _exit(0);
     752              : }
     753              : 
     754            5 : static pid_t start_reconcile_server(int *port_out, int count) {
     755            5 :     int lfd = gs_make_listener(port_out);
     756            5 :     if (lfd < 0) return -1;
     757            5 :     pid_t pid = fork();
     758           10 :     if (pid < 0) { close(lfd); return -1; }
     759           10 :     if (pid == 0) run_reconcile_server(lfd, count);
     760            5 :     close(lfd);
     761            5 :     return pid;
     762              : }
     763              : 
     764            1 : static void test_reconcile_success(void) {
     765            1 :     reset_gmail_test_env();
     766            1 :     local_pending_fetch_clear();
     767              : 
     768            1 :     int port = 0;
     769              :     /* 2 connections: list_messages + list_labels */
     770            1 :     pid_t pid = start_reconcile_server(&port, 2);
     771            1 :     if (pid < 0) { ASSERT(0, "reconcile: could not start mock server"); return; }
     772              : 
     773            1 :     usleep(20000);
     774              : 
     775            1 :     GmailClient *gc = gs_make_client(port);
     776            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "reconcile: client connected"); return; }
     777              : 
     778            1 :     int queued = gmail_sync_reconcile(gc);
     779            1 :     int pending_cnt = local_pending_fetch_count();
     780            1 :     gmail_disconnect(gc);
     781            1 :     gs_wait_child(pid);
     782              : 
     783            1 :     ASSERT(queued == 2, "reconcile: 2 messages queued");
     784            1 :     ASSERT(pending_cnt == 2, "reconcile: pending_fetch count == 2");
     785              : }
     786              : 
     787            1 : static void test_reconcile_with_cached_messages(void) {
     788              :     /* Pre-cache one of the two server messages so reconcile sees 1 cached
     789              :      * and 1 queued.  Covers lines 450-456 (the "cached++" path). */
     790            1 :     reset_gmail_test_env();
     791            1 :     local_pending_fetch_clear();
     792              : 
     793              :     /* Pre-save uid1 (already cached) */
     794            1 :     const char *uid1 = "aabbcc0000000001";
     795            1 :     const char *raw = "From: alice@example.com\r\nSubject: Reconcile Test\r\n\r\nBody\r\n";
     796            1 :     local_msg_save("", uid1, raw, strlen(raw));
     797            1 :     local_hdr_save("", uid1, "Alice\tReconcile Test\t2024-01-01\tINBOX\t0",
     798              :                    strlen("Alice\tReconcile Test\t2024-01-01\tINBOX\t0"));
     799              : 
     800            1 :     int port = 0;
     801              :     /* 2 connections: list_messages + list_labels */
     802            1 :     pid_t pid = start_reconcile_server(&port, 2);
     803            1 :     if (pid < 0) { ASSERT(0, "reconcile_cached: could not start mock server"); return; }
     804              : 
     805            1 :     usleep(20000);
     806              : 
     807            1 :     GmailClient *gc = gs_make_client(port);
     808            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "reconcile_cached: client connected"); return; }
     809              : 
     810            1 :     int queued = gmail_sync_reconcile(gc);
     811            1 :     int pend_cnt = local_pending_fetch_count();
     812            1 :     gmail_disconnect(gc);
     813            1 :     gs_wait_child(pid);
     814              : 
     815              :     /* uid1 is cached, uid2 is new → 1 queued */
     816            1 :     ASSERT(queued == 1, "reconcile_cached: 1 new message queued");
     817            1 :     ASSERT(pend_cnt == 1, "reconcile_cached: pending count == 1");
     818              : }
     819              : 
     820            1 : static void test_fetch_pending_empty_queue(void) {
     821              :     /* fetch_pending with an empty queue should return 0 immediately. */
     822            1 :     reset_gmail_test_env();
     823            1 :     local_pending_fetch_clear();
     824              : 
     825              :     /* No network needed — empty queue exits early */
     826            1 :     setenv("GMAIL_TEST_TOKEN", "test_access_token", 1);
     827            1 :     setenv("GMAIL_API_BASE_URL", "http://127.0.0.1:1/gmail/v1/users/me", 1);
     828            1 :     Config cfg = {0};
     829            1 :     cfg.gmail_mode = 1;
     830            1 :     cfg.gmail_refresh_token = "fake";
     831            1 :     GmailClient *gc = gmail_connect(&cfg);
     832            1 :     if (!gc) { ASSERT(0, "fetch_empty: client created"); return; }
     833              : 
     834            1 :     int fetched = gmail_sync_fetch_pending(gc);
     835            1 :     gmail_disconnect(gc);
     836              : 
     837            1 :     ASSERT(fetched == 0, "fetch_empty: returns 0 on empty queue");
     838              : }
     839              : 
     840              : /* Server that returns a message with only CATEGORY_ labels (no real labels).
     841              :  * Used to test the _nolabel path in store_fetched_message (lines 397-404). */
     842            1 : static void run_nolabel_msg_server(int lfd, int count) {
     843            1 :     const char *raw_email =
     844              :         "From: promo@example.com\r\n"
     845              :         "To: me@gmail.com\r\n"
     846              :         "Subject: Promotions\r\n"
     847              :         "Date: Tue, 01 Jan 2025 00:00:00 +0000\r\n"
     848              :         "\r\n"
     849              :         "Click here to buy things!\r\n";
     850            1 :     char *b64 = gs_b64encode(raw_email, strlen(raw_email));
     851              : 
     852            1 :     struct sockaddr_in cli = {0};
     853            1 :     socklen_t cli_len = sizeof(cli);
     854            2 :     for (int i = 0; i < count; i++) {
     855            1 :         int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
     856            1 :         if (cfd < 0) break;
     857            1 :         struct timeval tv = {.tv_sec = 5, .tv_usec = 0};
     858            1 :         setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
     859              : 
     860              :         char buf[4096];
     861            1 :         if (gs_read_req(cfd, buf, (int)sizeof(buf)) <= 0) { close(cfd); continue; }
     862              : 
     863            1 :         char method[16] = {0}, path[2048] = {0};
     864            1 :         sscanf(buf, "%15s %2047s", method, path);
     865              : 
     866            2 :         if (strstr(path, "/messages/") && strcmp(method, "GET") == 0) {
     867              :             /* Message with only CATEGORY_ label and IMPORTANT (both non-real).
     868              :              * IMPORTANT is filtered (filtered_label→skip).
     869              :              * CATEGORY_PROMOTIONS passes filter but is_category_label→true.
     870              :              * Result: has_real_label=0 → goes to _nolabel with UNSEEN cleared. */
     871              :             char body_buf[2048];
     872            1 :             snprintf(body_buf, sizeof(body_buf),
     873              :                 "{\"id\":\"nolabel000000001\","
     874              :                 "\"labelIds\":[\"CATEGORY_PROMOTIONS\",\"IMPORTANT\"],"
     875              :                 "\"raw\":\"%s\"}",
     876              :                 b64 ? b64 : "");
     877            1 :             gs_send_json(cfd, 200, body_buf);
     878              :         } else {
     879            0 :             gs_send_json(cfd, 404, "{}");
     880              :         }
     881            1 :         close(cfd);
     882              :     }
     883            1 :     free(b64);
     884            1 :     close(lfd);
     885            1 :     GCOV_FLUSH();
     886            0 :     _exit(0);
     887              : }
     888              : 
     889            1 : static pid_t start_nolabel_msg_server(int *port_out, int count) {
     890            1 :     int lfd = gs_make_listener(port_out);
     891            1 :     if (lfd < 0) return -1;
     892            1 :     pid_t pid = fork();
     893            2 :     if (pid < 0) { close(lfd); return -1; }
     894            2 :     if (pid == 0) run_nolabel_msg_server(lfd, count);
     895            1 :     close(lfd);
     896            1 :     return pid;
     897              : }
     898              : 
     899            1 : static void test_fetch_pending_nolabel_message(void) {
     900              :     /* Message has CATEGORY_PROMOTIONS + IMPORTANT labels.
     901              :      * CATEGORY_PROMOTIONS: is_category_label=true → has_real_label not set.
     902              :      * IMPORTANT: filtered → skipped entirely.
     903              :      * Result: has_real_label=0 → goes to _nolabel (covers lines 397-403).
     904              :      * No UNREAD → cur_flags=0 → UNSEEN was never set, flags remain 0. */
     905            1 :     reset_gmail_test_env();
     906            1 :     local_pending_fetch_clear();
     907              : 
     908            1 :     const char *uid = "nolabel000000001";
     909            1 :     local_pending_fetch_add(uid);
     910              : 
     911            1 :     int port = 0;
     912            1 :     pid_t pid = start_nolabel_msg_server(&port, 1);
     913            1 :     if (pid < 0) { ASSERT(0, "fetch_nolabel: no server"); return; }
     914              : 
     915            1 :     usleep(20000);
     916              : 
     917            1 :     GmailClient *gc = gs_make_client(port);
     918            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "fetch_nolabel: client connected"); return; }
     919              : 
     920            1 :     int fetched = gmail_sync_fetch_pending(gc);
     921            1 :     gmail_disconnect(gc);
     922            1 :     gs_wait_child(pid);
     923              : 
     924            1 :     ASSERT(fetched == 1, "fetch_nolabel: 1 message downloaded");
     925            1 :     ASSERT(local_msg_exists("", uid), "fetch_nolabel: .eml saved");
     926              : 
     927              :     /* Should be in _nolabel index */
     928            1 :     char (*idx_uids)[17] = NULL;
     929            1 :     int idx_count = 0;
     930            1 :     label_idx_load("_nolabel", &idx_uids, &idx_count);
     931            1 :     int found = 0;
     932            2 :     for (int i = 0; i < idx_count; i++)
     933            1 :         if (strcmp(idx_uids[i], uid) == 0) found = 1;
     934            1 :     free(idx_uids);
     935            1 :     ASSERT(found, "fetch_nolabel: uid in _nolabel index");
     936              : 
     937              :     /* UNSEEN flag should be cleared (archived messages are always read) */
     938            1 :     char *hdr = local_hdr_load("", uid);
     939            1 :     if (hdr) {
     940            1 :         const char *last_tab = strrchr(hdr, '\t');
     941            1 :         int flags = last_tab ? atoi(last_tab + 1) : -1;
     942            1 :         ASSERT((flags & 1) == 0, "fetch_nolabel: UNSEEN cleared for archived msg");
     943            1 :         free(hdr);
     944              :     }
     945              : }
     946              : 
     947            1 : static void test_fetch_pending_with_rules(void) {
     948              :     /* Write a mail rule that matches alice@example.com, then fetch_pending
     949              :      * so apply_rules_to_new_message is called.  Covers lines 109-167. */
     950            1 :     reset_gmail_test_env();
     951            1 :     local_pending_fetch_clear();
     952              : 
     953              :     /* Write a rules.ini for the test account */
     954              :     char rules_dir[512];
     955            1 :     snprintf(rules_dir, sizeof(rules_dir),
     956              :              "/tmp/email-cli-gmail-sync-test/.config/email-cli/accounts/csjpeterjaket@gmail.com");
     957              :     /* Use system() with literal paths — no variable in rm -rf */
     958            1 :     int _sr2 = system("mkdir -p '/tmp/email-cli-gmail-sync-test/.config/email-cli/accounts/csjpeterjaket@gmail.com'"); (void)_sr2;
     959              : 
     960              :     char rules_path[600];
     961            1 :     snprintf(rules_path, sizeof(rules_path), "%s/rules.ini", rules_dir);
     962            1 :     FILE *f = fopen(rules_path, "w");
     963            1 :     if (f) {
     964            1 :         fputs("[rule \"Test Rule\"]\n", f);
     965            1 :         fputs("if-from = *@example.com\n", f);
     966            1 :         fputs("then-add-label = Filtered\n", f);
     967            1 :         fclose(f);
     968              :     }
     969              : 
     970            1 :     const char *uid = "aabbcc0000000001";
     971            1 :     local_pending_fetch_add(uid);
     972              : 
     973            1 :     int port = 0;
     974              :     /* 1 connection: gmail_fetch_message */
     975            1 :     pid_t pid = start_reconcile_server(&port, 1);
     976            1 :     if (pid < 0) { ASSERT(0, "fetch_rules: no server"); return; }
     977              : 
     978            1 :     usleep(20000);
     979              : 
     980            1 :     GmailClient *gc = gs_make_client(port);
     981            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "fetch_rules: client connected"); return; }
     982              : 
     983            1 :     int fetched = gmail_sync_fetch_pending(gc);
     984            1 :     gmail_disconnect(gc);
     985            1 :     gs_wait_child(pid);
     986              : 
     987            1 :     ASSERT(fetched == 1, "fetch_rules: 1 message downloaded");
     988              :     /* Rule applied — message should be fetched (rules firing doesn't prevent storage) */
     989            1 :     ASSERT(local_msg_exists("", uid), "fetch_rules: .eml saved after rule application");
     990              : }
     991              : 
     992            1 : static void test_reconcile_server_error(void) {
     993              :     /* When the server returns 500 for messages.list, gmail_list_messages
     994              :      * treats it as an empty result (0 messages). Reconcile returns 0 with
     995              :      * nothing queued.  Exercises the error-response code path in list. */
     996            1 :     reset_gmail_test_env();
     997              : 
     998            1 :     int port = 0;
     999            1 :     int lfd = gs_make_listener(&port);
    1000            1 :     if (lfd < 0) { ASSERT(0, "reconcile_server_err: no listener"); return; }
    1001              : 
    1002            1 :     pid_t pid = fork();
    1003            2 :     if (pid < 0) { close(lfd); ASSERT(0, "reconcile_server_err: fork failed"); return; }
    1004            2 :     if (pid == 0) {
    1005            1 :         struct sockaddr_in cli = {0};
    1006            1 :         socklen_t cli_len = sizeof(cli);
    1007            4 :         for (int i = 0; i < 4; i++) {
    1008            4 :             int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
    1009            4 :             if (cfd < 0) break;
    1010              :             char buf[512];
    1011            3 :             gs_read_req(cfd, buf, (int)sizeof(buf));
    1012            3 :             gs_send_json(cfd, 500, "{\"error\":\"server error\"}");
    1013            3 :             close(cfd);
    1014              :         }
    1015            1 :         close(lfd);
    1016            1 :         GCOV_FLUSH();
    1017            0 :         _exit(0);
    1018              :     }
    1019            1 :     close(lfd);
    1020            1 :     usleep(20000);
    1021              : 
    1022            1 :     GmailClient *gc = gs_make_client(port);
    1023            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "reconcile_server_err: client connected"); return; }
    1024              : 
    1025            1 :     int rc = gmail_sync_reconcile(gc);
    1026            1 :     gmail_disconnect(gc);
    1027            1 :     gs_wait_child(pid);
    1028              : 
    1029              :     /* gmail_list_messages returns 0 even on 500 (treats as empty list).
    1030              :      * So reconcile returns 0 with 0 messages queued. */
    1031            1 :     ASSERT(rc == 0, "reconcile_server_err: returns 0 (empty list) on server 500");
    1032            1 :     ASSERT(local_pending_fetch_count() == 0,
    1033              :            "reconcile_server_err: nothing queued when server returns 500");
    1034              : }
    1035              : 
    1036            1 : static void test_fetch_pending_success(void) {
    1037            1 :     reset_gmail_test_env();
    1038            1 :     local_pending_fetch_clear();
    1039              : 
    1040            1 :     const char *uid = "aabbcc0000000001";
    1041            1 :     local_pending_fetch_add(uid);
    1042              : 
    1043            1 :     int port = 0;
    1044              :     /* 1 connection: gmail_fetch_message for the 1 pending UID */
    1045            1 :     pid_t pid = start_reconcile_server(&port, 1);
    1046            1 :     if (pid < 0) { ASSERT(0, "fetch_pending: no server"); return; }
    1047              : 
    1048            1 :     usleep(20000);
    1049              : 
    1050            1 :     GmailClient *gc = gs_make_client(port);
    1051            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "fetch_pending: client connected"); return; }
    1052              : 
    1053            1 :     int fetched = gmail_sync_fetch_pending(gc);
    1054            1 :     int msg_ok  = local_msg_exists("", uid);
    1055            1 :     int hdr_ok  = local_hdr_exists("", uid);
    1056            1 :     int pend_cnt = local_pending_fetch_count();
    1057            1 :     gmail_disconnect(gc);
    1058            1 :     gs_wait_child(pid);
    1059              : 
    1060            1 :     ASSERT(fetched == 1, "fetch_pending: 1 message downloaded");
    1061            1 :     ASSERT(msg_ok,   "fetch_pending: .eml saved");
    1062            1 :     ASSERT(hdr_ok,   "fetch_pending: .hdr saved");
    1063            1 :     ASSERT(pend_cnt == 0, "fetch_pending: queue empty");
    1064              : }
    1065              : 
    1066            1 : static void test_fetch_pending_already_cached(void) {
    1067            1 :     reset_gmail_test_env();
    1068            1 :     local_pending_fetch_clear();
    1069              : 
    1070            1 :     const char *uid = "bbccdd0000000001";
    1071            1 :     const char *raw = "From: X\r\nSubject: Y\r\n\r\nBody\r\n";
    1072            1 :     local_msg_save("", uid, raw, strlen(raw));
    1073            1 :     const char *hdr = "X\tY\t2024-01-01\tINBOX\t0";
    1074            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
    1075            1 :     local_pending_fetch_add(uid);
    1076              : 
    1077              :     /* Use an unreachable port — no network calls should happen */
    1078            1 :     setenv("GMAIL_TEST_TOKEN", "test_access_token", 1);
    1079            1 :     setenv("GMAIL_API_BASE_URL", "http://127.0.0.1:1/gmail/v1/users/me", 1);
    1080            1 :     Config cfg = {0};
    1081            1 :     cfg.gmail_mode = 1;
    1082            1 :     cfg.gmail_refresh_token = "fake";
    1083            1 :     GmailClient *gc = gmail_connect(&cfg);
    1084            1 :     if (!gc) { ASSERT(0, "fetch_cached: client created"); return; }
    1085              : 
    1086            1 :     int fetched = gmail_sync_fetch_pending(gc);
    1087            1 :     int pend_cnt = local_pending_fetch_count();
    1088            1 :     gmail_disconnect(gc);
    1089              : 
    1090            1 :     ASSERT(fetched == 0, "fetch_cached: 0 downloaded (already cached)");
    1091            1 :     ASSERT(pend_cnt == 0, "fetch_cached: queue cleared");
    1092              : }
    1093              : 
    1094            1 : static void test_fetch_pending_server_error(void) {
    1095            1 :     reset_gmail_test_env();
    1096            1 :     local_pending_fetch_clear();
    1097              : 
    1098            1 :     const char *uid = "ccddee0000000001";
    1099            1 :     local_pending_fetch_add(uid);
    1100              : 
    1101            1 :     int port = 0;
    1102            1 :     int lfd = gs_make_listener(&port);
    1103            1 :     if (lfd < 0) { ASSERT(0, "fetch_err: no listener"); return; }
    1104              : 
    1105            1 :     pid_t pid = fork();
    1106            2 :     if (pid < 0) { close(lfd); ASSERT(0, "fetch_err: fork failed"); return; }
    1107            2 :     if (pid == 0) {
    1108            1 :         struct sockaddr_in cli = {0};
    1109            1 :         socklen_t cli_len = sizeof(cli);
    1110            2 :         for (int i = 0; i < 3; i++) {
    1111            2 :             int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
    1112            2 :             if (cfd < 0) break;
    1113              :             char buf[512];
    1114            1 :             gs_read_req(cfd, buf, (int)sizeof(buf));
    1115            1 :             gs_send_json(cfd, 404, "{\"error\":{\"code\":404}}");
    1116            1 :             close(cfd);
    1117              :         }
    1118            1 :         close(lfd);
    1119            1 :         GCOV_FLUSH();
    1120            0 :         _exit(0);
    1121              :     }
    1122            1 :     close(lfd);
    1123            1 :     usleep(20000);
    1124              : 
    1125            1 :     GmailClient *gc = gs_make_client(port);
    1126            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "fetch_err: client connected"); return; }
    1127              : 
    1128            1 :     int fetched = gmail_sync_fetch_pending(gc);
    1129            1 :     int pend_cnt = local_pending_fetch_count();
    1130            1 :     gmail_disconnect(gc);
    1131            1 :     gs_wait_child(pid);
    1132              : 
    1133            1 :     ASSERT(fetched == 0, "fetch_err: 0 downloaded on 404");
    1134            1 :     ASSERT(pend_cnt == 1, "fetch_err: uid stays in queue");
    1135              : }
    1136              : 
    1137              : /*
    1138              :  * incremental_server: handles the various gmail_sync_incremental paths.
    1139              :  * Returns history with messagesAdded, labelsAdded, labelsRemoved, messagesDeleted.
    1140              :  */
    1141            1 : static void run_incremental_server(int lfd, int count) {
    1142            1 :     const char *raw_email =
    1143              :         "From: bob@example.com\r\n"
    1144              :         "To: me@gmail.com\r\n"
    1145              :         "Subject: Incremental Test\r\n"
    1146              :         "Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n"
    1147              :         "\r\n"
    1148              :         "Incremental body.\r\n";
    1149            1 :     char *b64 = gs_b64encode(raw_email, strlen(raw_email));
    1150              : 
    1151            1 :     struct sockaddr_in cli = {0};
    1152            1 :     socklen_t cli_len = sizeof(cli);
    1153            6 :     for (int i = 0; i < count; i++) {
    1154            5 :         int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
    1155            5 :         if (cfd < 0) break;
    1156            5 :         struct timeval tv = {.tv_sec = 5, .tv_usec = 0};
    1157            5 :         setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    1158              : 
    1159              :         char buf[4096];
    1160            5 :         if (gs_read_req(cfd, buf, (int)sizeof(buf)) <= 0) { close(cfd); continue; }
    1161              : 
    1162            5 :         char method[16] = {0}, path[2048] = {0};
    1163            5 :         sscanf(buf, "%15s %2047s", method, path);
    1164              : 
    1165            6 :         if (strstr(path, "/history") && strcmp(method, "GET") == 0) {
    1166              :             /* Nested format matching the real Gmail History API:
    1167              :              * events are objects inside the "history" array, and each
    1168              :              * event item nests the message ID under {"message":{"id":"..."}}. */
    1169              :             char body_buf[2048];
    1170            1 :             snprintf(body_buf, sizeof(body_buf),
    1171              :                 "{"
    1172              :                 "\"historyId\":\"99999\","
    1173              :                 "\"history\":["
    1174              :                 "  {"
    1175              :                 "    \"messagesAdded\":[{\"message\":{\"id\":\"incr000000000001\"}}],"
    1176              :                 "    \"labelsAdded\":[{\"message\":{\"id\":\"incr000000000001\"},\"labelIds\":[\"STARRED\"]}],"
    1177              :                 "    \"labelsRemoved\":[{\"message\":{\"id\":\"incr000000000001\"},\"labelIds\":[\"UNREAD\"]}],"
    1178              :                 "    \"messagesDeleted\":[{\"message\":{\"id\":\"incr000000000002\"}}]"
    1179              :                 "  }"
    1180              :                 "]"
    1181              :                 "}");
    1182            1 :             gs_send_json(cfd, 200, body_buf);
    1183            6 :         } else if (strstr(path, "/messages/") && strcmp(method, "GET") == 0) {
    1184              :             char body_buf[2048];
    1185            2 :             snprintf(body_buf, sizeof(body_buf),
    1186              :                 "{\"id\":\"incr000000000001\","
    1187              :                 "\"labelIds\":[\"INBOX\",\"STARRED\"],"
    1188              :                 "\"raw\":\"%s\"}",
    1189              :                 b64 ? b64 : "");
    1190            2 :             gs_send_json(cfd, 200, body_buf);
    1191            2 :         } else if (strstr(path, "/labels") && strcmp(method, "GET") == 0) {
    1192            2 :             gs_send_json(cfd, 200,
    1193              :                 "{\"labels\":["
    1194              :                 "{\"id\":\"INBOX\",\"name\":\"INBOX\"},"
    1195              :                 "{\"id\":\"STARRED\",\"name\":\"STARRED\"},"
    1196              :                 "{\"id\":\"UNREAD\",\"name\":\"UNREAD\"}"
    1197              :                 "]}");
    1198            0 :         } else if (strstr(path, "/profile")) {
    1199            0 :             gs_send_json(cfd, 200,
    1200              :                 "{\"historyId\":\"99999\","
    1201              :                 "\"emailAddress\":\"test@gmail.com\"}");
    1202              :         } else {
    1203            0 :             gs_send_json(cfd, 404, "{}");
    1204              :         }
    1205            5 :         close(cfd);
    1206              :     }
    1207            1 :     free(b64);
    1208            1 :     close(lfd);
    1209            1 :     GCOV_FLUSH();
    1210            0 :     _exit(0);
    1211              : }
    1212              : 
    1213            1 : static pid_t start_incremental_server(int *port_out, int count) {
    1214            1 :     int lfd = gs_make_listener(port_out);
    1215            1 :     if (lfd < 0) return -1;
    1216            1 :     pid_t pid = fork();
    1217            2 :     if (pid < 0) { close(lfd); return -1; }
    1218            2 :     if (pid == 0) run_incremental_server(lfd, count);
    1219            1 :     close(lfd);
    1220            1 :     return pid;
    1221              : }
    1222              : 
    1223            1 : static void test_incremental_with_history(void) {
    1224              :     /* Tests gmail_sync_incremental with a live mock server.
    1225              :      * Covers: process_message_added, process_labels_added,
    1226              :      *         process_labels_removed, process_message_deleted,
    1227              :      *         the label-refresh branch (label_changes > 0). */
    1228            1 :     reset_gmail_test_env();
    1229            1 :     local_pending_fetch_clear();
    1230              : 
    1231            1 :     local_gmail_history_save("12345");
    1232              : 
    1233              :     /* Pre-save the "deleted" message so remove operations have something to do */
    1234            1 :     const char *del_uid = "incr000000000002";
    1235            1 :     local_msg_save("", del_uid, "From: X\r\n\r\nbody\r\n", 18);
    1236            1 :     local_hdr_save("", del_uid, "X\tDel\t2024-01-01\tINBOX\t0", 25);
    1237            1 :     label_idx_add("INBOX", del_uid);
    1238              : 
    1239            1 :     int port = 0;
    1240              :     /* Connections: history(1) + msg_added fetch(1) + labels_removed fetch(1)
    1241              :      *              + labels for msg_deleted(1) + labels refresh(1) = 5 */
    1242            1 :     pid_t pid = start_incremental_server(&port, 5);
    1243            1 :     if (pid < 0) { ASSERT(0, "incremental: could not start mock server"); return; }
    1244              : 
    1245            1 :     usleep(20000);
    1246              : 
    1247            1 :     GmailClient *gc = gs_make_client(port);
    1248            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "incremental: client connected"); return; }
    1249              : 
    1250            1 :     int rc = gmail_sync_incremental(gc);
    1251            1 :     char *hid = local_gmail_history_load();
    1252            1 :     int msg_ok = local_msg_exists("", "incr000000000001");
    1253            1 :     gmail_disconnect(gc);
    1254            1 :     gs_wait_child(pid);
    1255              : 
    1256            1 :     ASSERT(rc == 0, "incremental: returns 0 on success");
    1257            1 :     ASSERT(hid != NULL, "incremental: historyId saved");
    1258            1 :     if (hid) {
    1259            1 :         ASSERT(strcmp(hid, "99999") == 0, "incremental: new historyId == 99999");
    1260            1 :         free(hid);
    1261              :     }
    1262            1 :     ASSERT(msg_ok, "incremental: added message saved");
    1263              : }
    1264              : 
    1265            1 : static void test_incremental_history_expired(void) {
    1266              :     /* Server returns 404 for history → must return -2. */
    1267            1 :     reset_gmail_test_env();
    1268              : 
    1269            1 :     local_gmail_history_save("old_history_id");
    1270              : 
    1271            1 :     int port = 0;
    1272            1 :     int lfd = gs_make_listener(&port);
    1273            1 :     if (lfd < 0) { ASSERT(0, "incr_expired: no listener"); return; }
    1274              : 
    1275            1 :     pid_t pid = fork();
    1276            2 :     if (pid < 0) { close(lfd); ASSERT(0, "incr_expired: fork failed"); return; }
    1277            2 :     if (pid == 0) {
    1278            1 :         struct sockaddr_in cli = {0};
    1279            1 :         socklen_t cli_len = sizeof(cli);
    1280            2 :         for (int i = 0; i < 3; i++) {
    1281            2 :             int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
    1282            2 :             if (cfd < 0) break;
    1283              :             char buf[512];
    1284            1 :             gs_read_req(cfd, buf, (int)sizeof(buf));
    1285            1 :             gs_send_json(cfd, 404,
    1286              :                 "{\"error\":{\"code\":404,\"message\":\"History ID is too old\"}}");
    1287            1 :             close(cfd);
    1288              :         }
    1289            1 :         close(lfd);
    1290            1 :         GCOV_FLUSH();
    1291            0 :         _exit(0);
    1292              :     }
    1293            1 :     close(lfd);
    1294            1 :     usleep(20000);
    1295              : 
    1296            1 :     GmailClient *gc = gs_make_client(port);
    1297            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "incr_expired: client connected"); return; }
    1298              : 
    1299            1 :     int rc = gmail_sync_incremental(gc);
    1300            1 :     gmail_disconnect(gc);
    1301            1 :     gs_wait_child(pid);
    1302              : 
    1303            1 :     ASSERT(rc == -2, "incr_expired: returns -2 on expired historyId");
    1304              : }
    1305              : 
    1306            1 : static void test_sync_full_success(void) {
    1307              :     /* gmail_sync_full = reconcile + fetch_pending + rebuild_indexes. */
    1308            1 :     reset_gmail_test_env();
    1309            1 :     local_pending_fetch_clear();
    1310              : 
    1311            1 :     int port = 0;
    1312              :     /* reconcile: list(1) + labels(1) = 2
    1313              :      * fetch_pending: 2 new messages × 1 each = 2
    1314              :      * Total = 4 */
    1315            1 :     pid_t pid = start_reconcile_server(&port, 4);
    1316            1 :     if (pid < 0) { ASSERT(0, "sync_full: could not start mock server"); return; }
    1317              : 
    1318            1 :     usleep(20000);
    1319              : 
    1320            1 :     GmailClient *gc = gs_make_client(port);
    1321            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "sync_full: client connected"); return; }
    1322              : 
    1323            1 :     int rc = gmail_sync_full(gc);
    1324            1 :     gmail_disconnect(gc);
    1325            1 :     gs_wait_child(pid);
    1326              : 
    1327            1 :     ASSERT(rc == 0, "sync_full: returns 0");
    1328              : }
    1329              : 
    1330            1 : static void test_reconcile_no_history_id_in_list(void) {
    1331              :     /* Messages list response has no historyId → falls back to GET /profile.
    1332              :      * Covers the else-branch in gmail_sync_reconcile. */
    1333            1 :     reset_gmail_test_env();
    1334            1 :     local_pending_fetch_clear();
    1335              : 
    1336            1 :     int port = 0;
    1337            1 :     int lfd = gs_make_listener(&port);
    1338            1 :     if (lfd < 0) { ASSERT(0, "reconcile_nohid: no listener"); return; }
    1339              : 
    1340            1 :     pid_t pid = fork();
    1341            2 :     if (pid < 0) { close(lfd); ASSERT(0, "reconcile_nohid: fork failed"); return; }
    1342            2 :     if (pid == 0) {
    1343            1 :         struct sockaddr_in cli = {0};
    1344            1 :         socklen_t cli_len = sizeof(cli);
    1345            4 :         for (int i = 0; i < 6; i++) {
    1346            4 :             int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
    1347            4 :             if (cfd < 0) break;
    1348            3 :             struct timeval tv = {.tv_sec = 5, .tv_usec = 0};
    1349            3 :             setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    1350              :             char buf[4096];
    1351            3 :             if (gs_read_req(cfd, buf, (int)sizeof(buf)) <= 0) { close(cfd); continue; }
    1352            3 :             char method[16] = {0}, path[2048] = {0};
    1353            3 :             sscanf(buf, "%15s %2047s", method, path);
    1354              : 
    1355            3 :             if (strstr(path, "/messages") && !strstr(path, "/messages/") &&
    1356            1 :                 strcmp(method, "GET") == 0) {
    1357              :                 /* No historyId in list response — forces /profile fallback */
    1358            1 :                 gs_send_json(cfd, 200,
    1359              :                     "{\"messages\":[],"
    1360              :                     "\"resultSizeEstimate\":0}");
    1361            2 :             } else if (strstr(path, "/profile")) {
    1362            1 :                 gs_send_json(cfd, 200,
    1363              :                     "{\"historyId\":\"77777\","
    1364              :                     "\"emailAddress\":\"test@gmail.com\"}");
    1365            1 :             } else if (strstr(path, "/labels") && strcmp(method, "GET") == 0) {
    1366            1 :                 gs_send_json(cfd, 200,
    1367              :                     "{\"labels\":["
    1368              :                     "{\"id\":\"INBOX\",\"name\":\"INBOX\"}"
    1369              :                     "]}");
    1370              :             } else {
    1371            0 :                 gs_send_json(cfd, 404, "{}");
    1372              :             }
    1373            3 :             close(cfd);
    1374              :         }
    1375            1 :         close(lfd);
    1376            1 :         GCOV_FLUSH();
    1377            0 :         _exit(0);
    1378              :     }
    1379            1 :     close(lfd);
    1380            1 :     usleep(20000);
    1381              : 
    1382            1 :     GmailClient *gc = gs_make_client(port);
    1383            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "reconcile_nohid: client connected"); return; }
    1384              : 
    1385            1 :     int queued = gmail_sync_reconcile(gc);
    1386            1 :     char *hid = local_gmail_history_load();
    1387            1 :     gmail_disconnect(gc);
    1388            1 :     gs_wait_child(pid);
    1389              : 
    1390            1 :     ASSERT(queued == 0, "reconcile_nohid: 0 messages queued (empty server)");
    1391            1 :     ASSERT(hid != NULL, "reconcile_nohid: historyId saved from /profile");
    1392            1 :     free(hid);
    1393              : }
    1394              : 
    1395              : /* ── Registration ────────────────────────────────────────────────────── */
    1396              : 
    1397            1 : void test_gmail_sync(void) {
    1398            1 :     RUN_TEST(test_filtered_null);
    1399            1 :     RUN_TEST(test_filtered_category);
    1400            1 :     RUN_TEST(test_filtered_important);
    1401            1 :     RUN_TEST(test_not_filtered_system);
    1402            1 :     RUN_TEST(test_not_filtered_user);
    1403            1 :     RUN_TEST(test_filtered_edge_cases);
    1404            1 :     RUN_TEST(test_build_hdr_basic);
    1405            1 :     RUN_TEST(test_build_hdr_starred);
    1406            1 :     RUN_TEST(test_build_hdr_no_labels);
    1407            1 :     RUN_TEST(test_build_hdr_missing_headers);
    1408            1 :     RUN_TEST(test_build_hdr_combined_flags);
    1409            1 :     RUN_TEST(test_build_hdr_archive_unread_flags);
    1410            1 :     RUN_TEST(test_build_hdr_spam_flag);
    1411            1 :     RUN_TEST(test_incremental_no_history);
    1412            1 :     RUN_TEST(test_incremental_with_saved_history_no_server);
    1413            1 :     RUN_TEST(test_repair_archive_flags_clears_unseen);
    1414            1 :     RUN_TEST(test_repair_archive_flags_preserves_flagged);
    1415            1 :     RUN_TEST(test_repair_archive_flags_noop_when_already_read);
    1416            1 :     RUN_TEST(test_pending_fetch_empty_initially);
    1417            1 :     RUN_TEST(test_pending_fetch_add_and_load);
    1418            1 :     RUN_TEST(test_pending_fetch_remove);
    1419            1 :     RUN_TEST(test_pending_fetch_clear);
    1420            1 :     RUN_TEST(test_pending_fetch_count_matches_load);
    1421            1 :     RUN_TEST(test_rebuild_indexes_empty_store);
    1422            1 :     RUN_TEST(test_rebuild_indexes_basic);
    1423            1 :     RUN_TEST(test_rebuild_indexes_nolabel);
    1424            1 :     RUN_TEST(test_rebuild_indexes_many_labels);
    1425            1 :     RUN_TEST(test_rebuild_indexes_trash);
    1426            1 :     RUN_TEST(test_rebuild_indexes_flags_sync);
    1427            1 :     RUN_TEST(test_rebuild_indexes_hdr_no_tabs);
    1428            1 :     RUN_TEST(test_reconcile_success);
    1429            1 :     RUN_TEST(test_reconcile_with_cached_messages);
    1430            1 :     RUN_TEST(test_reconcile_server_error);
    1431            1 :     RUN_TEST(test_reconcile_no_history_id_in_list);
    1432            1 :     RUN_TEST(test_fetch_pending_success);
    1433            1 :     RUN_TEST(test_fetch_pending_empty_queue);
    1434            1 :     RUN_TEST(test_fetch_pending_already_cached);
    1435            1 :     RUN_TEST(test_fetch_pending_server_error);
    1436            1 :     RUN_TEST(test_fetch_pending_nolabel_message);
    1437            1 :     RUN_TEST(test_fetch_pending_with_rules);
    1438            1 :     RUN_TEST(test_incremental_with_history);
    1439            1 :     RUN_TEST(test_incremental_history_expired);
    1440            1 :     RUN_TEST(test_sync_full_success);
    1441            1 : }
        

Generated by: LCOV version 2.0-1