Skip to main content

omni_dev/gmail/
history_api.rs

1//! Gmail History API wrapper.
2//!
3//! Same cursor-pagination shape as
4//! [`crate::gmail::messages_api::MessagesApi`], but `startHistoryId` is a
5//! required parameter rather than an optional filter. Recovered (and
6//! adapted — see [`crate::gmail::types::HistoryMessageRef`]) from Phase 1's
7//! `0db5a605` deletion; `gmail sync`'s incremental path
8//! (`src/cli/gmail/sync/engine.rs`) is the intended, and first, caller.
9
10use anyhow::Result;
11use url::Url;
12
13use crate::gmail::client::GmailClient;
14use crate::gmail::types::HistoryListResponse;
15use crate::utils::rate_limit::TokenBucket;
16
17/// Maximum page size accepted by `GET /gmail/v1/users/{userId}/history`.
18pub const MAX_PAGE_LIMIT: usize = 500;
19
20/// Per-call upper bound on the number of history records returned by
21/// [`HistoryApi::list_all`], even when the caller passes `limit = 0`.
22pub const HARD_CAP: usize = 10_000;
23
24/// Quota-unit cost of one `history.list` page request, per Google's
25/// documented per-method quota cost table.
26pub const HISTORY_LIST_COST_UNITS: u32 = 2;
27
28/// History API façade.
29#[derive(Debug)]
30pub struct HistoryApi<'a> {
31    client: &'a GmailClient,
32}
33
34impl<'a> HistoryApi<'a> {
35    /// Wraps an existing [`GmailClient`] for history operations.
36    #[must_use]
37    pub fn new(client: &'a GmailClient) -> Self {
38        Self { client }
39    }
40
41    /// Lists mailbox changes since `start_history_id`, returning a single
42    /// page.
43    ///
44    /// Google returns 404 `notFound` when `start_history_id` is older than
45    /// the mailbox's retention window (about a week); `gmail sync` catches
46    /// that specific case and falls back to a full reconciliation pass.
47    pub async fn list(
48        &self,
49        start_history_id: &str,
50        history_types: &[&str],
51        limit: usize,
52        page_token: Option<&str>,
53    ) -> Result<HistoryListResponse> {
54        if limit > MAX_PAGE_LIMIT {
55            return Err(anyhow::anyhow!(
56                "`limit` must be <= {MAX_PAGE_LIMIT} (Gmail history.list per-page cap; use \
57                 `list_all` to auto-paginate)"
58            ));
59        }
60        let url = build_history_list_url(
61            self.client.base_url(),
62            start_history_id,
63            history_types,
64            limit,
65            page_token,
66        )?;
67        self.client
68            .get_parsed(url.as_str(), "Failed to parse history.list response")
69            .await
70    }
71
72    /// Lists mailbox changes since `start_history_id`, auto-paginating via
73    /// cursor as needed. `limit == 0` means "fetch every change up to
74    /// [`HARD_CAP`]" — a deliberate safety limit for this general-purpose
75    /// entry point. See [`Self::list_all_unbounded`] for the one caller that
76    /// must not have it.
77    pub async fn list_all(
78        &self,
79        start_history_id: &str,
80        history_types: &[&str],
81        limit: usize,
82    ) -> Result<HistoryListResponse> {
83        self.paginate(
84            start_history_id,
85            history_types,
86            Some(effective_cap(limit)),
87            None,
88        )
89        .await
90    }
91
92    /// Lists mailbox changes since `start_history_id`, auto-paginating with
93    /// **no cap** — every page is fetched until Gmail stops returning a
94    /// `nextPageToken`.
95    ///
96    /// `gmail sync`'s incremental path is the one caller for which a
97    /// truncated listing is a correctness bug: a history burst larger than
98    /// [`HARD_CAP`] (e.g. a large bulk label operation from another client)
99    /// would otherwise silently drop `messagesAdded`/`messagesDeleted`/
100    /// `labelsAdded`/`labelsRemoved` events past the cap (#1467), the same
101    /// class of bug
102    /// [`crate::gmail::messages_api::MessagesApi::search_all_unbounded_streaming`]
103    /// fixes for the full-mailbox listing path.
104    ///
105    /// `limiter` paces each page request at [`HISTORY_LIST_COST_UNITS`]
106    /// against the caller's quota budget, proactively rather than relying on
107    /// reactive 429/403 retry.
108    pub(crate) async fn list_all_unbounded(
109        &self,
110        start_history_id: &str,
111        history_types: &[&str],
112        limiter: &TokenBucket,
113    ) -> Result<HistoryListResponse> {
114        self.paginate(start_history_id, history_types, None, Some(limiter))
115            .await
116    }
117
118    /// Shared pagination loop backing [`Self::list_all`] and
119    /// [`Self::list_all_unbounded`]. `cap: None` means no ceiling at all —
120    /// only an absent `nextPageToken` stops the loop.
121    async fn paginate(
122        &self,
123        start_history_id: &str,
124        history_types: &[&str],
125        cap: Option<usize>,
126        limiter: Option<&TokenBucket>,
127    ) -> Result<HistoryListResponse> {
128        let mut acc: Option<HistoryListResponse> = None;
129        let mut page_token: Option<String> = None;
130        loop {
131            let collected = acc.as_ref().map_or(0, |r| r.history.len());
132            let page_size = match cap {
133                Some(cap) => (cap - collected).min(MAX_PAGE_LIMIT),
134                None => MAX_PAGE_LIMIT,
135            };
136            if let Some(limiter) = limiter {
137                limiter.acquire(HISTORY_LIST_COST_UNITS).await;
138            }
139            let page = self
140                .list(
141                    start_history_id,
142                    history_types,
143                    page_size,
144                    page_token.as_deref(),
145                )
146                .await?;
147            let next_token = page.next_page_token.clone();
148            match acc.as_mut() {
149                Some(existing) => {
150                    existing.history.extend(page.history);
151                    existing.next_page_token = page.next_page_token;
152                    existing.history_id = page.history_id;
153                }
154                None => acc = Some(page),
155            }
156            let collected = acc.as_ref().map_or(0, |r| r.history.len());
157            let cap_reached = cap.is_some_and(|cap| collected >= cap);
158            if cap_reached || next_token.is_none() {
159                break;
160            }
161            page_token = next_token;
162        }
163        let mut result = acc.unwrap_or_default();
164        if let Some(cap) = cap {
165            result.history.truncate(cap);
166        }
167        Ok(result)
168    }
169}
170
171fn build_history_list_url(
172    base_url: &str,
173    start_history_id: &str,
174    history_types: &[&str],
175    limit: usize,
176    page_token: Option<&str>,
177) -> Result<Url> {
178    let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/history")?;
179    {
180        let mut pairs = url.query_pairs_mut();
181        pairs.append_pair("startHistoryId", start_history_id);
182        for history_type in history_types {
183            pairs.append_pair("historyTypes", history_type);
184        }
185        if limit > 0 {
186            pairs.append_pair("maxResults", &limit.to_string());
187        }
188        if let Some(token) = page_token {
189            pairs.append_pair("pageToken", token);
190        }
191    }
192    Ok(url)
193}
194
195/// Clamps a caller-supplied limit to [`HARD_CAP`], treating `0` as "fetch
196/// as many as the cap allows".
197fn effective_cap(limit: usize) -> usize {
198    if limit == 0 {
199        HARD_CAP
200    } else {
201        limit.min(HARD_CAP)
202    }
203}
204
205#[cfg(test)]
206#[allow(clippy::unwrap_used, clippy::expect_used)]
207mod tests {
208    use super::*;
209    use crate::gmail::auth::{GmailCredentials, GmailScope};
210    use crate::utils::secret::Secret;
211    use std::sync::atomic::{AtomicUsize, Ordering};
212
213    /// A `history.list` responder that serves `full_pages` full pages (each
214    /// with a fresh `nextPageToken`) followed by one terminating page with no
215    /// token — used to prove [`HistoryApi::list_all_unbounded`] keeps
216    /// paginating past whatever [`HARD_CAP`] would have stopped
217    /// [`HistoryApi::list_all`] at.
218    struct SequentialPages {
219        full_pages: usize,
220        calls: AtomicUsize,
221    }
222
223    impl wiremock::Respond for SequentialPages {
224        fn respond(&self, _req: &wiremock::Request) -> wiremock::ResponseTemplate {
225            let call = self.calls.fetch_add(1, Ordering::SeqCst);
226            if call < self.full_pages {
227                let page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
228                    .map(|i| history_record_json(&format!("p{call}-h{i}")))
229                    .collect();
230                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
231                    "history": page,
232                    "nextPageToken": format!("token-{}", call + 1),
233                }))
234            } else {
235                wiremock::ResponseTemplate::new(200)
236                    .set_body_json(serde_json::json!({"history": Vec::<serde_json::Value>::new()}))
237            }
238        }
239    }
240
241    fn test_credentials() -> GmailCredentials {
242        GmailCredentials {
243            client_id: "client-1".to_string(),
244            client_secret: Secret::new("secret-1"),
245            refresh_token: Secret::new("refresh-1"),
246            scope: GmailScope::ReadOnly,
247        }
248    }
249
250    fn dead_client() -> GmailClient {
251        // Routes the session's token endpoint to the same dead address —
252        // otherwise `GmailSession` would try to refresh against the real
253        // Google token endpoint before the API call is ever attempted.
254        let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
255        crate::gmail::client::test_support::replace_session(
256            &mut client,
257            &test_credentials(),
258            "http://127.0.0.1:1",
259        );
260        client
261    }
262
263    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
264        wiremock::Mock::given(wiremock::matchers::method("POST"))
265            .and(wiremock::matchers::path("/token"))
266            .respond_with(
267                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
268                    "access_token": "test-token",
269                    "expires_in": 3600,
270                })),
271            )
272            .mount(server)
273            .await;
274
275        let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
276        crate::gmail::client::test_support::replace_session(
277            &mut client,
278            &test_credentials(),
279            &format!("{}/token", server.uri()),
280        );
281        client
282    }
283
284    fn history_record_json(id: &str) -> serde_json::Value {
285        serde_json::json!({"id": id})
286    }
287
288    fn page_body(
289        ids: &[&str],
290        next_token: Option<&str>,
291        final_history_id: Option<&str>,
292    ) -> serde_json::Value {
293        let history: Vec<serde_json::Value> =
294            ids.iter().map(|id| history_record_json(id)).collect();
295        let mut body = serde_json::json!({"history": history});
296        if let Some(token) = next_token {
297            body["nextPageToken"] = serde_json::json!(token);
298        }
299        if let Some(hid) = final_history_id {
300            body["historyId"] = serde_json::json!(hid);
301        }
302        body
303    }
304
305    // ── URL builders (pure) ──────────────────────────────────────────
306
307    #[test]
308    fn build_history_list_url_always_includes_start_history_id() {
309        let url =
310            build_history_list_url("https://gmail.googleapis.com", "1000", &[], 0, None).unwrap();
311        assert!(url
312            .query_pairs()
313            .any(|pair| pair == ("startHistoryId".into(), "1000".into())));
314    }
315
316    #[test]
317    fn build_history_list_url_repeats_history_types() {
318        let url = build_history_list_url(
319            "https://gmail.googleapis.com",
320            "1000",
321            &["messageAdded", "labelAdded"],
322            0,
323            None,
324        )
325        .unwrap();
326        let query: Vec<_> = url.query_pairs().collect();
327        assert!(query.contains(&("historyTypes".into(), "messageAdded".into())));
328        assert!(query.contains(&("historyTypes".into(), "labelAdded".into())));
329    }
330
331    #[test]
332    fn build_history_list_url_rejects_invalid_base_url() {
333        let err = build_history_list_url("not a url", "1000", &[], 0, None).unwrap_err();
334        assert!(err.to_string().contains("Invalid Gmail base URL"));
335    }
336
337    // ── Standard error paths ─────────────────────────────────────────
338
339    #[tokio::test]
340    async fn list_propagates_api_errors() {
341        let server = wiremock::MockServer::start().await;
342        let client = client_with_bootstrapped_token(&server).await;
343        wiremock::Mock::given(wiremock::matchers::method("GET"))
344            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
345            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
346            .mount(&server)
347            .await;
348
349        let err = HistoryApi::new(&client)
350            .list("1000", &[], 10, None)
351            .await
352            .unwrap_err();
353        assert!(err.to_string().contains("400"));
354    }
355
356    #[tokio::test]
357    async fn list_propagates_404_not_found_for_expired_start_history_id() {
358        let server = wiremock::MockServer::start().await;
359        let client = client_with_bootstrapped_token(&server).await;
360        wiremock::Mock::given(wiremock::matchers::method("GET"))
361            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
362            .respond_with(
363                wiremock::ResponseTemplate::new(404).set_body_json(serde_json::json!({
364                    "error": {"message": "Not Found", "errors": [{"reason": "notFound"}]}
365                })),
366            )
367            .mount(&server)
368            .await;
369
370        let err = HistoryApi::new(&client)
371            .list("1", &[], 10, None)
372            .await
373            .unwrap_err();
374        let msg = err.to_string();
375        assert!(msg.contains("404"));
376        assert!(msg.contains("notFound"));
377    }
378
379    #[tokio::test]
380    async fn list_rejects_limit_above_max_page_limit_client_side() {
381        let client = dead_client();
382        let err = HistoryApi::new(&client)
383            .list("1000", &[], MAX_PAGE_LIMIT + 1, None)
384            .await
385            .unwrap_err();
386        let msg = err.to_string();
387        assert!(msg.contains("limit"));
388        assert!(msg.contains("list_all"));
389    }
390
391    #[tokio::test]
392    async fn list_propagates_network_errors() {
393        // `dead_client()` also points the session's token endpoint at the
394        // dead address, so the failure surfaces during token acquisition
395        // before the history.list request is ever attempted.
396        let client = dead_client();
397        let err = HistoryApi::new(&client)
398            .list("1000", &[], 10, None)
399            .await
400            .unwrap_err();
401        assert!(err
402            .to_string()
403            .contains("Failed to obtain a Gmail access token"));
404    }
405
406    #[tokio::test]
407    async fn list_errors_on_malformed_response() {
408        let server = wiremock::MockServer::start().await;
409        let client = client_with_bootstrapped_token(&server).await;
410        wiremock::Mock::given(wiremock::matchers::method("GET"))
411            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
412            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
413            .mount(&server)
414            .await;
415
416        let err = HistoryApi::new(&client)
417            .list("1000", &[], 10, None)
418            .await
419            .unwrap_err();
420        assert!(err.to_string().contains("Failed to parse"));
421    }
422
423    // ── Pagination ────────────────────────────────────────────────────
424
425    #[tokio::test]
426    async fn list_all_single_page_when_no_next_token() {
427        let server = wiremock::MockServer::start().await;
428        let client = client_with_bootstrapped_token(&server).await;
429        wiremock::Mock::given(wiremock::matchers::method("GET"))
430            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
431            .respond_with(
432                wiremock::ResponseTemplate::new(200).set_body_json(page_body(
433                    &["a", "b"],
434                    None,
435                    Some("2000"),
436                )),
437            )
438            .expect(1)
439            .mount(&server)
440            .await;
441
442        let result = HistoryApi::new(&client)
443            .list_all("1000", &[], 100)
444            .await
445            .unwrap();
446        assert_eq!(result.history.len(), 2);
447        assert_eq!(result.history_id.as_deref(), Some("2000"));
448    }
449
450    #[tokio::test]
451    async fn list_all_follows_next_page_token_to_exhaustion() {
452        let server = wiremock::MockServer::start().await;
453        let client = client_with_bootstrapped_token(&server).await;
454        wiremock::Mock::given(wiremock::matchers::method("GET"))
455            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
456            .and(wiremock::matchers::query_param_is_missing("pageToken"))
457            .respond_with(
458                wiremock::ResponseTemplate::new(200).set_body_json(page_body(
459                    &["a", "b"],
460                    Some("c1"),
461                    None,
462                )),
463            )
464            .expect(1)
465            .mount(&server)
466            .await;
467        wiremock::Mock::given(wiremock::matchers::method("GET"))
468            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
469            .and(wiremock::matchers::query_param("pageToken", "c1"))
470            .respond_with(
471                wiremock::ResponseTemplate::new(200).set_body_json(page_body(
472                    &["c"],
473                    None,
474                    Some("3000"),
475                )),
476            )
477            .expect(1)
478            .mount(&server)
479            .await;
480
481        let result = HistoryApi::new(&client)
482            .list_all("1000", &[], 0)
483            .await
484            .unwrap();
485        let ids: Vec<&str> = result.history.iter().map(|h| h.id.as_str()).collect();
486        assert_eq!(ids, ["a", "b", "c"]);
487        assert_eq!(result.history_id.as_deref(), Some("3000"));
488    }
489
490    #[tokio::test]
491    async fn list_all_stops_at_explicit_limit() {
492        let server = wiremock::MockServer::start().await;
493        let client = client_with_bootstrapped_token(&server).await;
494        wiremock::Mock::given(wiremock::matchers::method("GET"))
495            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
496            .respond_with(
497                wiremock::ResponseTemplate::new(200).set_body_json(page_body(
498                    &["a", "b", "c"],
499                    Some("more"),
500                    None,
501                )),
502            )
503            .expect(1)
504            .mount(&server)
505            .await;
506
507        let result = HistoryApi::new(&client)
508            .list_all("1000", &[], 3)
509            .await
510            .unwrap();
511        assert_eq!(result.history.len(), 3);
512    }
513
514    #[tokio::test]
515    async fn list_all_truncates_to_hard_cap() {
516        let server = wiremock::MockServer::start().await;
517        let client = client_with_bootstrapped_token(&server).await;
518        let full_page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
519            .map(|i| history_record_json(&format!("h{i}")))
520            .collect();
521        let body = serde_json::json!({"history": full_page, "nextPageToken": "always-more"});
522        wiremock::Mock::given(wiremock::matchers::method("GET"))
523            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
524            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
525            .mount(&server)
526            .await;
527
528        let result = HistoryApi::new(&client)
529            .list_all("1000", &[], 0)
530            .await
531            .unwrap();
532        assert_eq!(result.history.len(), HARD_CAP);
533    }
534
535    #[tokio::test]
536    async fn list_all_continues_past_empty_page_with_a_valid_next_page_token() {
537        let server = wiremock::MockServer::start().await;
538        let client = client_with_bootstrapped_token(&server).await;
539        wiremock::Mock::given(wiremock::matchers::method("GET"))
540            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
541            .and(wiremock::matchers::query_param_is_missing("pageToken"))
542            .respond_with(
543                wiremock::ResponseTemplate::new(200).set_body_json(page_body(
544                    &[],
545                    Some("p2"),
546                    None,
547                )),
548            )
549            .expect(1)
550            .mount(&server)
551            .await;
552        wiremock::Mock::given(wiremock::matchers::method("GET"))
553            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
554            .and(wiremock::matchers::query_param("pageToken", "p2"))
555            .respond_with(
556                wiremock::ResponseTemplate::new(200).set_body_json(page_body(
557                    &["a"],
558                    None,
559                    Some("9"),
560                )),
561            )
562            .expect(1)
563            .mount(&server)
564            .await;
565
566        let result = HistoryApi::new(&client)
567            .list_all("1000", &[], 0)
568            .await
569            .unwrap();
570        assert_eq!(result.history.len(), 1);
571    }
572
573    #[tokio::test]
574    async fn list_all_propagates_api_errors_on_first_page() {
575        let server = wiremock::MockServer::start().await;
576        let client = client_with_bootstrapped_token(&server).await;
577        wiremock::Mock::given(wiremock::matchers::method("GET"))
578            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
579            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
580            .mount(&server)
581            .await;
582
583        let err = HistoryApi::new(&client)
584            .list_all("1000", &[], 0)
585            .await
586            .unwrap_err();
587        assert!(err.to_string().contains("403"));
588    }
589
590    // ── list_all_unbounded ───────────────────────────────────────────
591
592    #[tokio::test]
593    async fn list_all_unbounded_does_not_truncate_past_hard_cap() {
594        let server = wiremock::MockServer::start().await;
595        let client = client_with_bootstrapped_token(&server).await;
596        // One more full page than `list_all` would allow before hitting
597        // `HARD_CAP` — a regression back to the capped pagination path would
598        // truncate this result to exactly `HARD_CAP`.
599        let full_pages = HARD_CAP / MAX_PAGE_LIMIT + 1;
600        wiremock::Mock::given(wiremock::matchers::method("GET"))
601            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
602            .respond_with(SequentialPages {
603                full_pages,
604                calls: AtomicUsize::new(0),
605            })
606            .mount(&server)
607            .await;
608
609        let limiter = TokenBucket::new(1_000_000, 1_000_000);
610        let result = HistoryApi::new(&client)
611            .list_all_unbounded("1000", &[], &limiter)
612            .await
613            .unwrap();
614
615        assert_eq!(result.history.len(), full_pages * MAX_PAGE_LIMIT);
616        assert!(result.history.len() > HARD_CAP);
617    }
618
619    #[tokio::test]
620    async fn list_all_unbounded_draws_the_limiter_once_per_page() {
621        let server = wiremock::MockServer::start().await;
622        let client = client_with_bootstrapped_token(&server).await;
623        let full_pages = 4;
624        wiremock::Mock::given(wiremock::matchers::method("GET"))
625            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
626            .respond_with(SequentialPages {
627                full_pages,
628                calls: AtomicUsize::new(0),
629            })
630            .mount(&server)
631            .await;
632
633        // Zero refill: capacity alone is large enough that `acquire` never
634        // actually waits, and with no refill between real HTTP round trips
635        // the token count accurately reflects cumulative debits — a nonzero
636        // refill rate this large would otherwise replenish the bucket back
637        // to full between each network round trip, masking how many times
638        // `acquire` was really called. This test only proves each of the 5
639        // page requests (4 full + 1 terminating) draws
640        // `HISTORY_LIST_COST_UNITS`; `TokenBucket` pacing itself is already
641        // covered by `rate_limit.rs`'s own tests.
642        let limiter = TokenBucket::new(1_000_000, 0);
643        HistoryApi::new(&client)
644            .list_all_unbounded("1000", &[], &limiter)
645            .await
646            .unwrap();
647
648        let page_requests = 5;
649        let expected_spent = f64::from(page_requests * HISTORY_LIST_COST_UNITS);
650        // Exact integer-valued floats (units are whole numbers well within
651        // f64's precision) — cast to compare, avoiding a lint against
652        // strict floating-point equality that doesn't apply here.
653        assert_eq!(
654            limiter.available().await as i64,
655            (1_000_000.0 - expected_spent) as i64
656        );
657    }
658
659    // ── History record shapes (added/deleted/label changes) ──────────
660
661    #[tokio::test]
662    async fn list_parses_messages_added_with_label_ids() {
663        let server = wiremock::MockServer::start().await;
664        let client = client_with_bootstrapped_token(&server).await;
665        wiremock::Mock::given(wiremock::matchers::method("GET"))
666            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
667            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
668                "history": [{
669                    "id": "10",
670                    "messagesAdded": [{
671                        "message": {"id": "m1", "threadId": "t1", "labelIds": ["INBOX", "UNREAD"]}
672                    }]
673                }]
674            })))
675            .mount(&server)
676            .await;
677
678        let result = HistoryApi::new(&client)
679            .list("1", &[], 10, None)
680            .await
681            .unwrap();
682        let added = &result.history[0].messages_added[0].message;
683        assert_eq!(added.id, "m1");
684        assert_eq!(added.thread_id, "t1");
685        assert_eq!(added.label_ids, vec!["INBOX", "UNREAD"]);
686    }
687
688    #[tokio::test]
689    async fn list_parses_messages_deleted_and_label_changes() {
690        let server = wiremock::MockServer::start().await;
691        let client = client_with_bootstrapped_token(&server).await;
692        wiremock::Mock::given(wiremock::matchers::method("GET"))
693            .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
694            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
695                "history": [{
696                    "id": "10",
697                    "messagesDeleted": [{"message": {"id": "m2", "threadId": "t2"}}],
698                    "labelsAdded": [{"message": {"id": "m3", "threadId": "t3"}, "labelIds": ["IMPORTANT"]}],
699                    "labelsRemoved": [{"message": {"id": "m3", "threadId": "t3"}, "labelIds": ["UNREAD"]}]
700                }]
701            })))
702            .mount(&server)
703            .await;
704
705        let result = HistoryApi::new(&client)
706            .list("1", &[], 10, None)
707            .await
708            .unwrap();
709        let record = &result.history[0];
710        assert_eq!(record.messages_deleted[0].message.id, "m2");
711        assert_eq!(record.labels_added[0].label_ids, vec!["IMPORTANT"]);
712        assert_eq!(record.labels_removed[0].label_ids, vec!["UNREAD"]);
713    }
714
715    // ── effective_cap ─────────────────────────────────────────────────
716
717    #[test]
718    fn effective_cap_zero_is_hard_cap() {
719        assert_eq!(effective_cap(0), HARD_CAP);
720    }
721
722    #[test]
723    fn effective_cap_clamps_above_hard_cap() {
724        assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
725    }
726}