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 % 856 836
Test Date: 2026-05-07 15:53:07 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           64 :     for (const char *p = hdr; *p; p++)
     183           63 :         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              :     /* Flags should be 0 (no UNREAD, no STARRED) */
     219            1 :     const char *last_tab = strrchr(hdr, '\t');
     220            1 :     int flags = atoi(last_tab + 1);
     221            1 :     ASSERT(flags == 0, "build_hdr no labels: flags=0");
     222              : 
     223            1 :     free(hdr);
     224              : }
     225              : 
     226            1 : static void test_build_hdr_missing_headers(void) {
     227              :     /* Message with no From/Subject/Date headers */
     228            1 :     const char *raw = "\r\nJust a body.\r\n";
     229            1 :     char *labels[] = {"INBOX"};
     230            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 1);
     231            1 :     ASSERT(hdr != NULL, "build_hdr missing headers: not NULL");
     232              : 
     233              :     /* Should have empty fields but not crash */
     234            1 :     ASSERT(hdr[0] == '\t', "build_hdr missing headers: from is empty");
     235              : 
     236            1 :     free(hdr);
     237              : }
     238              : 
     239            1 : static void test_build_hdr_combined_flags(void) {
     240            1 :     const char *raw = "From: X\r\nSubject: Y\r\nDate: Mon, 14 Apr 2026 08:00:00 +0000\r\n\r\n";
     241            1 :     char *labels[] = {"UNREAD", "STARRED", "INBOX"};
     242            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 3);
     243            1 :     ASSERT(hdr != NULL, "build_hdr combined: not NULL");
     244              : 
     245            1 :     const char *last_tab = strrchr(hdr, '\t');
     246            1 :     int flags = atoi(last_tab + 1);
     247            1 :     ASSERT((flags & 1) != 0, "build_hdr combined: UNSEEN set");
     248            1 :     ASSERT((flags & 2) != 0, "build_hdr combined: FLAGGED set");
     249              : 
     250            1 :     free(hdr);
     251              : }
     252              : 
     253              : /* ── incremental sync — no history ───────────────────────────────────── */
     254              : 
     255            1 : static void test_incremental_no_history(void) {
     256              :     /* Without local_store_init, local_gmail_history_load returns NULL → -2 */
     257            1 :     int rc = gmail_sync_incremental(NULL);
     258            1 :     ASSERT(rc == -2, "incremental: no historyId → returns -2");
     259              : }
     260              : 
     261              : /* ── repair_archive_flags ────────────────────────────────────────────── */
     262              : 
     263              : /* Helper: sets HOME to a temp dir and inits local store for Gmail. */
     264           29 : static void setup_gmail_test_env(const char *home) {
     265           29 :     setenv("HOME", home, 1);
     266           29 :     unsetenv("XDG_DATA_HOME");
     267           29 :     local_store_init("gmail://csjpeterjaket@gmail.com", "csjpeterjaket@gmail.com");
     268           29 : }
     269              : 
     270              : /* Helper: wipe the test home dir and reinitialise store.
     271              :  * Used by network-based tests that need a clean message store to avoid
     272              :  * interference from messages written by earlier tests. */
     273           13 : static void reset_gmail_test_env(void) {
     274           13 :     int _sr = system("rm -rf '/tmp/email-cli-gmail-sync-test'"); (void)_sr;
     275           13 :     setup_gmail_test_env("/tmp/email-cli-gmail-sync-test");
     276           13 : }
     277              : 
     278            1 : static void test_repair_archive_flags_clears_unseen(void) {
     279              :     /* A message synced to _nolabel while still having UNREAD label → UNSEEN
     280              :      * flag should be cleared by repair_archive_flags(). */
     281            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     282            1 :     setup_gmail_test_env(home);
     283              : 
     284            1 :     const char *uid = "0000000000aabbcc";
     285              : 
     286              :     /* Write a .hdr with UNSEEN bit set (flags = 1) */
     287            1 :     const char *hdr = "Sender\tArchived msg\t2026-04-20\tUNREAD\t1";
     288            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     289              : 
     290              :     /* Add to _nolabel index */
     291            1 :     label_idx_add("_nolabel", uid);
     292              : 
     293              :     /* Run repair */
     294            1 :     gmail_sync_repair_archive_flags();
     295              : 
     296              :     /* Load and verify UNSEEN was cleared */
     297            1 :     char *loaded = local_hdr_load("", uid);
     298            1 :     ASSERT(loaded != NULL, "repair: .hdr still exists");
     299            1 :     const char *last_tab = strrchr(loaded, '\t');
     300            1 :     ASSERT(last_tab != NULL, "repair: flags tab present");
     301            1 :     int flags = atoi(last_tab + 1);
     302            1 :     ASSERT((flags & 1) == 0, "repair: UNSEEN bit cleared for archived message");
     303            1 :     free(loaded);
     304              : }
     305              : 
     306            1 : static void test_repair_archive_flags_preserves_flagged(void) {
     307              :     /* STARRED (FLAGGED bit) must survive the repair. */
     308            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     309            1 :     setup_gmail_test_env(home);
     310              : 
     311            1 :     const char *uid = "0000000000aabbdd";
     312              : 
     313              :     /* flags = 3 = UNSEEN | FLAGGED */
     314            1 :     const char *hdr = "Sender\tStarred archived\t2026-04-20\tUNREAD,STARRED\t3";
     315            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     316            1 :     label_idx_add("_nolabel", uid);
     317              : 
     318            1 :     gmail_sync_repair_archive_flags();
     319              : 
     320            1 :     char *loaded = local_hdr_load("", uid);
     321            1 :     ASSERT(loaded != NULL, "repair flagged: .hdr exists");
     322            1 :     const char *last_tab = strrchr(loaded, '\t');
     323            1 :     int flags = atoi(last_tab + 1);
     324            1 :     ASSERT((flags & 1) == 0, "repair flagged: UNSEEN cleared");
     325            1 :     ASSERT((flags & 2) != 0, "repair flagged: FLAGGED preserved");
     326            1 :     free(loaded);
     327              : }
     328              : 
     329            1 : static void test_repair_archive_flags_noop_when_already_read(void) {
     330              :     /* If UNSEEN is already 0, the .hdr should not be rewritten (flags stay). */
     331            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     332            1 :     setup_gmail_test_env(home);
     333              : 
     334            1 :     const char *uid = "0000000000aabbee";
     335              : 
     336              :     /* flags = 0, no UNREAD */
     337            1 :     const char *hdr = "Sender\tAlready read\t2026-04-20\t\t0";
     338            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     339            1 :     label_idx_add("_nolabel", uid);
     340              : 
     341            1 :     gmail_sync_repair_archive_flags();
     342              : 
     343            1 :     char *loaded = local_hdr_load("", uid);
     344            1 :     ASSERT(loaded != NULL, "repair noop: .hdr exists");
     345            1 :     const char *last_tab = strrchr(loaded, '\t');
     346            1 :     int flags = atoi(last_tab + 1);
     347            1 :     ASSERT(flags == 0, "repair noop: flags remain 0");
     348            1 :     free(loaded);
     349              : }
     350              : 
     351            1 : static void test_build_hdr_archive_unread_flags(void) {
     352              :     /* build_hdr with UNREAD but no real label → flags still has UNSEEN.
     353              :      * The caller (sync loop) is responsible for clearing it when assigning
     354              :      * to _nolabel.  Verify build_hdr itself does not silently drop the flag. */
     355            1 :     const char *raw = "From: X\r\nSubject: Archived\r\nDate: Mon, 14 Apr 2026 08:00:00 +0000\r\n\r\n";
     356            1 :     char *labels[] = {"UNREAD", "CATEGORY_PROMOTIONS"};
     357            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 2);
     358            1 :     ASSERT(hdr != NULL, "build_hdr archive+unread: not NULL");
     359              : 
     360            1 :     const char *last_tab = strrchr(hdr, '\t');
     361            1 :     int flags = atoi(last_tab + 1);
     362              :     /* build_hdr sets UNSEEN; the caller must clear it for _nolabel messages */
     363            1 :     ASSERT((flags & 1) != 0, "build_hdr archive+unread: UNSEEN set by build_hdr (caller clears it)");
     364              : 
     365            1 :     free(hdr);
     366              : }
     367              : 
     368              : /* ── pending_fetch queue (local_store) ───────────────────────────────── */
     369              : 
     370            1 : static void test_pending_fetch_empty_initially(void) {
     371            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     372            1 :     setup_gmail_test_env(home);
     373              : 
     374            1 :     local_pending_fetch_clear();
     375            1 :     ASSERT(local_pending_fetch_count() == 0, "pending_fetch: empty initially");
     376            1 :     int count = -1;
     377            1 :     char (*uids)[17] = local_pending_fetch_load(&count);
     378            1 :     ASSERT(count == 0, "pending_fetch: load count is 0");
     379            1 :     free(uids);
     380              : }
     381              : 
     382            1 : static void test_pending_fetch_add_and_load(void) {
     383            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     384            1 :     setup_gmail_test_env(home);
     385            1 :     local_pending_fetch_clear();
     386              : 
     387            1 :     const char *uid1 = "aaaa000000000001";
     388            1 :     const char *uid2 = "aaaa000000000002";
     389            1 :     ASSERT(local_pending_fetch_add(uid1) == 0, "pending_fetch: add uid1");
     390            1 :     ASSERT(local_pending_fetch_add(uid2) == 0, "pending_fetch: add uid2");
     391              : 
     392            1 :     ASSERT(local_pending_fetch_count() == 2, "pending_fetch: count == 2");
     393              : 
     394            1 :     int count = 0;
     395            1 :     char (*uids)[17] = local_pending_fetch_load(&count);
     396            1 :     ASSERT(count == 2, "pending_fetch: load returns 2");
     397            1 :     ASSERT(uids != NULL, "pending_fetch: uids not NULL");
     398            1 :     ASSERT(strcmp(uids[0], uid1) == 0 || strcmp(uids[1], uid1) == 0,
     399              :            "pending_fetch: uid1 present");
     400            1 :     ASSERT(strcmp(uids[0], uid2) == 0 || strcmp(uids[1], uid2) == 0,
     401              :            "pending_fetch: uid2 present");
     402            1 :     free(uids);
     403              : }
     404              : 
     405            1 : static void test_pending_fetch_remove(void) {
     406            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     407            1 :     setup_gmail_test_env(home);
     408            1 :     local_pending_fetch_clear();
     409              : 
     410            1 :     local_pending_fetch_add("bbbb000000000001");
     411            1 :     local_pending_fetch_add("bbbb000000000002");
     412            1 :     local_pending_fetch_add("bbbb000000000003");
     413              : 
     414            1 :     local_pending_fetch_remove("bbbb000000000002");
     415              : 
     416            1 :     int count = 0;
     417            1 :     char (*uids)[17] = local_pending_fetch_load(&count);
     418            1 :     ASSERT(count == 2, "pending_fetch remove: 2 entries remain");
     419            1 :     int found2 = 0;
     420            3 :     for (int i = 0; i < count; i++)
     421            2 :         if (strcmp(uids[i], "bbbb000000000002") == 0) found2 = 1;
     422            1 :     ASSERT(!found2, "pending_fetch remove: uid2 gone");
     423            1 :     free(uids);
     424              : }
     425              : 
     426            1 : static void test_pending_fetch_clear(void) {
     427            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     428            1 :     setup_gmail_test_env(home);
     429              : 
     430            1 :     local_pending_fetch_add("cccc000000000001");
     431            1 :     local_pending_fetch_add("cccc000000000002");
     432            1 :     ASSERT(local_pending_fetch_count() >= 2, "pending_fetch clear: non-zero before clear");
     433              : 
     434            1 :     local_pending_fetch_clear();
     435            1 :     ASSERT(local_pending_fetch_count() == 0, "pending_fetch clear: zero after clear");
     436              : }
     437              : 
     438            1 : static void test_pending_fetch_count_matches_load(void) {
     439            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     440            1 :     setup_gmail_test_env(home);
     441            1 :     local_pending_fetch_clear();
     442              : 
     443            6 :     for (int i = 0; i < 5; i++) {
     444              :         char uid[17];
     445            5 :         snprintf(uid, sizeof(uid), "dddd%012d", i);
     446            5 :         local_pending_fetch_add(uid);
     447              :     }
     448              : 
     449            1 :     int cnt_fast = local_pending_fetch_count();
     450            1 :     int cnt_load = 0;
     451            1 :     char (*uids)[17] = local_pending_fetch_load(&cnt_load);
     452            1 :     ASSERT(cnt_fast == cnt_load,
     453              :            "pending_fetch: count() matches load() count");
     454            1 :     free(uids);
     455              : }
     456              : 
     457              : /* ── gmail_sync_rebuild_indexes ──────────────────────────────────────────── */
     458              : 
     459              : /*
     460              :  * Exercise the full rebuild_label_indexes + gmail_sync_rebuild_indexes code
     461              :  * path by populating a set of .hdr files then calling the public function.
     462              :  *
     463              :  * Coverage goal: lines 192-338 (rebuild_label_indexes) + 346-357
     464              :  * (gmail_sync_rebuild_indexes).
     465              :  */
     466            1 : static void test_rebuild_indexes_empty_store(void) {
     467              :     /* With no messages, rebuild_indexes is a no-op but must not crash. */
     468            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     469            1 :     setup_gmail_test_env(home);
     470              : 
     471            1 :     int rc = gmail_sync_rebuild_indexes();
     472            1 :     ASSERT(rc == 0, "rebuild_indexes empty: returns 0");
     473              : }
     474              : 
     475            1 : static void test_rebuild_indexes_basic(void) {
     476            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     477            1 :     setup_gmail_test_env(home);
     478              : 
     479              :     /* Write a few .hdr files with known labels */
     480            1 :     const char *uid1 = "1100000000000001";
     481            1 :     const char *uid2 = "1100000000000002";
     482            1 :     const char *uid3 = "1100000000000003";
     483              : 
     484              :     /* uid1: INBOX + UNREAD (flags=1) */
     485            1 :     const char *hdr1 = "Alice\tHello\t2026-04-01\tINBOX,UNREAD\t1";
     486            1 :     local_hdr_save("", uid1, hdr1, strlen(hdr1));
     487              : 
     488              :     /* uid2: STARRED only (flags=2) */
     489            1 :     const char *hdr2 = "Bob\tStarred\t2026-04-02\tSTARRED\t2";
     490            1 :     local_hdr_save("", uid2, hdr2, strlen(hdr2));
     491              : 
     492              :     /* uid3: SPAM (flags=0) — should be indexed as _spam */
     493            1 :     const char *hdr3 = "Eve\tSpam\t2026-04-03\tSPAM\t0";
     494            1 :     local_hdr_save("", uid3, hdr3, strlen(hdr3));
     495              : 
     496            1 :     int rc = gmail_sync_rebuild_indexes();
     497            1 :     ASSERT(rc == 0, "rebuild_indexes basic: returns 0");
     498              : 
     499              :     /* Verify INBOX index contains uid1 */
     500            1 :     char (*idx_uids)[17] = NULL;
     501            1 :     int idx_count = 0;
     502            1 :     int load_rc = label_idx_load("INBOX", &idx_uids, &idx_count);
     503            1 :     ASSERT(load_rc == 0, "rebuild_indexes: INBOX index loaded");
     504            1 :     int found1 = 0;
     505            2 :     for (int i = 0; i < idx_count; i++)
     506            1 :         if (strcmp(idx_uids[i], uid1) == 0) found1 = 1;
     507            1 :     free(idx_uids);
     508            1 :     ASSERT(found1, "rebuild_indexes: uid1 in INBOX index");
     509              : 
     510              :     /* Verify _spam index contains uid3 */
     511            1 :     idx_uids = NULL; idx_count = 0;
     512            1 :     load_rc = label_idx_load("_spam", &idx_uids, &idx_count);
     513            1 :     ASSERT(load_rc == 0, "rebuild_indexes: _spam index loaded");
     514            1 :     int found3 = 0;
     515            2 :     for (int i = 0; i < idx_count; i++)
     516            1 :         if (strcmp(idx_uids[i], uid3) == 0) found3 = 1;
     517            1 :     free(idx_uids);
     518            1 :     ASSERT(found3, "rebuild_indexes: uid3 in _spam index");
     519              : }
     520              : 
     521            1 : static void test_rebuild_indexes_nolabel(void) {
     522              :     /* A message with only CATEGORY_ labels and no real labels → goes to _nolabel.
     523              :      * Note: UNREAD is a real (non-CATEGORY_) label, so we must NOT include it
     524              :      * here — otherwise has_real=1 and the message won't go to _nolabel. */
     525            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     526            1 :     setup_gmail_test_env(home);
     527              : 
     528            1 :     const char *uid = "2200000000000001";
     529              :     /* Only category label, no UNREAD (flags=0) → should go to _nolabel */
     530            1 :     const char *hdr = "Cat\tCategory mail\t2026-04-04\tCATEGORY_PROMOTIONS\t0";
     531            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     532              : 
     533            1 :     int rc = gmail_sync_rebuild_indexes();
     534            1 :     ASSERT(rc == 0, "rebuild_indexes nolabel: returns 0");
     535              : 
     536              :     /* _nolabel index should contain this uid */
     537            1 :     char (*idx_uids)[17] = NULL;
     538            1 :     int idx_count = 0;
     539            1 :     int load_rc = label_idx_load("_nolabel", &idx_uids, &idx_count);
     540            1 :     ASSERT(load_rc == 0, "rebuild_indexes nolabel: _nolabel index loaded");
     541            1 :     int found = 0;
     542            3 :     for (int i = 0; i < idx_count; i++)
     543            2 :         if (strcmp(idx_uids[i], uid) == 0) found = 1;
     544            1 :     free(idx_uids);
     545            1 :     ASSERT(found, "rebuild_indexes nolabel: uid in _nolabel");
     546              : 
     547              :     /* The flags field should remain 0 (no UNSEEN to clear) */
     548            1 :     char *loaded = local_hdr_load("", uid);
     549            1 :     ASSERT(loaded != NULL, "rebuild_indexes nolabel: hdr still exists");
     550            1 :     const char *last_tab = strrchr(loaded, '\t');
     551            1 :     ASSERT(last_tab != NULL, "rebuild_indexes nolabel: flags tab present");
     552            1 :     int flags = atoi(last_tab + 1);
     553            1 :     ASSERT((flags & 1) == 0, "rebuild_indexes nolabel: UNSEEN clear for archived msg");
     554            1 :     free(loaded);
     555              : }
     556              : 
     557            1 : static void test_rebuild_indexes_many_labels(void) {
     558              :     /* Write many messages to force realloc in rebuild_label_indexes.
     559              :      * Each message has 6 labels → ~cap*5 pairs initial cap, then grows. */
     560            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     561            1 :     setup_gmail_test_env(home);
     562              : 
     563              :     /* Write 12 messages, each with multiple labels, to force the realloc path */
     564           13 :     for (int i = 0; i < 12; i++) {
     565              :         char uid[17];
     566           12 :         snprintf(uid, sizeof(uid), "3300%012d", i);
     567              :         char hdr[256];
     568           12 :         snprintf(hdr, sizeof(hdr),
     569              :                  "Sender%d\tSubject%d\t2026-04-01\tINBOX,UNREAD,STARRED,SENT,DRAFT,Work\t3",
     570              :                  i, i);
     571           12 :         local_hdr_save("", uid, hdr, strlen(hdr));
     572              :     }
     573              : 
     574            1 :     int rc = gmail_sync_rebuild_indexes();
     575            1 :     ASSERT(rc == 0, "rebuild_indexes many: returns 0");
     576              : 
     577              :     /* INBOX should have all 12 */
     578            1 :     char (*idx_uids)[17] = NULL;
     579            1 :     int idx_count = 0;
     580            1 :     label_idx_load("INBOX", &idx_uids, &idx_count);
     581            1 :     ASSERT(idx_count >= 12, "rebuild_indexes many: INBOX has >= 12 entries");
     582            1 :     free(idx_uids);
     583              : }
     584              : 
     585            1 : static void test_rebuild_indexes_trash(void) {
     586              :     /* TRASH label should be indexed as _trash */
     587            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     588            1 :     setup_gmail_test_env(home);
     589              : 
     590            1 :     const char *uid = "4400000000000001";
     591            1 :     const char *hdr = "Trashed\tDeleted\t2026-04-05\tTRASH\t0";
     592            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     593              : 
     594            1 :     gmail_sync_rebuild_indexes();
     595              : 
     596            1 :     char (*idx_uids)[17] = NULL;
     597            1 :     int idx_count = 0;
     598            1 :     label_idx_load("_trash", &idx_uids, &idx_count);
     599            1 :     int found = 0;
     600            2 :     for (int i = 0; i < idx_count; i++)
     601            1 :         if (strcmp(idx_uids[i], uid) == 0) found = 1;
     602            1 :     free(idx_uids);
     603            1 :     ASSERT(found, "rebuild_indexes trash: uid in _trash index");
     604              : }
     605              : 
     606            1 : static void test_rebuild_indexes_flags_sync(void) {
     607              :     /* A message where flags integer is inconsistent with labels:
     608              :      * labels say UNREAD,STARRED but flags field says 0.
     609              :      * rebuild_label_indexes must update the flags field. */
     610            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     611            1 :     setup_gmail_test_env(home);
     612              : 
     613            1 :     const char *uid = "5500000000000001";
     614              :     /* flags=0 but UNREAD and STARRED present → new_flags should be 3 */
     615            1 :     const char *hdr = "X\tFlagsSync\t2026-04-06\tINBOX,UNREAD,STARRED\t0";
     616            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     617              : 
     618            1 :     gmail_sync_rebuild_indexes();
     619              : 
     620            1 :     char *loaded = local_hdr_load("", uid);
     621            1 :     ASSERT(loaded != NULL, "flags_sync: hdr still exists");
     622            1 :     const char *last_tab = strrchr(loaded, '\t');
     623            1 :     int flags = last_tab ? atoi(last_tab + 1) : -1;
     624            1 :     ASSERT((flags & 1) != 0, "flags_sync: UNSEEN bit set after rebuild");
     625            1 :     ASSERT((flags & 2) != 0, "flags_sync: FLAGGED bit set after rebuild");
     626            1 :     free(loaded);
     627              : }
     628              : 
     629            1 : static void test_rebuild_indexes_hdr_no_tabs(void) {
     630              :     /* A malformed .hdr with no tabs should be gracefully skipped. */
     631            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     632            1 :     setup_gmail_test_env(home);
     633              : 
     634            1 :     const char *uid = "6600000000000001";
     635              :     /* No tab separators — rebuild_label_indexes should skip gracefully */
     636            1 :     const char *hdr = "malformed-hdr-no-tabs";
     637            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
     638              : 
     639            1 :     int rc = gmail_sync_rebuild_indexes();
     640            1 :     ASSERT(rc == 0, "rebuild_indexes no-tabs: no crash, returns 0");
     641              : }
     642              : 
     643              : /* ── gmail_sync_incremental: re-verify no-history path after store init ─── */
     644              : 
     645            1 : static void test_incremental_with_saved_history_no_server(void) {
     646              :     /* Verify that gmail_sync_incremental returns -2 whenever there is no
     647              :      * saved historyId, regardless of prior store state.  This exercises the
     648              :      * early-return branch in gmail_sync_incremental (lines 794-798). */
     649            1 :     const char home[] = "/tmp/email-cli-gmail-sync-test";
     650            1 :     setup_gmail_test_env(home);
     651              : 
     652              :     /* Ensure no history id is saved (fresh environment) */
     653            1 :     char *hid = local_gmail_history_load();
     654              :     /* If some previous test left a history file, skip gracefully */
     655            1 :     if (hid) {
     656            0 :         free(hid);
     657            0 :         ASSERT(1, "incremental no-server: history present from prev test, skipped");
     658            0 :         return;
     659              :     }
     660              : 
     661            1 :     int rc = gmail_sync_incremental(NULL);
     662            1 :     ASSERT(rc == -2, "incremental no-server: no historyId → -2");
     663              : }
     664              : 
     665              : /* ── build_hdr: SPAM label sets MSG_FLAG_JUNK ─────────────────────────────── */
     666              : 
     667            1 : static void test_build_hdr_spam_flag(void) {
     668            1 :     const char *raw = "From: Spammer\r\nSubject: Buy now\r\nDate: Mon, 14 Apr 2026 08:00:00 +0000\r\n\r\n";
     669            1 :     char *labels[] = {"SPAM"};
     670            1 :     char *hdr = gmail_sync_build_hdr(raw, labels, 1);
     671            1 :     ASSERT(hdr != NULL, "build_hdr spam: not NULL");
     672              : 
     673              :     /* SPAM label sets MSG_FLAG_JUNK (1<<6 = 64 per local_store.h) */
     674            1 :     const char *last_tab = strrchr(hdr, '\t');
     675            1 :     ASSERT(last_tab != NULL, "build_hdr spam: flags tab present");
     676            1 :     int flags = atoi(last_tab + 1);
     677            1 :     ASSERT((flags & 64) != 0, "build_hdr spam: JUNK flag set");
     678              : 
     679            1 :     free(hdr);
     680              : }
     681              : 
     682              : /* ── Mock servers for gmail_sync tests ──────────────────────────────── */
     683              : 
     684              : /*
     685              :  * reconcile_server: responds to:
     686              :  *   GET /messages  → 2-message list with historyId
     687              :  *   GET /labels    → label list
     688              :  *   GET /profile   → profile with historyId (fallback)
     689              :  *   GET /messages/{id} → raw message body
     690              :  *   any other      → 404
     691              :  */
     692            5 : static void run_reconcile_server(int lfd, int count) {
     693            5 :     const char *raw_email =
     694              :         "From: alice@example.com\r\n"
     695              :         "To: me@gmail.com\r\n"
     696              :         "Subject: Reconcile Test\r\n"
     697              :         "Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n"
     698              :         "\r\n"
     699              :         "Body here.\r\n";
     700            5 :     char *b64 = gs_b64encode(raw_email, strlen(raw_email));
     701              : 
     702            5 :     struct sockaddr_in cli = {0};
     703            5 :     socklen_t cli_len = sizeof(cli);
     704           15 :     for (int i = 0; i < count; i++) {
     705           10 :         int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
     706           10 :         if (cfd < 0) break;
     707           10 :         struct timeval tv = {.tv_sec = 5, .tv_usec = 0};
     708           10 :         setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
     709              : 
     710              :         char buf[4096];
     711           10 :         if (gs_read_req(cfd, buf, (int)sizeof(buf)) <= 0) { close(cfd); continue; }
     712              : 
     713           10 :         char method[16] = {0}, path[2048] = {0};
     714           10 :         sscanf(buf, "%15s %2047s", method, path);
     715              : 
     716           10 :         if (strstr(path, "/messages") && !strstr(path, "/messages/") &&
     717            3 :             strcmp(method, "GET") == 0) {
     718            3 :             gs_send_json(cfd, 200,
     719              :                 "{\"messages\":["
     720              :                 "{\"id\":\"aabbcc0000000001\",\"threadId\":\"t1\"},"
     721              :                 "{\"id\":\"aabbcc0000000002\",\"threadId\":\"t2\"}"
     722              :                 "],\"resultSizeEstimate\":2,\"historyId\":\"99001\"}");
     723           11 :         } else if (strstr(path, "/messages/") && strcmp(method, "GET") == 0) {
     724              :             char body_buf[2048];
     725            4 :             snprintf(body_buf, sizeof(body_buf),
     726              :                 "{\"id\":\"aabbcc0000000001\","
     727              :                 "\"labelIds\":[\"INBOX\",\"UNREAD\"],"
     728              :                 "\"raw\":\"%s\"}",
     729              :                 b64 ? b64 : "");
     730            4 :             gs_send_json(cfd, 200, body_buf);
     731            3 :         } else if (strstr(path, "/labels") && strcmp(method, "GET") == 0) {
     732            3 :             gs_send_json(cfd, 200,
     733              :                 "{\"labels\":["
     734              :                 "{\"id\":\"INBOX\",\"name\":\"INBOX\"},"
     735              :                 "{\"id\":\"UNREAD\",\"name\":\"UNREAD\"}"
     736              :                 "]}");
     737            0 :         } else if (strstr(path, "/profile")) {
     738            0 :             gs_send_json(cfd, 200,
     739              :                 "{\"historyId\":\"99001\","
     740              :                 "\"emailAddress\":\"test@gmail.com\"}");
     741              :         } else {
     742            0 :             gs_send_json(cfd, 404, "{}");
     743              :         }
     744           10 :         close(cfd);
     745              :     }
     746            5 :     free(b64);
     747            5 :     close(lfd);
     748            5 :     GCOV_FLUSH();
     749            0 :     _exit(0);
     750              : }
     751              : 
     752            5 : static pid_t start_reconcile_server(int *port_out, int count) {
     753            5 :     int lfd = gs_make_listener(port_out);
     754            5 :     if (lfd < 0) return -1;
     755            5 :     pid_t pid = fork();
     756           10 :     if (pid < 0) { close(lfd); return -1; }
     757           10 :     if (pid == 0) run_reconcile_server(lfd, count);
     758            5 :     close(lfd);
     759            5 :     return pid;
     760              : }
     761              : 
     762            1 : static void test_reconcile_success(void) {
     763            1 :     reset_gmail_test_env();
     764            1 :     local_pending_fetch_clear();
     765              : 
     766            1 :     int port = 0;
     767              :     /* 2 connections: list_messages + list_labels */
     768            1 :     pid_t pid = start_reconcile_server(&port, 2);
     769            1 :     if (pid < 0) { ASSERT(0, "reconcile: could not start mock server"); return; }
     770              : 
     771            1 :     usleep(20000);
     772              : 
     773            1 :     GmailClient *gc = gs_make_client(port);
     774            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "reconcile: client connected"); return; }
     775              : 
     776            1 :     int queued = gmail_sync_reconcile(gc);
     777            1 :     int pending_cnt = local_pending_fetch_count();
     778            1 :     gmail_disconnect(gc);
     779            1 :     gs_wait_child(pid);
     780              : 
     781            1 :     ASSERT(queued == 2, "reconcile: 2 messages queued");
     782            1 :     ASSERT(pending_cnt == 2, "reconcile: pending_fetch count == 2");
     783              : }
     784              : 
     785            1 : static void test_reconcile_with_cached_messages(void) {
     786              :     /* Pre-cache one of the two server messages so reconcile sees 1 cached
     787              :      * and 1 queued.  Covers lines 450-456 (the "cached++" path). */
     788            1 :     reset_gmail_test_env();
     789            1 :     local_pending_fetch_clear();
     790              : 
     791              :     /* Pre-save uid1 (already cached) */
     792            1 :     const char *uid1 = "aabbcc0000000001";
     793            1 :     const char *raw = "From: alice@example.com\r\nSubject: Reconcile Test\r\n\r\nBody\r\n";
     794            1 :     local_msg_save("", uid1, raw, strlen(raw));
     795            1 :     local_hdr_save("", uid1, "Alice\tReconcile Test\t2024-01-01\tINBOX\t0",
     796              :                    strlen("Alice\tReconcile Test\t2024-01-01\tINBOX\t0"));
     797              : 
     798            1 :     int port = 0;
     799              :     /* 2 connections: list_messages + list_labels */
     800            1 :     pid_t pid = start_reconcile_server(&port, 2);
     801            1 :     if (pid < 0) { ASSERT(0, "reconcile_cached: could not start mock server"); return; }
     802              : 
     803            1 :     usleep(20000);
     804              : 
     805            1 :     GmailClient *gc = gs_make_client(port);
     806            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "reconcile_cached: client connected"); return; }
     807              : 
     808            1 :     int queued = gmail_sync_reconcile(gc);
     809            1 :     int pend_cnt = local_pending_fetch_count();
     810            1 :     gmail_disconnect(gc);
     811            1 :     gs_wait_child(pid);
     812              : 
     813              :     /* uid1 is cached, uid2 is new → 1 queued */
     814            1 :     ASSERT(queued == 1, "reconcile_cached: 1 new message queued");
     815            1 :     ASSERT(pend_cnt == 1, "reconcile_cached: pending count == 1");
     816              : }
     817              : 
     818            1 : static void test_fetch_pending_empty_queue(void) {
     819              :     /* fetch_pending with an empty queue should return 0 immediately. */
     820            1 :     reset_gmail_test_env();
     821            1 :     local_pending_fetch_clear();
     822              : 
     823              :     /* No network needed — empty queue exits early */
     824            1 :     setenv("GMAIL_TEST_TOKEN", "test_access_token", 1);
     825            1 :     setenv("GMAIL_API_BASE_URL", "http://127.0.0.1:1/gmail/v1/users/me", 1);
     826            1 :     Config cfg = {0};
     827            1 :     cfg.gmail_mode = 1;
     828            1 :     cfg.gmail_refresh_token = "fake";
     829            1 :     GmailClient *gc = gmail_connect(&cfg);
     830            1 :     if (!gc) { ASSERT(0, "fetch_empty: client created"); return; }
     831              : 
     832            1 :     int fetched = gmail_sync_fetch_pending(gc);
     833            1 :     gmail_disconnect(gc);
     834              : 
     835            1 :     ASSERT(fetched == 0, "fetch_empty: returns 0 on empty queue");
     836              : }
     837              : 
     838              : /* Server that returns a message with only CATEGORY_ labels (no real labels).
     839              :  * Used to test the _nolabel path in store_fetched_message (lines 397-404). */
     840            1 : static void run_nolabel_msg_server(int lfd, int count) {
     841            1 :     const char *raw_email =
     842              :         "From: promo@example.com\r\n"
     843              :         "To: me@gmail.com\r\n"
     844              :         "Subject: Promotions\r\n"
     845              :         "Date: Tue, 01 Jan 2025 00:00:00 +0000\r\n"
     846              :         "\r\n"
     847              :         "Click here to buy things!\r\n";
     848            1 :     char *b64 = gs_b64encode(raw_email, strlen(raw_email));
     849              : 
     850            1 :     struct sockaddr_in cli = {0};
     851            1 :     socklen_t cli_len = sizeof(cli);
     852            2 :     for (int i = 0; i < count; i++) {
     853            1 :         int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
     854            1 :         if (cfd < 0) break;
     855            1 :         struct timeval tv = {.tv_sec = 5, .tv_usec = 0};
     856            1 :         setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
     857              : 
     858              :         char buf[4096];
     859            1 :         if (gs_read_req(cfd, buf, (int)sizeof(buf)) <= 0) { close(cfd); continue; }
     860              : 
     861            1 :         char method[16] = {0}, path[2048] = {0};
     862            1 :         sscanf(buf, "%15s %2047s", method, path);
     863              : 
     864            2 :         if (strstr(path, "/messages/") && strcmp(method, "GET") == 0) {
     865              :             /* Message with only CATEGORY_ label and IMPORTANT (both non-real).
     866              :              * IMPORTANT is filtered (filtered_label→skip).
     867              :              * CATEGORY_PROMOTIONS passes filter but is_category_label→true.
     868              :              * Result: has_real_label=0 → goes to _nolabel with UNSEEN cleared. */
     869              :             char body_buf[2048];
     870            1 :             snprintf(body_buf, sizeof(body_buf),
     871              :                 "{\"id\":\"nolabel000000001\","
     872              :                 "\"labelIds\":[\"CATEGORY_PROMOTIONS\",\"IMPORTANT\"],"
     873              :                 "\"raw\":\"%s\"}",
     874              :                 b64 ? b64 : "");
     875            1 :             gs_send_json(cfd, 200, body_buf);
     876              :         } else {
     877            0 :             gs_send_json(cfd, 404, "{}");
     878              :         }
     879            1 :         close(cfd);
     880              :     }
     881            1 :     free(b64);
     882            1 :     close(lfd);
     883            1 :     GCOV_FLUSH();
     884            0 :     _exit(0);
     885              : }
     886              : 
     887            1 : static pid_t start_nolabel_msg_server(int *port_out, int count) {
     888            1 :     int lfd = gs_make_listener(port_out);
     889            1 :     if (lfd < 0) return -1;
     890            1 :     pid_t pid = fork();
     891            2 :     if (pid < 0) { close(lfd); return -1; }
     892            2 :     if (pid == 0) run_nolabel_msg_server(lfd, count);
     893            1 :     close(lfd);
     894            1 :     return pid;
     895              : }
     896              : 
     897            1 : static void test_fetch_pending_nolabel_message(void) {
     898              :     /* Message has CATEGORY_PROMOTIONS + IMPORTANT labels.
     899              :      * CATEGORY_PROMOTIONS: is_category_label=true → has_real_label not set.
     900              :      * IMPORTANT: filtered → skipped entirely.
     901              :      * Result: has_real_label=0 → goes to _nolabel (covers lines 397-403).
     902              :      * No UNREAD → cur_flags=0 → UNSEEN was never set, flags remain 0. */
     903            1 :     reset_gmail_test_env();
     904            1 :     local_pending_fetch_clear();
     905              : 
     906            1 :     const char *uid = "nolabel000000001";
     907            1 :     local_pending_fetch_add(uid);
     908              : 
     909            1 :     int port = 0;
     910            1 :     pid_t pid = start_nolabel_msg_server(&port, 1);
     911            1 :     if (pid < 0) { ASSERT(0, "fetch_nolabel: no server"); return; }
     912              : 
     913            1 :     usleep(20000);
     914              : 
     915            1 :     GmailClient *gc = gs_make_client(port);
     916            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "fetch_nolabel: client connected"); return; }
     917              : 
     918            1 :     int fetched = gmail_sync_fetch_pending(gc);
     919            1 :     gmail_disconnect(gc);
     920            1 :     gs_wait_child(pid);
     921              : 
     922            1 :     ASSERT(fetched == 1, "fetch_nolabel: 1 message downloaded");
     923            1 :     ASSERT(local_msg_exists("", uid), "fetch_nolabel: .eml saved");
     924              : 
     925              :     /* Should be in _nolabel index */
     926            1 :     char (*idx_uids)[17] = NULL;
     927            1 :     int idx_count = 0;
     928            1 :     label_idx_load("_nolabel", &idx_uids, &idx_count);
     929            1 :     int found = 0;
     930            2 :     for (int i = 0; i < idx_count; i++)
     931            1 :         if (strcmp(idx_uids[i], uid) == 0) found = 1;
     932            1 :     free(idx_uids);
     933            1 :     ASSERT(found, "fetch_nolabel: uid in _nolabel index");
     934              : 
     935              :     /* UNSEEN flag should be cleared (archived messages are always read) */
     936            1 :     char *hdr = local_hdr_load("", uid);
     937            1 :     if (hdr) {
     938            1 :         const char *last_tab = strrchr(hdr, '\t');
     939            1 :         int flags = last_tab ? atoi(last_tab + 1) : -1;
     940            1 :         ASSERT((flags & 1) == 0, "fetch_nolabel: UNSEEN cleared for archived msg");
     941            1 :         free(hdr);
     942              :     }
     943              : }
     944              : 
     945            1 : static void test_fetch_pending_with_rules(void) {
     946              :     /* Write a mail rule that matches alice@example.com, then fetch_pending
     947              :      * so apply_rules_to_new_message is called.  Covers lines 109-167. */
     948            1 :     reset_gmail_test_env();
     949            1 :     local_pending_fetch_clear();
     950              : 
     951              :     /* Write a rules.ini for the test account */
     952              :     char rules_dir[512];
     953            1 :     snprintf(rules_dir, sizeof(rules_dir),
     954              :              "/tmp/email-cli-gmail-sync-test/.config/email-cli/accounts/csjpeterjaket@gmail.com");
     955              :     /* Use system() with literal paths — no variable in rm -rf */
     956            1 :     int _sr2 = system("mkdir -p '/tmp/email-cli-gmail-sync-test/.config/email-cli/accounts/csjpeterjaket@gmail.com'"); (void)_sr2;
     957              : 
     958              :     char rules_path[600];
     959            1 :     snprintf(rules_path, sizeof(rules_path), "%s/rules.ini", rules_dir);
     960            1 :     FILE *f = fopen(rules_path, "w");
     961            1 :     if (f) {
     962            1 :         fputs("[rule \"Test Rule\"]\n", f);
     963            1 :         fputs("if-from = *@example.com\n", f);
     964            1 :         fputs("then-add-label = Filtered\n", f);
     965            1 :         fclose(f);
     966              :     }
     967              : 
     968            1 :     const char *uid = "aabbcc0000000001";
     969            1 :     local_pending_fetch_add(uid);
     970              : 
     971            1 :     int port = 0;
     972              :     /* 1 connection: gmail_fetch_message */
     973            1 :     pid_t pid = start_reconcile_server(&port, 1);
     974            1 :     if (pid < 0) { ASSERT(0, "fetch_rules: no server"); return; }
     975              : 
     976            1 :     usleep(20000);
     977              : 
     978            1 :     GmailClient *gc = gs_make_client(port);
     979            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "fetch_rules: client connected"); return; }
     980              : 
     981            1 :     int fetched = gmail_sync_fetch_pending(gc);
     982            1 :     gmail_disconnect(gc);
     983            1 :     gs_wait_child(pid);
     984              : 
     985            1 :     ASSERT(fetched == 1, "fetch_rules: 1 message downloaded");
     986              :     /* Rule applied — message should be fetched (rules firing doesn't prevent storage) */
     987            1 :     ASSERT(local_msg_exists("", uid), "fetch_rules: .eml saved after rule application");
     988              : }
     989              : 
     990            1 : static void test_reconcile_server_error(void) {
     991              :     /* When the server returns 500 for messages.list, gmail_list_messages
     992              :      * treats it as an empty result (0 messages). Reconcile returns 0 with
     993              :      * nothing queued.  Exercises the error-response code path in list. */
     994            1 :     reset_gmail_test_env();
     995              : 
     996            1 :     int port = 0;
     997            1 :     int lfd = gs_make_listener(&port);
     998            1 :     if (lfd < 0) { ASSERT(0, "reconcile_server_err: no listener"); return; }
     999              : 
    1000            1 :     pid_t pid = fork();
    1001            2 :     if (pid < 0) { close(lfd); ASSERT(0, "reconcile_server_err: fork failed"); return; }
    1002            2 :     if (pid == 0) {
    1003            1 :         struct sockaddr_in cli = {0};
    1004            1 :         socklen_t cli_len = sizeof(cli);
    1005            4 :         for (int i = 0; i < 4; i++) {
    1006            4 :             int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
    1007            4 :             if (cfd < 0) break;
    1008              :             char buf[512];
    1009            3 :             gs_read_req(cfd, buf, (int)sizeof(buf));
    1010            3 :             gs_send_json(cfd, 500, "{\"error\":\"server error\"}");
    1011            3 :             close(cfd);
    1012              :         }
    1013            1 :         close(lfd);
    1014            1 :         GCOV_FLUSH();
    1015            0 :         _exit(0);
    1016              :     }
    1017            1 :     close(lfd);
    1018            1 :     usleep(20000);
    1019              : 
    1020            1 :     GmailClient *gc = gs_make_client(port);
    1021            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "reconcile_server_err: client connected"); return; }
    1022              : 
    1023            1 :     int rc = gmail_sync_reconcile(gc);
    1024            1 :     gmail_disconnect(gc);
    1025            1 :     gs_wait_child(pid);
    1026              : 
    1027              :     /* gmail_list_messages returns 0 even on 500 (treats as empty list).
    1028              :      * So reconcile returns 0 with 0 messages queued. */
    1029            1 :     ASSERT(rc == 0, "reconcile_server_err: returns 0 (empty list) on server 500");
    1030            1 :     ASSERT(local_pending_fetch_count() == 0,
    1031              :            "reconcile_server_err: nothing queued when server returns 500");
    1032              : }
    1033              : 
    1034            1 : static void test_fetch_pending_success(void) {
    1035            1 :     reset_gmail_test_env();
    1036            1 :     local_pending_fetch_clear();
    1037              : 
    1038            1 :     const char *uid = "aabbcc0000000001";
    1039            1 :     local_pending_fetch_add(uid);
    1040              : 
    1041            1 :     int port = 0;
    1042              :     /* 1 connection: gmail_fetch_message for the 1 pending UID */
    1043            1 :     pid_t pid = start_reconcile_server(&port, 1);
    1044            1 :     if (pid < 0) { ASSERT(0, "fetch_pending: no server"); return; }
    1045              : 
    1046            1 :     usleep(20000);
    1047              : 
    1048            1 :     GmailClient *gc = gs_make_client(port);
    1049            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "fetch_pending: client connected"); return; }
    1050              : 
    1051            1 :     int fetched = gmail_sync_fetch_pending(gc);
    1052            1 :     int msg_ok  = local_msg_exists("", uid);
    1053            1 :     int hdr_ok  = local_hdr_exists("", uid);
    1054            1 :     int pend_cnt = local_pending_fetch_count();
    1055            1 :     gmail_disconnect(gc);
    1056            1 :     gs_wait_child(pid);
    1057              : 
    1058            1 :     ASSERT(fetched == 1, "fetch_pending: 1 message downloaded");
    1059            1 :     ASSERT(msg_ok,   "fetch_pending: .eml saved");
    1060            1 :     ASSERT(hdr_ok,   "fetch_pending: .hdr saved");
    1061            1 :     ASSERT(pend_cnt == 0, "fetch_pending: queue empty");
    1062              : }
    1063              : 
    1064            1 : static void test_fetch_pending_already_cached(void) {
    1065            1 :     reset_gmail_test_env();
    1066            1 :     local_pending_fetch_clear();
    1067              : 
    1068            1 :     const char *uid = "bbccdd0000000001";
    1069            1 :     const char *raw = "From: X\r\nSubject: Y\r\n\r\nBody\r\n";
    1070            1 :     local_msg_save("", uid, raw, strlen(raw));
    1071            1 :     const char *hdr = "X\tY\t2024-01-01\tINBOX\t0";
    1072            1 :     local_hdr_save("", uid, hdr, strlen(hdr));
    1073            1 :     local_pending_fetch_add(uid);
    1074              : 
    1075              :     /* Use an unreachable port — no network calls should happen */
    1076            1 :     setenv("GMAIL_TEST_TOKEN", "test_access_token", 1);
    1077            1 :     setenv("GMAIL_API_BASE_URL", "http://127.0.0.1:1/gmail/v1/users/me", 1);
    1078            1 :     Config cfg = {0};
    1079            1 :     cfg.gmail_mode = 1;
    1080            1 :     cfg.gmail_refresh_token = "fake";
    1081            1 :     GmailClient *gc = gmail_connect(&cfg);
    1082            1 :     if (!gc) { ASSERT(0, "fetch_cached: client created"); return; }
    1083              : 
    1084            1 :     int fetched = gmail_sync_fetch_pending(gc);
    1085            1 :     int pend_cnt = local_pending_fetch_count();
    1086            1 :     gmail_disconnect(gc);
    1087              : 
    1088            1 :     ASSERT(fetched == 0, "fetch_cached: 0 downloaded (already cached)");
    1089            1 :     ASSERT(pend_cnt == 0, "fetch_cached: queue cleared");
    1090              : }
    1091              : 
    1092            1 : static void test_fetch_pending_server_error(void) {
    1093            1 :     reset_gmail_test_env();
    1094            1 :     local_pending_fetch_clear();
    1095              : 
    1096            1 :     const char *uid = "ccddee0000000001";
    1097            1 :     local_pending_fetch_add(uid);
    1098              : 
    1099            1 :     int port = 0;
    1100            1 :     int lfd = gs_make_listener(&port);
    1101            1 :     if (lfd < 0) { ASSERT(0, "fetch_err: no listener"); return; }
    1102              : 
    1103            1 :     pid_t pid = fork();
    1104            2 :     if (pid < 0) { close(lfd); ASSERT(0, "fetch_err: fork failed"); return; }
    1105            2 :     if (pid == 0) {
    1106            1 :         struct sockaddr_in cli = {0};
    1107            1 :         socklen_t cli_len = sizeof(cli);
    1108            2 :         for (int i = 0; i < 3; i++) {
    1109            2 :             int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
    1110            2 :             if (cfd < 0) break;
    1111              :             char buf[512];
    1112            1 :             gs_read_req(cfd, buf, (int)sizeof(buf));
    1113            1 :             gs_send_json(cfd, 404, "{\"error\":{\"code\":404}}");
    1114            1 :             close(cfd);
    1115              :         }
    1116            1 :         close(lfd);
    1117            1 :         GCOV_FLUSH();
    1118            0 :         _exit(0);
    1119              :     }
    1120            1 :     close(lfd);
    1121            1 :     usleep(20000);
    1122              : 
    1123            1 :     GmailClient *gc = gs_make_client(port);
    1124            1 :     if (!gc) { gs_wait_child(pid); ASSERT(0, "fetch_err: client connected"); return; }
    1125              : 
    1126            1 :     int fetched = gmail_sync_fetch_pending(gc);
    1127            1 :     int pend_cnt = local_pending_fetch_count();
    1128            1 :     gmail_disconnect(gc);
    1129            1 :     gs_wait_child(pid);
    1130              : 
    1131            1 :     ASSERT(fetched == 0, "fetch_err: 0 downloaded on 404");
    1132            1 :     ASSERT(pend_cnt == 1, "fetch_err: uid stays in queue");
    1133              : }
    1134              : 
    1135              : /*
    1136              :  * incremental_server: handles the various gmail_sync_incremental paths.
    1137              :  * Returns history with messagesAdded, labelsAdded, labelsRemoved, messagesDeleted.
    1138              :  */
    1139            1 : static void run_incremental_server(int lfd, int count) {
    1140            1 :     const char *raw_email =
    1141              :         "From: bob@example.com\r\n"
    1142              :         "To: me@gmail.com\r\n"
    1143              :         "Subject: Incremental Test\r\n"
    1144              :         "Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n"
    1145              :         "\r\n"
    1146              :         "Incremental body.\r\n";
    1147            1 :     char *b64 = gs_b64encode(raw_email, strlen(raw_email));
    1148              : 
    1149            1 :     struct sockaddr_in cli = {0};
    1150            1 :     socklen_t cli_len = sizeof(cli);
    1151            6 :     for (int i = 0; i < count; i++) {
    1152            5 :         int cfd = accept(lfd, (struct sockaddr *)&cli, &cli_len);
    1153            5 :         if (cfd < 0) break;
    1154            5 :         struct timeval tv = {.tv_sec = 5, .tv_usec = 0};
    1155            5 :         setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    1156              : 
    1157              :         char buf[4096];
    1158            5 :         if (gs_read_req(cfd, buf, (int)sizeof(buf)) <= 0) { close(cfd); continue; }
    1159              : 
    1160            5 :         char method[16] = {0}, path[2048] = {0};
    1161            5 :         sscanf(buf, "%15s %2047s", method, path);
    1162              : 
    1163            6 :         if (strstr(path, "/history") && strcmp(method, "GET") == 0) {
    1164              :             /* Flat top-level JSON: json_foreach_object searches at top level */
    1165              :             char body_buf[2048];
    1166            1 :             snprintf(body_buf, sizeof(body_buf),
    1167              :                 "{"
    1168              :                 "\"historyId\":\"99999\","
    1169              :                 "\"messagesAdded\":["
    1170              :                 "  {\"id\":\"incr000000000001\"}"
    1171              :                 "],"
    1172              :                 "\"labelsAdded\":["
    1173              :                 "  {\"id\":\"incr000000000001\",\"labelIds\":[\"STARRED\"]}"
    1174              :                 "],"
    1175              :                 "\"labelsRemoved\":["
    1176              :                 "  {\"id\":\"incr000000000001\",\"labelIds\":[\"UNREAD\"]}"
    1177              :                 "],"
    1178              :                 "\"messagesDeleted\":["
    1179              :                 "  {\"id\":\"incr000000000002\"}"
    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